Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +33 -6
- app.py +400 -0
- requirements.txt +23 -0
- samudra/__init__.py +12 -0
- samudra/aggregator/LICENSE-ACE.txt +201 -0
- samudra/aggregator/__init__.py +14 -0
- samudra/aggregator/inference/__init__.py +6 -0
- samudra/aggregator/inference/main.py +265 -0
- samudra/aggregator/inference/reduced.py +475 -0
- samudra/aggregator/inference/time_mean.py +350 -0
- samudra/aggregator/loss.py +73 -0
- samudra/aggregator/main.py +112 -0
- samudra/aggregator/metrics.py +142 -0
- samudra/aggregator/plotting.py +140 -0
- samudra/aggregator/train.py +62 -0
- samudra/aggregator/validate/__init__.py +6 -0
- samudra/aggregator/validate/main.py +117 -0
- samudra/aggregator/validate/map.py +122 -0
- samudra/aggregator/validate/reduced.py +165 -0
- samudra/aggregator/validate/snapshot.py +114 -0
- samudra/aggregator/validate/sub_aggregator.py +28 -0
- samudra/backend.py +107 -0
- samudra/cli.py +77 -0
- samudra/config.py +1339 -0
- samudra/config_base.py +166 -0
- samudra/config_schema.py +177 -0
- samudra/configs/data/llc.yaml +26 -0
- samudra/configs/data/obs.yaml +35 -0
- samudra/configs/data/om4.yaml +25 -0
- samudra/configs/data/om4_demo.yaml +51 -0
- samudra/configs/samudra_mini_om4/README.md +11 -0
- samudra/configs/samudra_mini_om4/model.yaml +20 -0
- samudra/configs/samudra_mini_om4/train.yaml +36 -0
- samudra/configs/samudra_multi_om4/README.md +11 -0
- samudra/configs/samudra_multi_om4/eval.yaml +71 -0
- samudra/configs/samudra_multi_om4/model.yaml +45 -0
- samudra/configs/samudra_multi_om4/train.yaml +75 -0
- samudra/configs/samudra_om4/README.md +10 -0
- samudra/configs/samudra_om4/eval.yaml +21 -0
- samudra/configs/samudra_om4/model.yaml +25 -0
- samudra/configs/samudra_om4/train.yaml +37 -0
- samudra/configs/samudra_om4/viz.yaml +25 -0
- samudra/configs/samudra_om4_v1/README.md +10 -0
- samudra/configs/samudra_om4_v1/eval.yaml +20 -0
- samudra/configs/samudra_om4_v1/model.yaml +25 -0
- samudra/configs/samudra_om4_v1/train.yaml +34 -0
- samudra/configs/samudra_om4_v1/viz.yaml +22 -0
- samudra/configs/samudra_om4_v2/README.md +11 -0
- samudra/configs/samudra_om4_v2/eval.yaml +20 -0
- samudra/configs/samudra_om4_v2/model.yaml +25 -0
README.md
CHANGED
|
@@ -1,13 +1,40 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.25.0
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
|
|
|
|
|
|
|
|
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Samudra 2
|
| 3 |
+
emoji: 🌊
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: gray
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.25.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
short_description: Neural ocean emulator rollouts scored against GFDL OM4
|
| 10 |
+
python_version: "3.12"
|
| 11 |
+
startup_duration_timeout: 1h
|
| 12 |
pinned: false
|
| 13 |
+
license: apache-2.0
|
| 14 |
+
models:
|
| 15 |
+
- M2LInES/Samudra2
|
| 16 |
---
|
| 17 |
|
| 18 |
+
# Samudra 2 — a neural ocean emulator
|
| 19 |
+
|
| 20 |
+
[Samudra 2](https://huggingface.co/M2LInES/Samudra2) (M2LInES) is an autoregressive
|
| 21 |
+
deep-learning emulator of the **GFDL OM4 global ocean model**. Given one ocean state plus
|
| 22 |
+
atmospheric forcing it advances temperature, salinity and horizontal currents across 19
|
| 23 |
+
depth levels together with sea surface height — 77 prognostic fields at once — 10 days per
|
| 24 |
+
autoregressive step.
|
| 25 |
+
|
| 26 |
+
This Space runs the released **1° checkpoint** (`onedeg/ema_ckpt.pt`, 84M parameters) on
|
| 27 |
+
genuinely held-out years (2014–2022). The initial condition and the atmospheric forcing are
|
| 28 |
+
streamed live from the public OM4 archive (`m2lines-pubs/Samudra` on the Open Storage
|
| 29 |
+
Network), the emulator is rolled forward on ZeroGPU, and the emulated ocean is scored
|
| 30 |
+
against the OM4 simulation it emulates — including a persistence baseline.
|
| 31 |
+
|
| 32 |
+
## Notes
|
| 33 |
+
|
| 34 |
+
- The vendored `samudra/` package is the upstream
|
| 35 |
+
[`m2lines/Samudra`](https://github.com/m2lines/Samudra) library (Apache-2.0), included
|
| 36 |
+
directly because its full dependency set (SkyPilot, xESMF) cannot be pip-resolved inside
|
| 37 |
+
a Space. Model configuration matches the upstream `samudra_om4_v2` config.
|
| 38 |
+
- Only the 1° checkpoint is demoed: the half- and quarter-degree checkpoints require the
|
| 39 |
+
`hfds_anomalies` boundary field, which is not present in the corresponding public stores.
|
| 40 |
+
- Each emulator step emits two 5-day snapshots, so *n* steps ⇒ 10·*n* days of forecast.
|
app.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Samudra 2 — an autoregressive neural ocean emulator (M2LInES).
|
| 2 |
+
|
| 3 |
+
The app streams real GFDL-OM4 ocean state from public S3 storage, rolls the
|
| 4 |
+
released 1-degree Samudra 2 checkpoint forward autoregressively on ZeroGPU,
|
| 5 |
+
and compares the emulated ocean against the OM4 simulation it emulates.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import spaces # noqa: F401 (must precede any CUDA-touching import)
|
| 9 |
+
|
| 10 |
+
import dataclasses
|
| 11 |
+
import functools
|
| 12 |
+
import os
|
| 13 |
+
import tempfile
|
| 14 |
+
import time
|
| 15 |
+
|
| 16 |
+
import matplotlib
|
| 17 |
+
|
| 18 |
+
matplotlib.use("Agg")
|
| 19 |
+
|
| 20 |
+
import gradio as gr
|
| 21 |
+
import imageio.v2 as imageio
|
| 22 |
+
import matplotlib.pyplot as plt
|
| 23 |
+
import numpy as np
|
| 24 |
+
import s3fs
|
| 25 |
+
import torch
|
| 26 |
+
import xarray as xr
|
| 27 |
+
from huggingface_hub import hf_hub_download
|
| 28 |
+
|
| 29 |
+
import samudra.utils.device as sdev
|
| 30 |
+
from samudra.config import BlockConfig, SamudraConfig, UNetBackboneConfig
|
| 31 |
+
from samudra.constants import build_om4_spec
|
| 32 |
+
from samudra.datasets import InferenceDataset
|
| 33 |
+
from samudra.utils.data import DataSource, Normalize
|
| 34 |
+
|
| 35 |
+
# Data pipeline work happens on CPU in the main process; the GPU worker flips
|
| 36 |
+
# this to "cuda" for the forward passes only.
|
| 37 |
+
sdev.set_device(torch.device("cpu"))
|
| 38 |
+
|
| 39 |
+
# --------------------------------------------------------------------------
|
| 40 |
+
# Data: public 1-degree OM4 store (Open Storage Network, anonymous access)
|
| 41 |
+
# --------------------------------------------------------------------------
|
| 42 |
+
ENDPOINT = "https://nyu1.osn.mghpcc.org"
|
| 43 |
+
ROOT = "m2lines-pubs/Samudra"
|
| 44 |
+
HIST = 1
|
| 45 |
+
MAX_STEPS = 12
|
| 46 |
+
|
| 47 |
+
_fs = s3fs.S3FileSystem(anon=True, client_kwargs={"endpoint_url": ENDPOINT})
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _open(name: str) -> xr.Dataset:
|
| 51 |
+
return xr.open_zarr(
|
| 52 |
+
s3fs.S3Map(root=f"{ROOT}/{name}", s3=_fs, check=False),
|
| 53 |
+
consolidated=True,
|
| 54 |
+
chunks={},
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
print("[samudra] opening public OM4 zarr stores ...", flush=True)
|
| 59 |
+
_t0 = time.time()
|
| 60 |
+
SPEC = build_om4_spec("thermo_dynamic_all", "tau_hfds_hfds_anom")
|
| 61 |
+
SRC = DataSource.from_datasets(
|
| 62 |
+
_open("OM4"),
|
| 63 |
+
_open("OM4_means"),
|
| 64 |
+
_open("OM4_stds"),
|
| 65 |
+
dataset_spec=SPEC,
|
| 66 |
+
prognostic_var_names=SPEC.prognostic_var_names,
|
| 67 |
+
boundary_var_names=SPEC.boundary_var_names,
|
| 68 |
+
name="om4-1deg",
|
| 69 |
+
)
|
| 70 |
+
PROG_NAMES = list(SPEC.prognostic_var_names)
|
| 71 |
+
BND_NAMES = list(SPEC.boundary_var_names)
|
| 72 |
+
N_PROG = len(PROG_NAMES)
|
| 73 |
+
_norm = Normalize(SRC, SPEC.prognostic_var_names, SPEC.boundary_var_names)
|
| 74 |
+
PROG_MEAN = np.asarray(_norm._prognostic_mean_np, dtype=np.float64)
|
| 75 |
+
PROG_STD = np.asarray(_norm._prognostic_std_np, dtype=np.float64)
|
| 76 |
+
TIMES = [str(t)[:10] for t in SRC.data.time.values]
|
| 77 |
+
print(f"[samudra] data source ready in {time.time() - _t0:.1f}s "
|
| 78 |
+
f"({len(TIMES)} snapshots, {N_PROG} prognostic vars)", flush=True)
|
| 79 |
+
|
| 80 |
+
# Samudra 2 was trained on 1975-2013 and validated to 2014-10; everything from
|
| 81 |
+
# 2014-10-10 onward is held out, so restrict the demo to genuine forecasts.
|
| 82 |
+
FIRST_HELDOUT = TIMES.index("2014-10-10")
|
| 83 |
+
LAST_START = len(TIMES) - (2 * MAX_STEPS + 2)
|
| 84 |
+
START_DATES = TIMES[FIRST_HELDOUT : LAST_START + 1]
|
| 85 |
+
|
| 86 |
+
# --------------------------------------------------------------------------
|
| 87 |
+
# Model: the released 1-degree Samudra 2 checkpoint
|
| 88 |
+
# --------------------------------------------------------------------------
|
| 89 |
+
print("[samudra] building model ...", flush=True)
|
| 90 |
+
_model_cfg = SamudraConfig(
|
| 91 |
+
checkpointing=None,
|
| 92 |
+
pred_residuals=False,
|
| 93 |
+
last_kernel_size=3,
|
| 94 |
+
pad="circular",
|
| 95 |
+
unet=UNetBackboneConfig(
|
| 96 |
+
ch_width=[280, 380, 480, 520],
|
| 97 |
+
dilation=[1, 2, 4, 8],
|
| 98 |
+
n_layers=[1, 1, 1, 1],
|
| 99 |
+
core_block=BlockConfig(
|
| 100 |
+
block_type="conv_next_block",
|
| 101 |
+
kernel_size=3,
|
| 102 |
+
activation="capped_gelu",
|
| 103 |
+
upscale_factor=2,
|
| 104 |
+
norm="batch",
|
| 105 |
+
),
|
| 106 |
+
down_sampling_block="avg_pool",
|
| 107 |
+
up_sampling_block="zonally_periodic_upsample",
|
| 108 |
+
),
|
| 109 |
+
)
|
| 110 |
+
_grid_src = dataclasses.replace(SRC, data=SRC.data.isel(time=slice(0, 4)))
|
| 111 |
+
MODEL = _model_cfg.build(
|
| 112 |
+
prog_channels=(HIST + 1) * N_PROG,
|
| 113 |
+
boundary_channels=(HIST + 1) * len(BND_NAMES),
|
| 114 |
+
out_channels=(HIST + 1) * N_PROG,
|
| 115 |
+
hist=HIST,
|
| 116 |
+
srcs=[_grid_src],
|
| 117 |
+
)
|
| 118 |
+
_ckpt_path = hf_hub_download("M2LInES/Samudra2", "onedeg/ema_ckpt.pt")
|
| 119 |
+
_ck = torch.load(_ckpt_path, map_location="cpu", weights_only=False)
|
| 120 |
+
MODEL.load_state_dict(
|
| 121 |
+
{k.removeprefix("module."): v for k, v in _ck["model"].items()}, strict=True
|
| 122 |
+
)
|
| 123 |
+
del _ck
|
| 124 |
+
DEVICE = os.environ.get("SAMUDRA_DEVICE", "cuda")
|
| 125 |
+
MODEL.eval().to(DEVICE)
|
| 126 |
+
N_PARAMS = sum(p.numel() for p in MODEL.parameters())
|
| 127 |
+
print(f"[samudra] model ready ({N_PARAMS / 1e6:.1f}M params)", flush=True)
|
| 128 |
+
|
| 129 |
+
# --------------------------------------------------------------------------
|
| 130 |
+
# Variable menu
|
| 131 |
+
# --------------------------------------------------------------------------
|
| 132 |
+
LEVELS = [2.5, 10, 22.5, 40, 65, 105, 165, 250, 375, 550, 775, 1050, 1400,
|
| 133 |
+
1850, 2400, 3100, 4000, 5000, 6000]
|
| 134 |
+
|
| 135 |
+
VARIABLES: dict[str, dict] = {
|
| 136 |
+
"Sea surface temperature (2.5 m)": dict(key="thetao_0", unit="°C", cmap="RdYlBu_r"),
|
| 137 |
+
"Ocean temperature at 105 m": dict(key="thetao_5", unit="°C", cmap="RdYlBu_r"),
|
| 138 |
+
"Ocean temperature at 550 m": dict(key="thetao_9", unit="°C", cmap="RdYlBu_r"),
|
| 139 |
+
"Sea surface salinity (2.5 m)": dict(key="so_0", unit="psu", cmap="viridis"),
|
| 140 |
+
"Sea surface height": dict(key="zos", unit="m", cmap="Spectral_r"),
|
| 141 |
+
"Zonal velocity (2.5 m)": dict(key="uo_0", unit="m/s", cmap="RdBu_r"),
|
| 142 |
+
"Meridional velocity (2.5 m)": dict(key="vo_0", unit="m/s", cmap="RdBu_r"),
|
| 143 |
+
}
|
| 144 |
+
for _meta in VARIABLES.values():
|
| 145 |
+
_meta["idx"] = PROG_NAMES.index(_meta["key"])
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# --------------------------------------------------------------------------
|
| 149 |
+
# Rollout
|
| 150 |
+
# --------------------------------------------------------------------------
|
| 151 |
+
@functools.lru_cache(maxsize=4)
|
| 152 |
+
def _prepare(i0: int, n_steps: int):
|
| 153 |
+
"""Stream the initial ocean state and atmospheric forcing for a window."""
|
| 154 |
+
window = SRC.data.isel(time=slice(i0, i0 + 2 * n_steps + 2))
|
| 155 |
+
windowed_src = dataclasses.replace(SRC, data=window)
|
| 156 |
+
dataset = InferenceDataset(
|
| 157 |
+
src=windowed_src,
|
| 158 |
+
prognostic_var_names=SPEC.prognostic_var_names,
|
| 159 |
+
boundary_var_names=SPEC.boundary_var_names,
|
| 160 |
+
hist=HIST,
|
| 161 |
+
normalize_before_mask=True,
|
| 162 |
+
masked_fill_value=0.0,
|
| 163 |
+
long_rollout=False,
|
| 164 |
+
)
|
| 165 |
+
initial = dataset.initial_prognostic
|
| 166 |
+
boundaries = tuple(dataset.get_boundary(k) for k in range(n_steps))
|
| 167 |
+
target_times = dataset.get_target_time(0, n_steps)
|
| 168 |
+
return initial, boundaries, dataset.ctx, target_times, window
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _gpu_duration(initial, boundaries, ctx, var_idx) -> int:
|
| 172 |
+
return int(12 + 2 * len(boundaries))
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@spaces.GPU(duration=_gpu_duration)
|
| 176 |
+
def _rollout(initial, boundaries, ctx, var_idx: int):
|
| 177 |
+
"""Autoregressively step the emulator; returns one normalised field on CPU."""
|
| 178 |
+
sdev.set_device(torch.device(DEVICE))
|
| 179 |
+
ctx = ctx.to(torch.device(DEVICE))
|
| 180 |
+
state = initial.to(DEVICE)
|
| 181 |
+
outputs = []
|
| 182 |
+
with torch.no_grad():
|
| 183 |
+
for boundary in boundaries:
|
| 184 |
+
state = MODEL.forward_once(state, boundary.to(DEVICE), ctx)
|
| 185 |
+
outputs.append(state)
|
| 186 |
+
stacked = torch.cat(outputs, dim=0)
|
| 187 |
+
stacked = stacked.reshape(-1, N_PROG, stacked.shape[-2], stacked.shape[-1])
|
| 188 |
+
return stacked[:, var_idx].float().cpu().numpy()
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# --------------------------------------------------------------------------
|
| 192 |
+
# Rendering
|
| 193 |
+
# --------------------------------------------------------------------------
|
| 194 |
+
def _roll(field: np.ndarray) -> np.ndarray:
|
| 195 |
+
"""Shift longitudes from 0..360 to -180..180 for a familiar world map."""
|
| 196 |
+
return np.roll(field, field.shape[-1] // 2, axis=-1)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _make_video(pred, truth, dates, label, unit, cmap, fps):
|
| 200 |
+
diff = pred - truth
|
| 201 |
+
finite = np.isfinite(truth)
|
| 202 |
+
vmin, vmax = np.nanpercentile(truth[finite], [1, 99])
|
| 203 |
+
dmax = float(np.nanpercentile(np.abs(diff[np.isfinite(diff)]), 99.5))
|
| 204 |
+
dmax = max(dmax, 1e-6)
|
| 205 |
+
extent = [-180, 180, -90, 90]
|
| 206 |
+
|
| 207 |
+
fig, axes = plt.subplots(3, 1, figsize=(7.2, 9.6), dpi=100)
|
| 208 |
+
artists = []
|
| 209 |
+
titles = [f"Samudra 2 emulator — {label}", f"GFDL OM4 simulation — {label}",
|
| 210 |
+
"Difference (emulator − OM4)"]
|
| 211 |
+
for ax, title, cm, lo, hi in zip(
|
| 212 |
+
axes, titles, [cmap, cmap, "RdBu_r"], [vmin, vmin, -dmax], [vmax, vmax, dmax]
|
| 213 |
+
):
|
| 214 |
+
colormap = matplotlib.colormaps[cm].copy()
|
| 215 |
+
colormap.set_bad("0.85")
|
| 216 |
+
im = ax.imshow(
|
| 217 |
+
np.zeros_like(pred[0]), origin="lower", extent=extent,
|
| 218 |
+
cmap=colormap, vmin=lo, vmax=hi, interpolation="nearest",
|
| 219 |
+
)
|
| 220 |
+
ax.set_title(title, fontsize=10)
|
| 221 |
+
ax.set_xticks([-180, -90, 0, 90, 180])
|
| 222 |
+
ax.set_yticks([-90, -45, 0, 45, 90])
|
| 223 |
+
ax.tick_params(labelsize=7)
|
| 224 |
+
fig.colorbar(im, ax=ax, fraction=0.025, pad=0.02).ax.tick_params(labelsize=7)
|
| 225 |
+
artists.append(im)
|
| 226 |
+
suptitle = fig.suptitle("", fontsize=12)
|
| 227 |
+
fig.tight_layout(rect=[0, 0, 1, 0.96])
|
| 228 |
+
|
| 229 |
+
frames = []
|
| 230 |
+
for k, date in enumerate(dates):
|
| 231 |
+
artists[0].set_data(_roll(pred[k]))
|
| 232 |
+
artists[1].set_data(_roll(truth[k]))
|
| 233 |
+
artists[2].set_data(_roll(diff[k]))
|
| 234 |
+
suptitle.set_text(f"{date} · lead time +{5 * (k + 1)} days · [{unit}]")
|
| 235 |
+
fig.canvas.draw()
|
| 236 |
+
frames.append(np.asarray(fig.canvas.buffer_rgba())[..., :3].copy())
|
| 237 |
+
plt.close(fig)
|
| 238 |
+
|
| 239 |
+
frames = frames + [frames[-1]] * max(1, int(fps))
|
| 240 |
+
path = os.path.join(tempfile.mkdtemp(), "samudra2_rollout.mp4")
|
| 241 |
+
imageio.mimwrite(
|
| 242 |
+
path, frames, fps=fps, codec="libx264", quality=8, macro_block_size=16
|
| 243 |
+
)
|
| 244 |
+
return path
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _make_skill_plot(rmse, persistence, unit, label):
|
| 248 |
+
leads = 5 * (np.arange(len(rmse)) + 1)
|
| 249 |
+
fig, ax = plt.subplots(figsize=(7.2, 3.6), dpi=100)
|
| 250 |
+
ax.plot(leads, persistence, "o--", color="0.55", label="Persistence baseline")
|
| 251 |
+
ax.plot(leads, rmse, "o-", color="#1f77b4", label="Samudra 2 emulator")
|
| 252 |
+
ax.set_xlabel("Lead time (days)")
|
| 253 |
+
ax.set_ylabel(f"RMSE vs OM4 [{unit}]")
|
| 254 |
+
ax.set_title(f"Emulator error growth — {label}", fontsize=11)
|
| 255 |
+
ax.grid(alpha=0.3)
|
| 256 |
+
ax.legend(fontsize=9)
|
| 257 |
+
ax.set_ylim(bottom=0)
|
| 258 |
+
fig.tight_layout()
|
| 259 |
+
path = os.path.join(tempfile.mkdtemp(), "samudra2_skill.png")
|
| 260 |
+
fig.savefig(path)
|
| 261 |
+
plt.close(fig)
|
| 262 |
+
return path
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
# --------------------------------------------------------------------------
|
| 266 |
+
# Handler
|
| 267 |
+
# --------------------------------------------------------------------------
|
| 268 |
+
def forecast(
|
| 269 |
+
variable: str,
|
| 270 |
+
start_date: str,
|
| 271 |
+
n_steps: int = 6,
|
| 272 |
+
fps: int = 3,
|
| 273 |
+
progress=gr.Progress(track_tqdm=True),
|
| 274 |
+
):
|
| 275 |
+
"""Roll Samudra 2 forward from a held-out ocean state and score it against OM4.
|
| 276 |
+
|
| 277 |
+
Args:
|
| 278 |
+
variable: Which ocean field to visualise (the emulator always advances
|
| 279 |
+
the full 77-channel ocean state).
|
| 280 |
+
start_date: Initial condition date, from the held-out 2014-2022 period.
|
| 281 |
+
n_steps: Number of autoregressive emulator steps; each step advances the
|
| 282 |
+
ocean by 10 days (two 5-day snapshots).
|
| 283 |
+
fps: Frames per second of the returned animation.
|
| 284 |
+
|
| 285 |
+
Returns:
|
| 286 |
+
An MP4 animation (emulator / OM4 / difference), an error-growth chart,
|
| 287 |
+
and a short text summary.
|
| 288 |
+
"""
|
| 289 |
+
meta = VARIABLES[variable]
|
| 290 |
+
n_steps = int(n_steps)
|
| 291 |
+
i0 = TIMES.index(start_date)
|
| 292 |
+
|
| 293 |
+
progress(0.05, desc="Streaming ocean state from public OM4 archive…")
|
| 294 |
+
initial, boundaries, ctx, target_times, window = _prepare(i0, n_steps)
|
| 295 |
+
|
| 296 |
+
key = meta["key"]
|
| 297 |
+
idx = meta["idx"]
|
| 298 |
+
|
| 299 |
+
progress(0.45, desc="Running the emulator on GPU…")
|
| 300 |
+
t_gpu = time.time()
|
| 301 |
+
pred_norm = _rollout(initial, boundaries, ctx, idx)
|
| 302 |
+
gpu_seconds = time.time() - t_gpu
|
| 303 |
+
|
| 304 |
+
progress(0.7, desc="Fetching the OM4 reference…")
|
| 305 |
+
wet = SRC.masks.prognostic[idx].numpy().astype(bool)
|
| 306 |
+
pred = pred_norm.astype(np.float64) * PROG_STD[idx] + PROG_MEAN[idx]
|
| 307 |
+
pred = np.where(wet[None], pred, np.nan)
|
| 308 |
+
truth = np.asarray(window[key].sel(time=target_times).values, dtype=np.float32)
|
| 309 |
+
truth = np.where(wet[None], truth, np.nan)
|
| 310 |
+
initial_field = np.where(wet, np.asarray(window[key].isel(time=1).values), np.nan)
|
| 311 |
+
|
| 312 |
+
rmse = np.sqrt(np.nanmean((pred - truth) ** 2, axis=(1, 2)))
|
| 313 |
+
persistence = np.sqrt(np.nanmean((initial_field[None] - truth) ** 2, axis=(1, 2)))
|
| 314 |
+
|
| 315 |
+
dates = [str(t)[:10] for t in np.asarray(target_times)]
|
| 316 |
+
progress(0.8, desc="Rendering animation…")
|
| 317 |
+
video = _make_video(pred, truth, dates, variable, meta["unit"], meta["cmap"], int(fps))
|
| 318 |
+
plot = _make_skill_plot(rmse, persistence, meta["unit"], variable)
|
| 319 |
+
|
| 320 |
+
summary = (
|
| 321 |
+
f"**Initial condition** {TIMES[i0 + 1]} → **forecast** {dates[0]} … {dates[-1]} \n"
|
| 322 |
+
f"**{n_steps} autoregressive steps** = {10 * n_steps} days "
|
| 323 |
+
f"({len(dates)} 5-day snapshots) in **{gpu_seconds:.1f} s** of GPU time. \n"
|
| 324 |
+
f"**{variable}** RMSE vs OM4: {rmse[0]:.3f} {meta['unit']} at +5 days → "
|
| 325 |
+
f"{rmse[-1]:.3f} {meta['unit']} at +{5 * len(dates)} days "
|
| 326 |
+
f"(persistence: {persistence[-1]:.3f} {meta['unit']}). \n"
|
| 327 |
+
f"Every step advances all {N_PROG} prognostic channels "
|
| 328 |
+
f"(temperature, salinity, currents over 19 depth levels, plus sea surface height)."
|
| 329 |
+
)
|
| 330 |
+
progress(1.0, desc="Done")
|
| 331 |
+
return video, plot, summary
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
DESCRIPTION = """
|
| 335 |
+
# 🌊 Samudra 2 — a neural ocean emulator
|
| 336 |
+
|
| 337 |
+
[**Samudra 2**](https://huggingface.co/M2LInES/Samudra2) (M2LInES) is an autoregressive
|
| 338 |
+
deep-learning emulator of the **GFDL OM4 global ocean model**. From one ocean state plus
|
| 339 |
+
the atmospheric forcing, it advances *temperature, salinity, and horizontal currents across
|
| 340 |
+
19 depth levels together with sea surface height* — 77 prognostic fields at once — 10 days
|
| 341 |
+
per step, orders of magnitude faster than the ocean model it replaces.
|
| 342 |
+
|
| 343 |
+
This Space runs the released **1° checkpoint** on genuinely held-out years (2014–2022):
|
| 344 |
+
the initial condition and forcing are streamed live from the public OM4 archive, the
|
| 345 |
+
emulator is rolled forward on ZeroGPU, and the result is scored against the OM4
|
| 346 |
+
simulation itself.
|
| 347 |
+
"""
|
| 348 |
+
|
| 349 |
+
with gr.Blocks(title="Samudra 2 ocean emulator") as demo:
|
| 350 |
+
gr.Markdown(DESCRIPTION)
|
| 351 |
+
|
| 352 |
+
with gr.Row():
|
| 353 |
+
with gr.Column(scale=1):
|
| 354 |
+
variable = gr.Dropdown(
|
| 355 |
+
choices=list(VARIABLES), value="Sea surface temperature (2.5 m)",
|
| 356 |
+
label="Field to visualise",
|
| 357 |
+
info="The emulator always advances the full ocean state.",
|
| 358 |
+
)
|
| 359 |
+
start_date = gr.Dropdown(
|
| 360 |
+
choices=START_DATES, value="2015-06-02",
|
| 361 |
+
label="Initial condition (held-out period)",
|
| 362 |
+
)
|
| 363 |
+
n_steps = gr.Slider(
|
| 364 |
+
1, MAX_STEPS, value=6, step=1,
|
| 365 |
+
label="Autoregressive steps (10 days each)",
|
| 366 |
+
)
|
| 367 |
+
run = gr.Button("Run the ocean emulator", variant="primary")
|
| 368 |
+
with gr.Accordion("Advanced", open=False):
|
| 369 |
+
fps = gr.Slider(1, 8, value=3, step=1, label="Animation frames per second")
|
| 370 |
+
summary = gr.Markdown()
|
| 371 |
+
with gr.Column(scale=2):
|
| 372 |
+
video = gr.Video(label="Emulator vs OM4", autoplay=True, loop=True)
|
| 373 |
+
plot = gr.Image(label="Error growth with lead time", type="filepath")
|
| 374 |
+
|
| 375 |
+
inputs = [variable, start_date, n_steps, fps]
|
| 376 |
+
outputs = [video, plot, summary]
|
| 377 |
+
run.click(forecast, inputs=inputs, outputs=outputs, api_name="forecast")
|
| 378 |
+
|
| 379 |
+
gr.Examples(
|
| 380 |
+
examples=[
|
| 381 |
+
["Sea surface temperature (2.5 m)", "2015-06-02", 6],
|
| 382 |
+
["Sea surface height", "2016-02-07", 8],
|
| 383 |
+
["Sea surface salinity (2.5 m)", "2018-11-04", 6],
|
| 384 |
+
["Ocean temperature at 105 m", "2020-03-18", 10],
|
| 385 |
+
],
|
| 386 |
+
inputs=[variable, start_date, n_steps],
|
| 387 |
+
outputs=outputs,
|
| 388 |
+
fn=forecast,
|
| 389 |
+
cache_examples=True,
|
| 390 |
+
cache_mode="lazy",
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
gr.Markdown(
|
| 394 |
+
"Data: public 1° GFDL-OM4 archive (`m2lines-pubs/Samudra`, Open Storage Network). "
|
| 395 |
+
"Model: [M2LInES/Samudra2](https://huggingface.co/M2LInES/Samudra2) · "
|
| 396 |
+
"code: [m2lines/Samudra](https://github.com/m2lines/Samudra) (Apache-2.0)."
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
if __name__ == "__main__":
|
| 400 |
+
demo.queue().launch(theme=gr.themes.Citrus(), mcp_server=True)
|
requirements.txt
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy>=1.26.4,<2
|
| 2 |
+
pandas>=2.2
|
| 3 |
+
scipy>=1.15.2
|
| 4 |
+
matplotlib>=3.10.1
|
| 5 |
+
imageio>=2.34
|
| 6 |
+
imageio-ffmpeg>=0.5
|
| 7 |
+
cftime>=1.6.4.post1
|
| 8 |
+
dacite>=1.9.2
|
| 9 |
+
dask>=2025.2,<2026
|
| 10 |
+
einops>=0.7
|
| 11 |
+
jaxtyping>=0.3
|
| 12 |
+
microsoft-aurora>=1.8
|
| 13 |
+
perceiver-pytorch>=0.8.8
|
| 14 |
+
pydantic-settings>=2.8.1
|
| 15 |
+
pyyaml>=6.0.2
|
| 16 |
+
s3fs==2025.5.1
|
| 17 |
+
torchinfo>=1.8
|
| 18 |
+
tqdm>=4.67.1
|
| 19 |
+
typing-extensions>=4.15
|
| 20 |
+
wandb>=0.19.8
|
| 21 |
+
xarray>=2025.1.2
|
| 22 |
+
xarray-einstats>=0.8
|
| 23 |
+
zarr<3
|
samudra/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
from importlib.metadata import PackageNotFoundError, version
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
__version__ = version("samudra")
|
| 9 |
+
except PackageNotFoundError: # not installed (e.g. running from a bare source tree)
|
| 10 |
+
__version__ = "0.0.0+unknown"
|
| 11 |
+
|
| 12 |
+
__all__ = ["__version__"]
|
samudra/aggregator/LICENSE-ACE.txt
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Apache License
|
| 2 |
+
Version 2.0, January 2004
|
| 3 |
+
http://www.apache.org/licenses/
|
| 4 |
+
|
| 5 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 6 |
+
|
| 7 |
+
1. Definitions.
|
| 8 |
+
|
| 9 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 10 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 11 |
+
|
| 12 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 13 |
+
the copyright owner that is granting the License.
|
| 14 |
+
|
| 15 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 16 |
+
other entities that control, are controlled by, or are under common
|
| 17 |
+
control with that entity. For the purposes of this definition,
|
| 18 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 19 |
+
direction or management of such entity, whether by contract or
|
| 20 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 21 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 22 |
+
|
| 23 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 24 |
+
exercising permissions granted by this License.
|
| 25 |
+
|
| 26 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 27 |
+
including but not limited to software source code, documentation
|
| 28 |
+
source, and configuration files.
|
| 29 |
+
|
| 30 |
+
"Object" form shall mean any form resulting from mechanical
|
| 31 |
+
transformation or translation of a Source form, including but
|
| 32 |
+
not limited to compiled object code, generated documentation,
|
| 33 |
+
and conversions to other media types.
|
| 34 |
+
|
| 35 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 36 |
+
Object form, made available under the License, as indicated by a
|
| 37 |
+
copyright notice that is included in or attached to the work
|
| 38 |
+
(an example is provided in the Appendix below).
|
| 39 |
+
|
| 40 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 41 |
+
form, that is based on (or derived from) the Work and for which the
|
| 42 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 43 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 44 |
+
of this License, Derivative Works shall not include works that remain
|
| 45 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 46 |
+
the Work and Derivative Works thereof.
|
| 47 |
+
|
| 48 |
+
"Contribution" shall mean any work of authorship, including
|
| 49 |
+
the original version of the Work and any modifications or additions
|
| 50 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 51 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 52 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 53 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 54 |
+
means any form of electronic, verbal, or written communication sent
|
| 55 |
+
to the Licensor or its representatives, including but not limited to
|
| 56 |
+
communication on electronic mailing lists, source code control systems,
|
| 57 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 58 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 59 |
+
excluding communication that is conspicuously marked or otherwise
|
| 60 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 61 |
+
|
| 62 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 63 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 64 |
+
subsequently incorporated within the Work.
|
| 65 |
+
|
| 66 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 67 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 68 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 69 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 70 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 71 |
+
Work and such Derivative Works in Source or Object form.
|
| 72 |
+
|
| 73 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 74 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 75 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 76 |
+
(except as stated in this section) patent license to make, have made,
|
| 77 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 78 |
+
where such license applies only to those patent claims licensable
|
| 79 |
+
by such Contributor that are necessarily infringed by their
|
| 80 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 81 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 82 |
+
institute patent litigation against any entity (including a
|
| 83 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 84 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 85 |
+
or contributory patent infringement, then any patent licenses
|
| 86 |
+
granted to You under this License for that Work shall terminate
|
| 87 |
+
as of the date such litigation is filed.
|
| 88 |
+
|
| 89 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 90 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 91 |
+
modifications, and in Source or Object form, provided that You
|
| 92 |
+
meet the following conditions:
|
| 93 |
+
|
| 94 |
+
(a) You must give any other recipients of the Work or
|
| 95 |
+
Derivative Works a copy of this License; and
|
| 96 |
+
|
| 97 |
+
(b) You must cause any modified files to carry prominent notices
|
| 98 |
+
stating that You changed the files; and
|
| 99 |
+
|
| 100 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 101 |
+
that You distribute, all copyright, patent, trademark, and
|
| 102 |
+
attribution notices from the Source form of the Work,
|
| 103 |
+
excluding those notices that do not pertain to any part of
|
| 104 |
+
the Derivative Works; and
|
| 105 |
+
|
| 106 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 107 |
+
distribution, then any Derivative Works that You distribute must
|
| 108 |
+
include a readable copy of the attribution notices contained
|
| 109 |
+
within such NOTICE file, excluding those notices that do not
|
| 110 |
+
pertain to any part of the Derivative Works, in at least one
|
| 111 |
+
of the following places: within a NOTICE text file distributed
|
| 112 |
+
as part of the Derivative Works; within the Source form or
|
| 113 |
+
documentation, if provided along with the Derivative Works; or,
|
| 114 |
+
within a display generated by the Derivative Works, if and
|
| 115 |
+
wherever such third-party notices normally appear. The contents
|
| 116 |
+
of the NOTICE file are for informational purposes only and
|
| 117 |
+
do not modify the License. You may add Your own attribution
|
| 118 |
+
notices within Derivative Works that You distribute, alongside
|
| 119 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 120 |
+
that such additional attribution notices cannot be construed
|
| 121 |
+
as modifying the License.
|
| 122 |
+
|
| 123 |
+
You may add Your own copyright statement to Your modifications and
|
| 124 |
+
may provide additional or different license terms and conditions
|
| 125 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 126 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 127 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 128 |
+
the conditions stated in this License.
|
| 129 |
+
|
| 130 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 131 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 132 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 133 |
+
this License, without any additional terms or conditions.
|
| 134 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 135 |
+
the terms of any separate license agreement you may have executed
|
| 136 |
+
with Licensor regarding such Contributions.
|
| 137 |
+
|
| 138 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 139 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 140 |
+
except as required for reasonable and customary use in describing the
|
| 141 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 142 |
+
|
| 143 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 144 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 145 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 146 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 147 |
+
implied, including, without limitation, any warranties or conditions
|
| 148 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 149 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 150 |
+
appropriateness of using or redistributing the Work and assume any
|
| 151 |
+
risks associated with Your exercise of permissions under this License.
|
| 152 |
+
|
| 153 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 154 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 155 |
+
unless required by applicable law (such as deliberate and grossly
|
| 156 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 157 |
+
liable to You for damages, including any direct, indirect, special,
|
| 158 |
+
incidental, or consequential damages of any character arising as a
|
| 159 |
+
result of this License or out of the use or inability to use the
|
| 160 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 161 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 162 |
+
other commercial damages or losses), even if such Contributor
|
| 163 |
+
has been advised of the possibility of such damages.
|
| 164 |
+
|
| 165 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 166 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 167 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 168 |
+
or other liability obligations and/or rights consistent with this
|
| 169 |
+
License. However, in accepting such obligations, You may act only
|
| 170 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 171 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 172 |
+
defend, and hold each Contributor harmless for any liability
|
| 173 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 174 |
+
of your accepting any such warranty or additional liability.
|
| 175 |
+
|
| 176 |
+
END OF TERMS AND CONDITIONS
|
| 177 |
+
|
| 178 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 179 |
+
|
| 180 |
+
To apply the Apache License to your work, attach the following
|
| 181 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 182 |
+
replaced with your own identifying information. (Don't include
|
| 183 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 184 |
+
comment syntax for the file format. We also recommend that a
|
| 185 |
+
file or class name and description of purpose be included on the
|
| 186 |
+
same "printed page" as the copyright notice for easier
|
| 187 |
+
identification within third-party archives.
|
| 188 |
+
|
| 189 |
+
Copyright [yyyy] [name of copyright owner]
|
| 190 |
+
|
| 191 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 192 |
+
you may not use this file except in compliance with the License.
|
| 193 |
+
You may obtain a copy of the License at
|
| 194 |
+
|
| 195 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 196 |
+
|
| 197 |
+
Unless required by applicable law or agreed to in writing, software
|
| 198 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 199 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 200 |
+
See the License for the specific language governing permissions and
|
| 201 |
+
limitations under the License.
|
samudra/aggregator/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
from .inference import InferenceEvaluatorAggregator
|
| 7 |
+
from .loss import (
|
| 8 |
+
get_channel_loss_dict,
|
| 9 |
+
get_channel_loss_scale_dict,
|
| 10 |
+
get_depth_loss_dict,
|
| 11 |
+
get_variable_loss_dict,
|
| 12 |
+
)
|
| 13 |
+
from .main import Aggregator
|
| 14 |
+
from .validate import ValidateAggregator
|
samudra/aggregator/inference/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
from .main import InferenceEvaluatorAggregator
|
samudra/aggregator/inference/main.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import wandb
|
| 8 |
+
import xarray as xr
|
| 9 |
+
|
| 10 |
+
from samudra.constants import TensorMap
|
| 11 |
+
from samudra.utils.data import Normalize, get_aggregator_dicts
|
| 12 |
+
from samudra.utils.output import ModelInferenceOutput
|
| 13 |
+
from samudra.utils.wandb import Metrics, MetricsDict
|
| 14 |
+
|
| 15 |
+
from ..validate.reduced import MeanAggregator as OneStepMeanAggregator
|
| 16 |
+
from .reduced import MeanAggregator
|
| 17 |
+
from .time_mean import TimeMeanEvaluatorAggregator
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class InferenceEvaluatorAggregator:
|
| 21 |
+
"""
|
| 22 |
+
Aggregates statistics for inference comparing a generated and target series.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
n_timesteps: int,
|
| 28 |
+
metadata: dict[str, dict[str, str]],
|
| 29 |
+
hist: int,
|
| 30 |
+
area_weights: torch.Tensor,
|
| 31 |
+
wet: torch.Tensor,
|
| 32 |
+
num_prognostic_channels: int,
|
| 33 |
+
normalize: Normalize,
|
| 34 |
+
tensor_map: TensorMap,
|
| 35 |
+
record_step_20: bool = True,
|
| 36 |
+
log_global_mean_time_series: bool = True,
|
| 37 |
+
log_global_mean_norm_time_series: bool = True,
|
| 38 |
+
time_mean_reference_data: xr.Dataset | None = None,
|
| 39 |
+
channel_mean_names: list[str] | None = None,
|
| 40 |
+
):
|
| 41 |
+
"""
|
| 42 |
+
Args:
|
| 43 |
+
n_timesteps: Number of timesteps of inference that will be run.
|
| 44 |
+
metadata: Mapping of variable names their metadata that will
|
| 45 |
+
used in generating logged image captions.
|
| 46 |
+
hist: Number of timesteps of history.
|
| 47 |
+
area_weights: Area weights for the data.
|
| 48 |
+
wet: Wet mask for the data.
|
| 49 |
+
num_prognostic_channels: Number of prognostic channels in the data.
|
| 50 |
+
normalize: Normalization helper for prognostic channels.
|
| 51 |
+
tensor_map: Mapping from prognostic variables to tensor channels.
|
| 52 |
+
record_step_20: Whether to record the mean of the 20th steps.
|
| 53 |
+
log_global_mean_time_series: Whether to log global mean time series metrics.
|
| 54 |
+
log_global_mean_norm_time_series: Whether to log the normalized global mean
|
| 55 |
+
time series metrics.
|
| 56 |
+
time_mean_reference_data: Reference time means for computing bias stats.
|
| 57 |
+
channel_mean_names: List of channel names to compute the mean of.
|
| 58 |
+
"""
|
| 59 |
+
self._aggregators: dict[
|
| 60 |
+
str, MeanAggregator | OneStepMeanAggregator | TimeMeanEvaluatorAggregator
|
| 61 |
+
] = {}
|
| 62 |
+
self._time_dependent_aggregators: dict[str, TimeMeanEvaluatorAggregator] = {}
|
| 63 |
+
self._log_time_series = (
|
| 64 |
+
log_global_mean_time_series or log_global_mean_norm_time_series
|
| 65 |
+
)
|
| 66 |
+
if log_global_mean_time_series:
|
| 67 |
+
self._aggregators["mean"] = MeanAggregator(
|
| 68 |
+
target="denorm",
|
| 69 |
+
n_timesteps=n_timesteps,
|
| 70 |
+
metadata=metadata,
|
| 71 |
+
area_weights=area_weights,
|
| 72 |
+
)
|
| 73 |
+
if log_global_mean_norm_time_series:
|
| 74 |
+
self._aggregators["mean_norm"] = MeanAggregator(
|
| 75 |
+
target="norm",
|
| 76 |
+
n_timesteps=n_timesteps,
|
| 77 |
+
metadata=metadata,
|
| 78 |
+
area_weights=area_weights,
|
| 79 |
+
)
|
| 80 |
+
if record_step_20:
|
| 81 |
+
self._aggregators["mean_step_20"] = OneStepMeanAggregator(
|
| 82 |
+
target_time=20,
|
| 83 |
+
area_weights=area_weights,
|
| 84 |
+
)
|
| 85 |
+
self._aggregators["time_mean"] = TimeMeanEvaluatorAggregator(
|
| 86 |
+
metadata=metadata,
|
| 87 |
+
area_weights=area_weights,
|
| 88 |
+
reference_means=time_mean_reference_data,
|
| 89 |
+
channel_mean_names=channel_mean_names,
|
| 90 |
+
)
|
| 91 |
+
self._aggregators["time_mean_norm"] = TimeMeanEvaluatorAggregator(
|
| 92 |
+
metadata=metadata,
|
| 93 |
+
area_weights=area_weights,
|
| 94 |
+
target="norm",
|
| 95 |
+
reference_means=time_mean_reference_data,
|
| 96 |
+
channel_mean_names=channel_mean_names,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
self._summary_aggregators = {
|
| 100 |
+
name: agg
|
| 101 |
+
for name, agg in list(self._aggregators.items())
|
| 102 |
+
+ list(self._time_dependent_aggregators.items())
|
| 103 |
+
if name not in ["mean", "mean_norm"]
|
| 104 |
+
}
|
| 105 |
+
self._n_timesteps_seen = 0
|
| 106 |
+
self._normalize = normalize
|
| 107 |
+
self._tensor_map = tensor_map
|
| 108 |
+
self.num_prognostic_channels = num_prognostic_channels
|
| 109 |
+
self.hist = hist
|
| 110 |
+
self.wet = wet
|
| 111 |
+
|
| 112 |
+
@property
|
| 113 |
+
def log_time_series(self) -> bool:
|
| 114 |
+
return self._log_time_series
|
| 115 |
+
|
| 116 |
+
@torch.no_grad()
|
| 117 |
+
def record_batch(self, data: ModelInferenceOutput):
|
| 118 |
+
if len(data.prediction) == 0:
|
| 119 |
+
raise ValueError("No prediction values in data")
|
| 120 |
+
if len(data.target) == 0:
|
| 121 |
+
raise ValueError("No target values in data")
|
| 122 |
+
total_len = len(data.time)
|
| 123 |
+
assert data.prediction.shape[0] == total_len // (self.hist + 1)
|
| 124 |
+
target_norm_dict, target_unnorm_dict = get_aggregator_dicts(
|
| 125 |
+
data.target,
|
| 126 |
+
normalize=self._normalize,
|
| 127 |
+
tensor_map=self._tensor_map,
|
| 128 |
+
wet=self.wet,
|
| 129 |
+
long_rollout=True,
|
| 130 |
+
input_type="prognostic",
|
| 131 |
+
num_prognostic_channels=self.num_prognostic_channels,
|
| 132 |
+
hist=self.hist,
|
| 133 |
+
)
|
| 134 |
+
gen_norm_dict, gen_unnorm_dict = get_aggregator_dicts(
|
| 135 |
+
data.prediction,
|
| 136 |
+
normalize=self._normalize,
|
| 137 |
+
tensor_map=self._tensor_map,
|
| 138 |
+
wet=self.wet,
|
| 139 |
+
long_rollout=True,
|
| 140 |
+
input_type="prognostic",
|
| 141 |
+
num_prognostic_channels=self.num_prognostic_channels,
|
| 142 |
+
hist=self.hist,
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
for aggregator in self._aggregators.values():
|
| 146 |
+
aggregator.record_batch(
|
| 147 |
+
target_data=target_unnorm_dict,
|
| 148 |
+
gen_data=gen_unnorm_dict,
|
| 149 |
+
target_data_norm=target_norm_dict,
|
| 150 |
+
gen_data_norm=gen_norm_dict,
|
| 151 |
+
i_time_start=self._n_timesteps_seen,
|
| 152 |
+
)
|
| 153 |
+
# TODO: Add time-dependent aggregators
|
| 154 |
+
# for time_dependent_aggregator in self._time_dependent_aggregators.values():
|
| 155 |
+
# time_dependent_aggregator.record_batch(
|
| 156 |
+
# time=data.time,
|
| 157 |
+
# target_data=target_unnorm_dict,
|
| 158 |
+
# gen_data=gen_unnorm_dict,
|
| 159 |
+
# )
|
| 160 |
+
key = list(target_unnorm_dict.keys())[0]
|
| 161 |
+
n_times = target_unnorm_dict[key].shape[1]
|
| 162 |
+
logs = self._get_inference_logs_slice(
|
| 163 |
+
step_slice=slice(self._n_timesteps_seen, self._n_timesteps_seen + n_times),
|
| 164 |
+
)
|
| 165 |
+
self._n_timesteps_seen += n_times
|
| 166 |
+
return logs
|
| 167 |
+
|
| 168 |
+
def record_initial_prognostic(
|
| 169 |
+
self,
|
| 170 |
+
initial_prognostic: torch.Tensor,
|
| 171 |
+
):
|
| 172 |
+
if self._n_timesteps_seen != 0:
|
| 173 |
+
raise RuntimeError(
|
| 174 |
+
"record_initial_condition may only be called once, "
|
| 175 |
+
"before recording any batches"
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
data_norm_dict, data_unnorm_dict = get_aggregator_dicts(
|
| 179 |
+
initial_prognostic,
|
| 180 |
+
normalize=self._normalize,
|
| 181 |
+
tensor_map=self._tensor_map,
|
| 182 |
+
wet=self.wet,
|
| 183 |
+
long_rollout=True,
|
| 184 |
+
input_type="input",
|
| 185 |
+
num_prognostic_channels=self.num_prognostic_channels,
|
| 186 |
+
hist=self.hist,
|
| 187 |
+
)
|
| 188 |
+
for aggregator_name in ["mean", "mean_norm"]:
|
| 189 |
+
aggregator = self._aggregators.get(aggregator_name)
|
| 190 |
+
if aggregator is not None:
|
| 191 |
+
aggregator.record_batch(
|
| 192 |
+
target_data=data_unnorm_dict,
|
| 193 |
+
gen_data=data_unnorm_dict,
|
| 194 |
+
target_data_norm=data_norm_dict,
|
| 195 |
+
gen_data_norm=data_norm_dict,
|
| 196 |
+
i_time_start=0,
|
| 197 |
+
)
|
| 198 |
+
key = list(data_unnorm_dict.keys())[0]
|
| 199 |
+
n_times = data_unnorm_dict[key].shape[1]
|
| 200 |
+
logs = self._get_inference_logs_slice(
|
| 201 |
+
step_slice=slice(0, n_times),
|
| 202 |
+
)
|
| 203 |
+
self._n_timesteps_seen = n_times
|
| 204 |
+
return logs
|
| 205 |
+
|
| 206 |
+
def get_summary_logs(self):
|
| 207 |
+
# These aggregators require a full timeseries of
|
| 208 |
+
# data and thus wandb logged at the end
|
| 209 |
+
logs: MetricsDict = {}
|
| 210 |
+
for name, aggregator in self._summary_aggregators.items():
|
| 211 |
+
logs.update(aggregator.get_logs(label=name))
|
| 212 |
+
return logs
|
| 213 |
+
|
| 214 |
+
@torch.no_grad()
|
| 215 |
+
def _get_logs(self) -> Metrics:
|
| 216 |
+
"""
|
| 217 |
+
Returns logs as can be reported to WandB.
|
| 218 |
+
"""
|
| 219 |
+
logs: MetricsDict = {}
|
| 220 |
+
for name, aggregator in self._aggregators.items():
|
| 221 |
+
logs.update(aggregator.get_logs(label=name))
|
| 222 |
+
for name, time_dependent_aggregator in self._time_dependent_aggregators.items():
|
| 223 |
+
logs.update(time_dependent_aggregator.get_logs(label=name))
|
| 224 |
+
return logs
|
| 225 |
+
|
| 226 |
+
@torch.no_grad()
|
| 227 |
+
def _get_inference_logs_slice(self, step_slice: slice):
|
| 228 |
+
"""
|
| 229 |
+
Returns a subset of the time series for applicable metrics
|
| 230 |
+
for a specific slice of as can be reported to WandB.
|
| 231 |
+
|
| 232 |
+
Args:
|
| 233 |
+
step_slice: Timestep slice to determine the time series subset.
|
| 234 |
+
|
| 235 |
+
Returns:
|
| 236 |
+
Tuple of start index and list of logs.
|
| 237 |
+
"""
|
| 238 |
+
logs = {}
|
| 239 |
+
for name, aggregator in self._aggregators.items():
|
| 240 |
+
if isinstance(aggregator, MeanAggregator):
|
| 241 |
+
logs.update(aggregator.get_logs(label=name, step_slice=step_slice))
|
| 242 |
+
return to_inference_logs(logs)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def to_inference_logs(log):
|
| 246 |
+
# we have a dictionary which contains WandB tables
|
| 247 |
+
# which we will convert to a list of dictionaries, one for each
|
| 248 |
+
# row in the tables. Any scalar values will be reported in the last
|
| 249 |
+
# dictionary.
|
| 250 |
+
n_rows = 0
|
| 251 |
+
for val in log.values():
|
| 252 |
+
if isinstance(val, wandb.Table):
|
| 253 |
+
n_rows = max(n_rows, len(val.data))
|
| 254 |
+
logs: list[dict[str, float | int]] = []
|
| 255 |
+
for i in range(max(1, n_rows)): # need at least one for non-series values
|
| 256 |
+
logs.append({})
|
| 257 |
+
for key, val in log.items():
|
| 258 |
+
if isinstance(val, wandb.Table):
|
| 259 |
+
for i, row in enumerate(val.data):
|
| 260 |
+
for j, col in enumerate(val.columns):
|
| 261 |
+
key_without_table_name = key[: key.rfind("/")]
|
| 262 |
+
logs[i][f"{key_without_table_name}/{col}"] = row[j]
|
| 263 |
+
else:
|
| 264 |
+
logs[-1][key] = val
|
| 265 |
+
return logs
|
samudra/aggregator/inference/reduced.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
import dataclasses
|
| 7 |
+
from collections import defaultdict
|
| 8 |
+
from functools import partial
|
| 9 |
+
from typing import Literal, Protocol
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import torch
|
| 13 |
+
import wandb
|
| 14 |
+
|
| 15 |
+
from samudra.aggregator.metrics import (
|
| 16 |
+
area_weighted_gradient_magnitude_percent_diff,
|
| 17 |
+
area_weighted_mean,
|
| 18 |
+
area_weighted_mean_bias,
|
| 19 |
+
area_weighted_rmse,
|
| 20 |
+
area_weighted_std,
|
| 21 |
+
)
|
| 22 |
+
from samudra.utils.device import get_device
|
| 23 |
+
from samudra.utils.distributed import all_reduce_mean
|
| 24 |
+
from samudra.utils.wandb import Metrics
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclasses.dataclass
|
| 28 |
+
class _SeriesData:
|
| 29 |
+
metric_name: str
|
| 30 |
+
var_name: str
|
| 31 |
+
data: np.ndarray
|
| 32 |
+
|
| 33 |
+
def get_wandb_key(self) -> str:
|
| 34 |
+
return f"{self.metric_name}/{self.var_name}"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def get_gen_shape(gen_data: dict[str, torch.Tensor]):
|
| 38 |
+
for name in gen_data:
|
| 39 |
+
return gen_data[name].shape
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class MeanMetric(Protocol):
|
| 43 |
+
def record(self, target: torch.Tensor, gen: torch.Tensor, i_time_start: int):
|
| 44 |
+
"""
|
| 45 |
+
Update metric for a batch of data.
|
| 46 |
+
"""
|
| 47 |
+
...
|
| 48 |
+
|
| 49 |
+
def get(self) -> torch.Tensor:
|
| 50 |
+
"""
|
| 51 |
+
Get the total metric value, not divided by number of recorded batches.
|
| 52 |
+
"""
|
| 53 |
+
...
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class SingleTargetMeanMetric(Protocol):
|
| 57 |
+
def record(self, tensor: torch.Tensor, i_time_start: int):
|
| 58 |
+
"""
|
| 59 |
+
Update metric for a batch of data.
|
| 60 |
+
"""
|
| 61 |
+
...
|
| 62 |
+
|
| 63 |
+
def get(self) -> torch.Tensor:
|
| 64 |
+
"""
|
| 65 |
+
Get the total metric value, not divided by number of recorded batches.
|
| 66 |
+
"""
|
| 67 |
+
...
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class AreaWeightedFunction(Protocol):
|
| 71 |
+
"""
|
| 72 |
+
A function that computes a metric on the true and predicted values,
|
| 73 |
+
weighted by area.
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
def __call__(
|
| 77 |
+
self,
|
| 78 |
+
target: torch.Tensor,
|
| 79 |
+
gen: torch.Tensor,
|
| 80 |
+
) -> torch.Tensor: ...
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class AreaWeightedSingleTargetFunction(Protocol):
|
| 84 |
+
"""
|
| 85 |
+
A function that computes a metric on a single value, weighted by area.
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
def __call__(
|
| 89 |
+
self,
|
| 90 |
+
tensor: torch.Tensor,
|
| 91 |
+
) -> torch.Tensor: ...
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def compute_metric_on(
|
| 95 |
+
source: Literal["gen", "target"], metric: AreaWeightedSingleTargetFunction
|
| 96 |
+
) -> AreaWeightedFunction:
|
| 97 |
+
"""Turns a single-target metric function
|
| 98 |
+
(computed on only the generated or target data) into a function that takes in
|
| 99 |
+
both the generated and target data as arguments, as required for the APIs
|
| 100 |
+
which call generic metric functions.
|
| 101 |
+
"""
|
| 102 |
+
|
| 103 |
+
def metric_wrapper(
|
| 104 |
+
target: torch.Tensor,
|
| 105 |
+
gen: torch.Tensor,
|
| 106 |
+
) -> torch.Tensor:
|
| 107 |
+
if source == "gen":
|
| 108 |
+
return metric(gen)
|
| 109 |
+
elif source == "target":
|
| 110 |
+
return metric(target)
|
| 111 |
+
|
| 112 |
+
return metric_wrapper
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class AreaWeightedReducedMetric:
|
| 116 |
+
"""
|
| 117 |
+
A wrapper around an area-weighted metric function.
|
| 118 |
+
"""
|
| 119 |
+
|
| 120 |
+
def __init__(
|
| 121 |
+
self,
|
| 122 |
+
device: torch.device,
|
| 123 |
+
compute_metric: AreaWeightedFunction,
|
| 124 |
+
n_timesteps: int,
|
| 125 |
+
):
|
| 126 |
+
self._compute_metric = compute_metric
|
| 127 |
+
self._total: torch.Tensor | None = None
|
| 128 |
+
self._n_batches = torch.zeros(
|
| 129 |
+
n_timesteps, dtype=torch.int32, device=get_device()
|
| 130 |
+
)
|
| 131 |
+
self._device = device
|
| 132 |
+
self._n_timesteps = n_timesteps
|
| 133 |
+
|
| 134 |
+
def record(self, target: torch.Tensor, gen: torch.Tensor, i_time_start: int):
|
| 135 |
+
"""Add a batch of data to the metric.
|
| 136 |
+
|
| 137 |
+
Args:
|
| 138 |
+
target: Target data. Should have shape [batch, time, height, width].
|
| 139 |
+
gen: Generated data. Should have shape [batch, time, height, width].
|
| 140 |
+
i_time_start: The index of the first timestep in the batch.
|
| 141 |
+
"""
|
| 142 |
+
time_dim = 1
|
| 143 |
+
if target.shape != gen.shape:
|
| 144 |
+
raise RuntimeError(
|
| 145 |
+
"target and gen must have the same shape, got "
|
| 146 |
+
f"{target.shape} and {gen.shape}"
|
| 147 |
+
)
|
| 148 |
+
new_value = (
|
| 149 |
+
self._compute_metric(target=target, gen=gen).mean(dim=0).to(self._device)
|
| 150 |
+
)
|
| 151 |
+
if self._total is None:
|
| 152 |
+
self._total = torch.zeros(
|
| 153 |
+
[self._n_timesteps], dtype=new_value.dtype, device=self._device
|
| 154 |
+
)
|
| 155 |
+
time_slice = slice(i_time_start, i_time_start + gen.shape[time_dim])
|
| 156 |
+
self._total[time_slice] += new_value
|
| 157 |
+
self._n_batches[time_slice] += 1
|
| 158 |
+
|
| 159 |
+
def get(self) -> torch.Tensor:
|
| 160 |
+
"""Returns the mean metric across recorded batches."""
|
| 161 |
+
if self._total is None:
|
| 162 |
+
return torch.tensor(torch.nan)
|
| 163 |
+
return self._total / self._n_batches
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class MeanAggregator:
|
| 167 |
+
def __init__(
|
| 168 |
+
self,
|
| 169 |
+
target: Literal["norm", "denorm"],
|
| 170 |
+
n_timesteps: int,
|
| 171 |
+
area_weights: torch.Tensor,
|
| 172 |
+
metadata: dict[str, dict[str, str]] | None = None,
|
| 173 |
+
):
|
| 174 |
+
self._variable_metrics: dict[str, dict[str, MeanMetric]] | None = None
|
| 175 |
+
self._shape_x = None
|
| 176 |
+
self._shape_y = None
|
| 177 |
+
self._target = target
|
| 178 |
+
self._n_timesteps = n_timesteps
|
| 179 |
+
self._area_weights = area_weights
|
| 180 |
+
|
| 181 |
+
if metadata is None:
|
| 182 |
+
self._metadata: dict[str, dict[str, str]] = {}
|
| 183 |
+
else:
|
| 184 |
+
self._metadata = metadata
|
| 185 |
+
|
| 186 |
+
def _get_variable_metrics(self, gen_data: dict[str, torch.Tensor]):
|
| 187 |
+
if self._variable_metrics is None:
|
| 188 |
+
self._variable_metrics = {}
|
| 189 |
+
|
| 190 |
+
device = get_device()
|
| 191 |
+
self._variable_metrics["weighted_rmse"] = defaultdict(
|
| 192 |
+
lambda: AreaWeightedReducedMetric(
|
| 193 |
+
device=device,
|
| 194 |
+
compute_metric=partial(
|
| 195 |
+
area_weighted_rmse, area_weights=self._area_weights
|
| 196 |
+
),
|
| 197 |
+
n_timesteps=self._n_timesteps,
|
| 198 |
+
)
|
| 199 |
+
)
|
| 200 |
+
if self._target == "denorm":
|
| 201 |
+
self._variable_metrics["weighted_grad_mag_percent_diff"] = defaultdict(
|
| 202 |
+
lambda: AreaWeightedReducedMetric(
|
| 203 |
+
device=device,
|
| 204 |
+
compute_metric=partial(
|
| 205 |
+
area_weighted_gradient_magnitude_percent_diff,
|
| 206 |
+
area_weights=self._area_weights,
|
| 207 |
+
), # noqa: E501
|
| 208 |
+
n_timesteps=self._n_timesteps,
|
| 209 |
+
)
|
| 210 |
+
)
|
| 211 |
+
self._variable_metrics["weighted_mean_gen"] = defaultdict(
|
| 212 |
+
lambda: AreaWeightedReducedMetric(
|
| 213 |
+
device=device,
|
| 214 |
+
compute_metric=compute_metric_on(
|
| 215 |
+
source="gen",
|
| 216 |
+
metric=lambda tensor: area_weighted_mean(
|
| 217 |
+
tensor, area_weights=self._area_weights
|
| 218 |
+
),
|
| 219 |
+
),
|
| 220 |
+
n_timesteps=self._n_timesteps,
|
| 221 |
+
)
|
| 222 |
+
)
|
| 223 |
+
self._variable_metrics["weighted_mean_target"] = defaultdict(
|
| 224 |
+
lambda: AreaWeightedReducedMetric(
|
| 225 |
+
device=device,
|
| 226 |
+
compute_metric=compute_metric_on(
|
| 227 |
+
source="target",
|
| 228 |
+
metric=lambda tensor: area_weighted_mean(
|
| 229 |
+
tensor, area_weights=self._area_weights
|
| 230 |
+
),
|
| 231 |
+
),
|
| 232 |
+
n_timesteps=self._n_timesteps,
|
| 233 |
+
)
|
| 234 |
+
)
|
| 235 |
+
self._variable_metrics["weighted_bias"] = defaultdict(
|
| 236 |
+
lambda: AreaWeightedReducedMetric(
|
| 237 |
+
device=device,
|
| 238 |
+
compute_metric=partial(
|
| 239 |
+
area_weighted_mean_bias, area_weights=self._area_weights
|
| 240 |
+
),
|
| 241 |
+
n_timesteps=self._n_timesteps,
|
| 242 |
+
)
|
| 243 |
+
)
|
| 244 |
+
self._variable_metrics["weighted_std_gen"] = defaultdict(
|
| 245 |
+
lambda: AreaWeightedReducedMetric(
|
| 246 |
+
device=device,
|
| 247 |
+
compute_metric=compute_metric_on(
|
| 248 |
+
source="gen",
|
| 249 |
+
metric=(
|
| 250 |
+
lambda tensor: area_weighted_std(
|
| 251 |
+
tensor, area_weights=self._area_weights
|
| 252 |
+
)
|
| 253 |
+
),
|
| 254 |
+
),
|
| 255 |
+
n_timesteps=self._n_timesteps,
|
| 256 |
+
)
|
| 257 |
+
)
|
| 258 |
+
return self._variable_metrics
|
| 259 |
+
|
| 260 |
+
@torch.no_grad()
|
| 261 |
+
def record_batch(
|
| 262 |
+
self,
|
| 263 |
+
target_data: dict[str, torch.Tensor],
|
| 264 |
+
gen_data: dict[str, torch.Tensor],
|
| 265 |
+
target_data_norm: dict[str, torch.Tensor],
|
| 266 |
+
gen_data_norm: dict[str, torch.Tensor],
|
| 267 |
+
i_time_start: int = 0,
|
| 268 |
+
):
|
| 269 |
+
if self._target == "norm":
|
| 270 |
+
target_data = target_data_norm
|
| 271 |
+
gen_data = gen_data_norm
|
| 272 |
+
variable_metrics = self._get_variable_metrics(gen_data)
|
| 273 |
+
for name in gen_data.keys():
|
| 274 |
+
for metric in variable_metrics:
|
| 275 |
+
variable_metrics[metric][name].record(
|
| 276 |
+
target=target_data[name],
|
| 277 |
+
gen=gen_data[name],
|
| 278 |
+
i_time_start=i_time_start,
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
def _get_series_data(self, step_slice: slice | None = None) -> list[_SeriesData]:
|
| 282 |
+
"""Converts internally stored variable_metrics to a list."""
|
| 283 |
+
if self._variable_metrics is None:
|
| 284 |
+
raise ValueError("No batches have been recorded.")
|
| 285 |
+
data: list[_SeriesData] = []
|
| 286 |
+
for metric in self._variable_metrics:
|
| 287 |
+
sorted_keys = sorted(list(self._variable_metrics[metric].keys()))
|
| 288 |
+
for key in sorted_keys:
|
| 289 |
+
arr = self._variable_metrics[metric][key].get().detach()
|
| 290 |
+
if step_slice is not None:
|
| 291 |
+
arr = arr[step_slice]
|
| 292 |
+
datum = _SeriesData(
|
| 293 |
+
metric_name=metric,
|
| 294 |
+
var_name=key,
|
| 295 |
+
data=all_reduce_mean(arr).cpu().numpy(),
|
| 296 |
+
)
|
| 297 |
+
data.append(datum)
|
| 298 |
+
return data
|
| 299 |
+
|
| 300 |
+
@torch.no_grad()
|
| 301 |
+
def get_logs(self, label: str, step_slice: slice | None = None):
|
| 302 |
+
"""
|
| 303 |
+
Returns logs as can be reported to WandB.
|
| 304 |
+
|
| 305 |
+
Args:
|
| 306 |
+
label: Label to prepend to all log keys.
|
| 307 |
+
step_slice: Slice of forecast steps to log.
|
| 308 |
+
"""
|
| 309 |
+
logs = {}
|
| 310 |
+
series_data: dict[str, np.ndarray] = {
|
| 311 |
+
datum.get_wandb_key(): datum.data
|
| 312 |
+
for datum in self._get_series_data(step_slice)
|
| 313 |
+
}
|
| 314 |
+
init_step = 0 if step_slice is None else step_slice.start
|
| 315 |
+
table = data_to_table(series_data, init_step)
|
| 316 |
+
logs[f"{label}/series"] = table
|
| 317 |
+
return logs
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def data_to_table(data: dict[str, np.ndarray], init_step: int = 0) -> wandb.Table:
|
| 321 |
+
"""
|
| 322 |
+
Convert a dictionary of 1-dimensional timeseries data to a wandb Table.
|
| 323 |
+
|
| 324 |
+
Args:
|
| 325 |
+
data: dictionary of timeseries data.
|
| 326 |
+
init_step: initial step corresponding to the first row's "forecast_step"
|
| 327 |
+
"""
|
| 328 |
+
keys = sorted(list(data.keys()))
|
| 329 |
+
table = wandb.Table(columns=["forecast_step"] + keys)
|
| 330 |
+
if len(keys) > 0:
|
| 331 |
+
for i in range(len(data[keys[0]])):
|
| 332 |
+
row = [init_step + i]
|
| 333 |
+
for key in keys:
|
| 334 |
+
row.append(data[key][i])
|
| 335 |
+
table.add_data(*row)
|
| 336 |
+
return table
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
class AreaWeightedSingleTargetReducedMetric:
|
| 340 |
+
"""
|
| 341 |
+
A wrapper around an area-weighted metric function on a single data source.
|
| 342 |
+
"""
|
| 343 |
+
|
| 344 |
+
def __init__(
|
| 345 |
+
self,
|
| 346 |
+
device: torch.device,
|
| 347 |
+
compute_metric: AreaWeightedSingleTargetFunction,
|
| 348 |
+
n_timesteps: int,
|
| 349 |
+
):
|
| 350 |
+
self._compute_metric = compute_metric
|
| 351 |
+
self._total: torch.Tensor | None = None
|
| 352 |
+
self._n_batches = torch.zeros(
|
| 353 |
+
n_timesteps, dtype=torch.int32, device=get_device()
|
| 354 |
+
)
|
| 355 |
+
self._device = device
|
| 356 |
+
self._n_timesteps = n_timesteps
|
| 357 |
+
|
| 358 |
+
def record(self, tensor: torch.Tensor, i_time_start: int):
|
| 359 |
+
"""Add a batch of data to the metric.
|
| 360 |
+
|
| 361 |
+
Args:
|
| 362 |
+
tensor: batch data. Should have shape [batch, time, height, width].
|
| 363 |
+
i_time_start: The index of the first timestep in the batch.
|
| 364 |
+
"""
|
| 365 |
+
new_value = self._compute_metric(tensor).mean(dim=0).to(self._device)
|
| 366 |
+
if self._total is None:
|
| 367 |
+
self._total = torch.zeros(
|
| 368 |
+
[self._n_timesteps], dtype=new_value.dtype, device=self._device
|
| 369 |
+
)
|
| 370 |
+
time_slice = slice(i_time_start, i_time_start + tensor.shape[1])
|
| 371 |
+
self._total[time_slice] += new_value
|
| 372 |
+
self._n_batches[time_slice] += 1
|
| 373 |
+
|
| 374 |
+
def get(self) -> torch.Tensor:
|
| 375 |
+
"""Returns the mean metric across recorded batches."""
|
| 376 |
+
if self._total is None:
|
| 377 |
+
return torch.tensor(torch.nan)
|
| 378 |
+
return self._total / self._n_batches
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
class SingleTargetMeanAggregator:
|
| 382 |
+
def __init__(
|
| 383 |
+
self,
|
| 384 |
+
n_timesteps: int,
|
| 385 |
+
area_weights: torch.Tensor,
|
| 386 |
+
metadata: dict[str, dict[str, str]] | None = None,
|
| 387 |
+
):
|
| 388 |
+
self._variable_metrics: dict[str, dict[str, SingleTargetMeanMetric]] | None = (
|
| 389 |
+
None
|
| 390 |
+
)
|
| 391 |
+
self._shape_x = None
|
| 392 |
+
self._shape_y = None
|
| 393 |
+
self._n_timesteps = n_timesteps
|
| 394 |
+
self._area_weights = area_weights
|
| 395 |
+
if metadata is None:
|
| 396 |
+
self._metadata: dict[str, dict[str, str]] = {}
|
| 397 |
+
else:
|
| 398 |
+
self._metadata = metadata
|
| 399 |
+
|
| 400 |
+
def _get_variable_metrics(self, gen_data: dict[str, torch.Tensor]):
|
| 401 |
+
if self._variable_metrics is None:
|
| 402 |
+
self._variable_metrics = {
|
| 403 |
+
"weighted_mean_gen": {},
|
| 404 |
+
"weighted_std_gen": {},
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
device = get_device()
|
| 408 |
+
self._variable_metrics["weighted_mean_gen"] = defaultdict(
|
| 409 |
+
lambda: AreaWeightedSingleTargetReducedMetric(
|
| 410 |
+
device=device,
|
| 411 |
+
compute_metric=lambda x: area_weighted_mean(x, self._area_weights),
|
| 412 |
+
n_timesteps=self._n_timesteps,
|
| 413 |
+
)
|
| 414 |
+
)
|
| 415 |
+
self._variable_metrics["weighted_std_gen"] = defaultdict(
|
| 416 |
+
lambda: AreaWeightedSingleTargetReducedMetric(
|
| 417 |
+
device=device,
|
| 418 |
+
compute_metric=lambda x: area_weighted_std(x, self._area_weights),
|
| 419 |
+
n_timesteps=self._n_timesteps,
|
| 420 |
+
)
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
return self._variable_metrics
|
| 424 |
+
|
| 425 |
+
@torch.no_grad()
|
| 426 |
+
def record_batch(
|
| 427 |
+
self,
|
| 428 |
+
data: dict[str, torch.Tensor],
|
| 429 |
+
i_time_start: int = 0,
|
| 430 |
+
):
|
| 431 |
+
variable_metrics = self._get_variable_metrics(data)
|
| 432 |
+
for name in data.keys():
|
| 433 |
+
for metric in variable_metrics:
|
| 434 |
+
variable_metrics[metric][name].record(
|
| 435 |
+
tensor=data[name],
|
| 436 |
+
i_time_start=i_time_start,
|
| 437 |
+
)
|
| 438 |
+
|
| 439 |
+
def _get_series_data(self, step_slice: slice | None = None) -> list[_SeriesData]:
|
| 440 |
+
"""Converts internally stored variable_metrics to a list."""
|
| 441 |
+
if self._variable_metrics is None:
|
| 442 |
+
raise ValueError("No batches have been recorded.")
|
| 443 |
+
data: list[_SeriesData] = []
|
| 444 |
+
for metric in self._variable_metrics:
|
| 445 |
+
sorted_keys = sorted(list(self._variable_metrics[metric].keys()))
|
| 446 |
+
for key in sorted_keys:
|
| 447 |
+
arr = self._variable_metrics[metric][key].get().detach()
|
| 448 |
+
if step_slice is not None:
|
| 449 |
+
arr = arr[step_slice]
|
| 450 |
+
datum = _SeriesData(
|
| 451 |
+
metric_name=metric,
|
| 452 |
+
var_name=key,
|
| 453 |
+
data=all_reduce_mean(arr).cpu().numpy(),
|
| 454 |
+
)
|
| 455 |
+
data.append(datum)
|
| 456 |
+
return data
|
| 457 |
+
|
| 458 |
+
@torch.no_grad()
|
| 459 |
+
def get_logs(self, label: str, step_slice: slice | None = None) -> Metrics:
|
| 460 |
+
"""
|
| 461 |
+
Returns logs as can be reported to WandB.
|
| 462 |
+
|
| 463 |
+
Args:
|
| 464 |
+
label: Label to prepend to all log keys.
|
| 465 |
+
step_slice: Slice of forecast steps to log.
|
| 466 |
+
"""
|
| 467 |
+
logs = {}
|
| 468 |
+
series_data: dict[str, np.ndarray] = {
|
| 469 |
+
datum.get_wandb_key(): datum.data
|
| 470 |
+
for datum in self._get_series_data(step_slice)
|
| 471 |
+
}
|
| 472 |
+
init_step = 0 if step_slice is None else step_slice.start
|
| 473 |
+
table = data_to_table(series_data, init_step)
|
| 474 |
+
logs[f"{label}/series"] = table
|
| 475 |
+
return logs
|
samudra/aggregator/inference/time_mean.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
import dataclasses
|
| 7 |
+
from typing import Literal
|
| 8 |
+
|
| 9 |
+
import matplotlib.pyplot as plt
|
| 10 |
+
import torch
|
| 11 |
+
import wandb
|
| 12 |
+
import xarray as xr
|
| 13 |
+
|
| 14 |
+
from samudra.aggregator.metrics import area_weighted_mean_bias, area_weighted_rmse
|
| 15 |
+
from samudra.aggregator.plotting import get_cmap_limits, plot_imshow
|
| 16 |
+
from samudra.utils.distributed import all_reduce_mean
|
| 17 |
+
from samudra.utils.wandb import Metrics, MetricsDict
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclasses.dataclass
|
| 21 |
+
class _TargetGenPair:
|
| 22 |
+
name: str
|
| 23 |
+
target: torch.Tensor
|
| 24 |
+
gen: torch.Tensor
|
| 25 |
+
area_weights: torch.Tensor
|
| 26 |
+
|
| 27 |
+
def bias(self):
|
| 28 |
+
return self.gen - self.target
|
| 29 |
+
|
| 30 |
+
def rmse(self) -> float:
|
| 31 |
+
ret = float(
|
| 32 |
+
area_weighted_rmse(
|
| 33 |
+
gen=self.gen,
|
| 34 |
+
target=self.target,
|
| 35 |
+
area_weights=self.area_weights,
|
| 36 |
+
)
|
| 37 |
+
.cpu()
|
| 38 |
+
.numpy()
|
| 39 |
+
)
|
| 40 |
+
return ret
|
| 41 |
+
|
| 42 |
+
def weighted_mean_bias(self) -> float:
|
| 43 |
+
return float(
|
| 44 |
+
area_weighted_mean_bias(
|
| 45 |
+
gen=self.gen,
|
| 46 |
+
target=self.target,
|
| 47 |
+
area_weights=self.area_weights,
|
| 48 |
+
)
|
| 49 |
+
.cpu()
|
| 50 |
+
.numpy()
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def get_gen_shape(gen_data: dict[str, torch.Tensor]):
|
| 55 |
+
for name in gen_data:
|
| 56 |
+
return gen_data[name].shape
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class TimeMeanAggregator:
|
| 60 |
+
_image_captions = {
|
| 61 |
+
"bias_map": "{name} time-mean bias (generated - reference) [{units}]",
|
| 62 |
+
"gen_map": "{name} time-mean generated [{units}]",
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
def __init__(
|
| 66 |
+
self,
|
| 67 |
+
area_weights: torch.Tensor,
|
| 68 |
+
target: Literal["norm", "denorm"] = "denorm",
|
| 69 |
+
metadata: dict[str, dict[str, str]] | None = None,
|
| 70 |
+
reference_means: xr.Dataset | None = None,
|
| 71 |
+
):
|
| 72 |
+
"""
|
| 73 |
+
Args:
|
| 74 |
+
area_weights: Tensor of shape [n_lat, n_lon] representing the area weights.
|
| 75 |
+
target: Whether to compute metrics on the normalized or denormalized data,
|
| 76 |
+
defaults to "denorm".
|
| 77 |
+
metadata: Mapping of variable names their metadata that will
|
| 78 |
+
used in generating logged image captions.
|
| 79 |
+
reference_means: Dataset containing reference time-mean values
|
| 80 |
+
for bias computation.
|
| 81 |
+
"""
|
| 82 |
+
self._target = target
|
| 83 |
+
if metadata is None:
|
| 84 |
+
self._metadata: dict[str, dict[str, str]] = {}
|
| 85 |
+
else:
|
| 86 |
+
self._metadata = metadata
|
| 87 |
+
# Dictionaries of tensors of shape [n_lat, n_lon] represnting time means
|
| 88 |
+
self._data: dict[str, torch.Tensor] | None = None
|
| 89 |
+
self._n_timesteps = 0
|
| 90 |
+
self._n_samples: int | None = None
|
| 91 |
+
self._reference_means = reference_means
|
| 92 |
+
self._reference_validated = False
|
| 93 |
+
self._area_weights = area_weights
|
| 94 |
+
|
| 95 |
+
@staticmethod
|
| 96 |
+
def _add_or_initialize_time_mean(
|
| 97 |
+
maybe_dict: dict[str, torch.Tensor] | None,
|
| 98 |
+
new_data: dict[str, torch.Tensor],
|
| 99 |
+
ignore_initial: bool = False,
|
| 100 |
+
) -> dict[str, torch.Tensor]:
|
| 101 |
+
sample_dim = 0
|
| 102 |
+
time_dim = 1
|
| 103 |
+
if ignore_initial:
|
| 104 |
+
time_slice = slice(1, None)
|
| 105 |
+
else:
|
| 106 |
+
time_slice = slice(0, None)
|
| 107 |
+
if maybe_dict is None:
|
| 108 |
+
d: dict[str, torch.Tensor] = {
|
| 109 |
+
name: tensor[:, time_slice].sum(dim=time_dim).sum(dim=sample_dim)
|
| 110 |
+
for name, tensor in new_data.items()
|
| 111 |
+
}
|
| 112 |
+
else:
|
| 113 |
+
d = dict(maybe_dict)
|
| 114 |
+
for name, tensor in new_data.items():
|
| 115 |
+
d[name] += tensor[:, time_slice].sum(dim=time_dim).sum(dim=sample_dim)
|
| 116 |
+
return d
|
| 117 |
+
|
| 118 |
+
@torch.no_grad()
|
| 119 |
+
def record_batch(
|
| 120 |
+
self,
|
| 121 |
+
data: dict[str, torch.Tensor],
|
| 122 |
+
i_time_start: int = 0,
|
| 123 |
+
):
|
| 124 |
+
ignore_initial = i_time_start == 0
|
| 125 |
+
if ignore_initial:
|
| 126 |
+
raise Exception(
|
| 127 |
+
"This is wrong! Make sure this isnt called while recording "
|
| 128 |
+
"initial prognostic"
|
| 129 |
+
)
|
| 130 |
+
self._data = self._add_or_initialize_time_mean(self._data, data, ignore_initial)
|
| 131 |
+
if self._n_samples is None:
|
| 132 |
+
self._n_samples = data[list(data)[0]].size(0)
|
| 133 |
+
if ignore_initial:
|
| 134 |
+
self._n_timesteps = data[list(data)[0]].size(1) - 1
|
| 135 |
+
else:
|
| 136 |
+
self._n_timesteps += data[list(data)[0]].size(1)
|
| 137 |
+
if not self._reference_validated:
|
| 138 |
+
if self._reference_means is not None:
|
| 139 |
+
self.get_logs(label="")
|
| 140 |
+
self._reference_validated = True
|
| 141 |
+
|
| 142 |
+
def get_data(self) -> dict[str, torch.Tensor]:
|
| 143 |
+
if self._n_timesteps == 0 or self._data is None:
|
| 144 |
+
raise ValueError("No data recorded.")
|
| 145 |
+
|
| 146 |
+
assert self._n_samples is not None # for type checker
|
| 147 |
+
|
| 148 |
+
ret = {}
|
| 149 |
+
names = sorted(list(self._data.keys())) # sort for rank-consistent order
|
| 150 |
+
for name in names:
|
| 151 |
+
value = self._data[name]
|
| 152 |
+
gen = all_reduce_mean(value / self._n_timesteps / self._n_samples)
|
| 153 |
+
ret[name] = gen
|
| 154 |
+
return ret
|
| 155 |
+
|
| 156 |
+
@torch.no_grad()
|
| 157 |
+
def get_logs(self, label: str) -> Metrics:
|
| 158 |
+
logs: MetricsDict = {}
|
| 159 |
+
data = self.get_data()
|
| 160 |
+
gen_map_key = "gen_map"
|
| 161 |
+
for name, pred in data.items():
|
| 162 |
+
vmin_pred, vmax_pred = get_cmap_limits(pred.cpu().numpy())
|
| 163 |
+
prediction_image = wandb.Image(
|
| 164 |
+
plot_imshow(pred.cpu().numpy()),
|
| 165 |
+
caption=self._get_caption(gen_map_key, name, vmin_pred, vmax_pred),
|
| 166 |
+
)
|
| 167 |
+
plt.close("all")
|
| 168 |
+
logs.update(
|
| 169 |
+
{
|
| 170 |
+
f"{gen_map_key}/{name}": prediction_image,
|
| 171 |
+
}
|
| 172 |
+
)
|
| 173 |
+
if self._reference_means is not None and name in self._reference_means:
|
| 174 |
+
pair = _TargetGenPair(
|
| 175 |
+
name=name,
|
| 176 |
+
target=torch.as_tensor(
|
| 177 |
+
self._reference_means[name].values, device=pred.device
|
| 178 |
+
),
|
| 179 |
+
gen=pred,
|
| 180 |
+
area_weights=self._area_weights,
|
| 181 |
+
)
|
| 182 |
+
bias_map = pair.bias().cpu().numpy()
|
| 183 |
+
vmin_bias, vmax_bias = get_cmap_limits(bias_map, diverging=True)
|
| 184 |
+
cmap = plt.colormaps.get_cmap("RdBu_r")
|
| 185 |
+
cmap.set_bad(color=(0.7, 0.7, 0.7))
|
| 186 |
+
|
| 187 |
+
bias_fig: plt.Figure = plot_imshow(
|
| 188 |
+
bias_map,
|
| 189 |
+
vmin=vmin_bias,
|
| 190 |
+
vmax=vmax_bias,
|
| 191 |
+
cmap=cmap,
|
| 192 |
+
nan_padding=False,
|
| 193 |
+
)
|
| 194 |
+
bias_image = wandb.Image(
|
| 195 |
+
bias_fig,
|
| 196 |
+
caption=self._get_caption("bias_map", name, vmin_bias, vmax_bias),
|
| 197 |
+
)
|
| 198 |
+
logs.update({f"ref_bias/{name}": pair.weighted_mean_bias()})
|
| 199 |
+
logs.update({f"ref_rmse/{name}": pair.rmse()})
|
| 200 |
+
logs.update({f"ref_bias_map/{name}": bias_image})
|
| 201 |
+
|
| 202 |
+
if len(label) != 0:
|
| 203 |
+
return {f"{label}/{key}": logs[key] for key in logs}
|
| 204 |
+
return logs
|
| 205 |
+
|
| 206 |
+
def _get_caption(self, key: str, name: str, vmin: float, vmax: float) -> str:
|
| 207 |
+
if name in self._metadata:
|
| 208 |
+
caption_name = self._metadata[name]["long_name"]
|
| 209 |
+
units = self._metadata[name]["units"]
|
| 210 |
+
else:
|
| 211 |
+
caption_name, units = name, "unknown_units"
|
| 212 |
+
caption = self._image_captions[key].format(name=caption_name, units=units)
|
| 213 |
+
caption += f" vmin={vmin:.4g}, vmax={vmax:.4g}."
|
| 214 |
+
return caption
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
class TimeMeanEvaluatorAggregator:
|
| 218 |
+
"""Statistics and images on the time-mean state.
|
| 219 |
+
|
| 220 |
+
This aggregator keeps track of the time-mean state, then computes
|
| 221 |
+
statistics and images on that time-mean state when logs are retrieved.
|
| 222 |
+
"""
|
| 223 |
+
|
| 224 |
+
_image_captions = {
|
| 225 |
+
"bias_map": "{name} time-mean bias (generated - target) [{units}]",
|
| 226 |
+
"gen_map": "{name} time-mean generated [{units}]",
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
def __init__(
|
| 230 |
+
self,
|
| 231 |
+
area_weights: torch.Tensor,
|
| 232 |
+
target: Literal["norm", "denorm"] = "denorm",
|
| 233 |
+
metadata: dict[str, dict[str, str]] | None = None,
|
| 234 |
+
reference_means: xr.Dataset | None = None,
|
| 235 |
+
channel_mean_names: list[str] | None = None,
|
| 236 |
+
):
|
| 237 |
+
"""
|
| 238 |
+
Args:
|
| 239 |
+
area_weights: Tensor of shape [n_lat, n_lon] representing the area weights.
|
| 240 |
+
target: Whether to compute metrics on the normalized or denormalized data,
|
| 241 |
+
defaults to "denorm".
|
| 242 |
+
metadata: Mapping of variable names their metadata that will
|
| 243 |
+
used in generating logged image captions.
|
| 244 |
+
reference_means: Dataset containing reference time-mean values
|
| 245 |
+
for bias computation.
|
| 246 |
+
channel_mean_names: Names of variables whose RMSE will be averaged. If
|
| 247 |
+
not provided, all available variables will be used.
|
| 248 |
+
"""
|
| 249 |
+
self._target = target
|
| 250 |
+
if metadata is None:
|
| 251 |
+
self._metadata: dict[str, dict[str, str]] = {}
|
| 252 |
+
else:
|
| 253 |
+
self._metadata = metadata
|
| 254 |
+
self._area_weights = area_weights
|
| 255 |
+
# Dictionaries of tensors of shape [n_lat, n_lon] represnting time means
|
| 256 |
+
self._target_agg = TimeMeanAggregator(
|
| 257 |
+
target=target, metadata=metadata, area_weights=self._area_weights
|
| 258 |
+
)
|
| 259 |
+
self._gen_agg = TimeMeanAggregator(
|
| 260 |
+
target=target,
|
| 261 |
+
metadata=metadata,
|
| 262 |
+
reference_means=reference_means,
|
| 263 |
+
area_weights=self._area_weights,
|
| 264 |
+
)
|
| 265 |
+
self._channel_mean_names = channel_mean_names
|
| 266 |
+
|
| 267 |
+
@torch.no_grad()
|
| 268 |
+
def record_batch(
|
| 269 |
+
self,
|
| 270 |
+
target_data: dict[str, torch.Tensor],
|
| 271 |
+
gen_data: dict[str, torch.Tensor],
|
| 272 |
+
target_data_norm: dict[str, torch.Tensor],
|
| 273 |
+
gen_data_norm: dict[str, torch.Tensor],
|
| 274 |
+
i_time_start: int = 0,
|
| 275 |
+
):
|
| 276 |
+
if self._target == "norm":
|
| 277 |
+
target_data = target_data_norm
|
| 278 |
+
gen_data = gen_data_norm
|
| 279 |
+
self._target_agg.record_batch(target_data, i_time_start)
|
| 280 |
+
self._gen_agg.record_batch(gen_data, i_time_start)
|
| 281 |
+
|
| 282 |
+
def _get_target_gen_pairs(self) -> list[_TargetGenPair]:
|
| 283 |
+
target_data = self._target_agg.get_data()
|
| 284 |
+
gen_data = self._gen_agg.get_data()
|
| 285 |
+
|
| 286 |
+
ret = []
|
| 287 |
+
for name in gen_data.keys():
|
| 288 |
+
ret.append(
|
| 289 |
+
_TargetGenPair(
|
| 290 |
+
gen=gen_data[name],
|
| 291 |
+
target=target_data[name],
|
| 292 |
+
name=name,
|
| 293 |
+
area_weights=self._area_weights,
|
| 294 |
+
)
|
| 295 |
+
)
|
| 296 |
+
return ret
|
| 297 |
+
|
| 298 |
+
@torch.no_grad()
|
| 299 |
+
def get_logs(self, label: str) -> Metrics:
|
| 300 |
+
logs = dict(self._gen_agg.get_logs(""))
|
| 301 |
+
preds = self._get_target_gen_pairs()
|
| 302 |
+
bias_map_key = "bias_map"
|
| 303 |
+
rmse_all_channels = {}
|
| 304 |
+
for pred in preds:
|
| 305 |
+
bias_data = pred.bias().cpu().numpy()
|
| 306 |
+
vmin_bias, vmax_bias = get_cmap_limits(bias_data, diverging=True)
|
| 307 |
+
cmap = plt.colormaps.get_cmap("RdBu_r")
|
| 308 |
+
cmap.set_bad(color=(0.7, 0.7, 0.7))
|
| 309 |
+
bias_fig = plot_imshow(
|
| 310 |
+
bias_data, vmin=vmin_bias, vmax=vmax_bias, cmap=cmap, nan_padding=False
|
| 311 |
+
)
|
| 312 |
+
bias_image = wandb.Image(
|
| 313 |
+
bias_fig,
|
| 314 |
+
caption=self._get_caption(
|
| 315 |
+
bias_map_key, pred.name, vmin_bias, vmax_bias
|
| 316 |
+
),
|
| 317 |
+
)
|
| 318 |
+
plt.close("all")
|
| 319 |
+
rmse_all_channels[pred.name] = pred.rmse()
|
| 320 |
+
logs.update({f"rmse/{pred.name}": rmse_all_channels[pred.name]})
|
| 321 |
+
if self._target == "denorm":
|
| 322 |
+
logs.update(
|
| 323 |
+
{
|
| 324 |
+
f"{bias_map_key}/{pred.name}": bias_image,
|
| 325 |
+
f"bias/{pred.name}": pred.weighted_mean_bias(),
|
| 326 |
+
}
|
| 327 |
+
)
|
| 328 |
+
if self._target == "norm":
|
| 329 |
+
metric_name = "rmse/channel_mean"
|
| 330 |
+
if self._channel_mean_names is None:
|
| 331 |
+
values_to_average = list(rmse_all_channels.values())
|
| 332 |
+
else:
|
| 333 |
+
values_to_average = [
|
| 334 |
+
rmse_all_channels[name] for name in self._channel_mean_names
|
| 335 |
+
]
|
| 336 |
+
logs.update({metric_name: sum(values_to_average) / len(values_to_average)})
|
| 337 |
+
|
| 338 |
+
if len(label) != 0:
|
| 339 |
+
return {f"{label}/{key}": logs[key] for key in logs}
|
| 340 |
+
return logs
|
| 341 |
+
|
| 342 |
+
def _get_caption(self, key: str, name: str, vmin: float, vmax: float) -> str:
|
| 343 |
+
if name in self._metadata:
|
| 344 |
+
caption_name = self._metadata[name]["long_name"]
|
| 345 |
+
units = self._metadata[name]["units"]
|
| 346 |
+
else:
|
| 347 |
+
caption_name, units = name, "unknown_units"
|
| 348 |
+
caption = self._image_captions[key].format(name=caption_name, units=units)
|
| 349 |
+
caption += f" vmin={vmin:.4g}, vmax={vmax:.4g}."
|
| 350 |
+
return caption
|
samudra/aggregator/loss.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
|
| 8 |
+
from samudra.constants import TensorMap
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def get_depth_loss_dict(
|
| 12 |
+
label: str,
|
| 13 |
+
loss_per_channel: torch.Tensor,
|
| 14 |
+
*,
|
| 15 |
+
tensor_map: TensorMap,
|
| 16 |
+
) -> dict[str, torch.Tensor]:
|
| 17 |
+
metrics = {}
|
| 18 |
+
for depth in tensor_map.DEPTH_SET:
|
| 19 |
+
metrics[f"{label}/loss/depth/depth_{depth}_loss"] = loss_per_channel[
|
| 20 |
+
tensor_map.DP_3D_IDX[depth]
|
| 21 |
+
].mean()
|
| 22 |
+
return metrics
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_variable_loss_dict(
|
| 26 |
+
label: str,
|
| 27 |
+
loss_per_channel: torch.Tensor,
|
| 28 |
+
*,
|
| 29 |
+
tensor_map: TensorMap,
|
| 30 |
+
) -> dict[str, torch.Tensor]:
|
| 31 |
+
metrics = {}
|
| 32 |
+
for variable in tensor_map.VAR_SET:
|
| 33 |
+
metrics[f"{label}/loss/variable/{variable}_loss"] = loss_per_channel[
|
| 34 |
+
tensor_map.VAR_3D_IDX[variable]
|
| 35 |
+
].mean()
|
| 36 |
+
return metrics
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def get_channel_loss_dict(
|
| 40 |
+
label: str,
|
| 41 |
+
loss_per_channel: torch.Tensor,
|
| 42 |
+
*,
|
| 43 |
+
tensor_map: TensorMap,
|
| 44 |
+
loss_name: str = "loss",
|
| 45 |
+
) -> dict[str, torch.Tensor]:
|
| 46 |
+
return get_channel_dict(label, loss_name, loss_per_channel, tensor_map=tensor_map)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def get_channel_loss_scale_dict(
|
| 50 |
+
label: str,
|
| 51 |
+
loss_scale_per_channel: torch.Tensor,
|
| 52 |
+
*,
|
| 53 |
+
tensor_map: TensorMap,
|
| 54 |
+
) -> dict[str, torch.Tensor]:
|
| 55 |
+
return get_channel_dict(
|
| 56 |
+
label,
|
| 57 |
+
"loss_scale",
|
| 58 |
+
loss_scale_per_channel,
|
| 59 |
+
tensor_map=tensor_map,
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def get_channel_dict(
|
| 64 |
+
prefix: str,
|
| 65 |
+
measure: str,
|
| 66 |
+
per_channel: torch.Tensor,
|
| 67 |
+
*,
|
| 68 |
+
tensor_map: TensorMap,
|
| 69 |
+
) -> dict[str, torch.Tensor]:
|
| 70 |
+
metrics = {}
|
| 71 |
+
for i, channel in enumerate(tensor_map.prognostic_var_names):
|
| 72 |
+
metrics[f"{prefix}/{measure}/channel/{channel}_{measure}"] = per_channel[i]
|
| 73 |
+
return metrics
|
samudra/aggregator/main.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
"""
|
| 7 |
+
The code in this directory is directly inspired by the ACE project by AI2.
|
| 8 |
+
|
| 9 |
+
See the original repository at: https://github.com/ai2cm/ace/tree/39133c18524cda85965486ecdc8cb64aac06f4c3/fme/fme/ace/aggregator
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
|
| 14 |
+
from samudra.aggregator.inference import InferenceEvaluatorAggregator
|
| 15 |
+
from samudra.aggregator.train import TrainAggregator
|
| 16 |
+
from samudra.aggregator.validate import ValidateAggregator
|
| 17 |
+
from samudra.aggregator.validate.map import MapAggregator
|
| 18 |
+
from samudra.aggregator.validate.reduced import MeanAggregator
|
| 19 |
+
from samudra.aggregator.validate.snapshot import SnapshotAggregator
|
| 20 |
+
from samudra.aggregator.validate.sub_aggregator import ValidateSubAggregator
|
| 21 |
+
from samudra.constants import TensorMap
|
| 22 |
+
from samudra.utils.data import Normalize
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class Aggregator:
|
| 26 |
+
@staticmethod
|
| 27 |
+
def get_train_aggregator(tensor_map: TensorMap) -> TrainAggregator:
|
| 28 |
+
return TrainAggregator(tensor_map)
|
| 29 |
+
|
| 30 |
+
@staticmethod
|
| 31 |
+
def get_validation_aggregator(
|
| 32 |
+
metadata: dict[str, dict[str, str]],
|
| 33 |
+
hist: int,
|
| 34 |
+
area_weights: torch.Tensor,
|
| 35 |
+
num_prognostic_channels: int,
|
| 36 |
+
tensor_map: TensorMap,
|
| 37 |
+
normalize: Normalize,
|
| 38 |
+
*,
|
| 39 |
+
include_image_aggregators: bool = True,
|
| 40 |
+
) -> ValidateAggregator:
|
| 41 |
+
val_aggregators: dict[str, ValidateSubAggregator] = {
|
| 42 |
+
"reduced": MeanAggregator(area_weights, hist),
|
| 43 |
+
}
|
| 44 |
+
if include_image_aggregators:
|
| 45 |
+
val_aggregators.update(
|
| 46 |
+
{
|
| 47 |
+
"snapshot": SnapshotAggregator(metadata, hist),
|
| 48 |
+
"mean_map": MapAggregator(metadata, hist),
|
| 49 |
+
}
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
return ValidateAggregator(
|
| 53 |
+
val_aggregators,
|
| 54 |
+
hist=hist,
|
| 55 |
+
num_prognostic_channels=num_prognostic_channels,
|
| 56 |
+
tensor_map=tensor_map,
|
| 57 |
+
normalize=normalize,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
@staticmethod
|
| 61 |
+
def get_inline_inference_aggregator(
|
| 62 |
+
n_timesteps: int,
|
| 63 |
+
metadata: dict[str, dict[str, str]],
|
| 64 |
+
hist: int,
|
| 65 |
+
area_weights: torch.Tensor,
|
| 66 |
+
wet: torch.Tensor,
|
| 67 |
+
num_prognostic_channels: int,
|
| 68 |
+
tensor_map: TensorMap,
|
| 69 |
+
normalize: Normalize,
|
| 70 |
+
channel_mean_names: list[str] | None = None,
|
| 71 |
+
) -> InferenceEvaluatorAggregator:
|
| 72 |
+
return InferenceEvaluatorAggregator(
|
| 73 |
+
n_timesteps=n_timesteps,
|
| 74 |
+
metadata=metadata,
|
| 75 |
+
hist=hist,
|
| 76 |
+
area_weights=area_weights,
|
| 77 |
+
wet=wet,
|
| 78 |
+
num_prognostic_channels=num_prognostic_channels,
|
| 79 |
+
normalize=normalize,
|
| 80 |
+
tensor_map=tensor_map,
|
| 81 |
+
record_step_20=(n_timesteps > 20),
|
| 82 |
+
log_global_mean_time_series=False,
|
| 83 |
+
log_global_mean_norm_time_series=False,
|
| 84 |
+
channel_mean_names=channel_mean_names,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
@staticmethod
|
| 88 |
+
def get_standalone_inference_aggregator(
|
| 89 |
+
n_timesteps: int,
|
| 90 |
+
metadata: dict[str, dict[str, str]],
|
| 91 |
+
hist: int,
|
| 92 |
+
area_weights: torch.Tensor,
|
| 93 |
+
wet: torch.Tensor,
|
| 94 |
+
num_prognostic_channels: int,
|
| 95 |
+
tensor_map: TensorMap,
|
| 96 |
+
normalize: Normalize,
|
| 97 |
+
channel_mean_names: list[str] | None = None,
|
| 98 |
+
) -> InferenceEvaluatorAggregator:
|
| 99 |
+
return InferenceEvaluatorAggregator(
|
| 100 |
+
n_timesteps=n_timesteps,
|
| 101 |
+
metadata=metadata,
|
| 102 |
+
hist=hist,
|
| 103 |
+
area_weights=area_weights,
|
| 104 |
+
wet=wet,
|
| 105 |
+
num_prognostic_channels=num_prognostic_channels,
|
| 106 |
+
normalize=normalize,
|
| 107 |
+
tensor_map=tensor_map,
|
| 108 |
+
record_step_20=(n_timesteps > 20),
|
| 109 |
+
log_global_mean_time_series=True,
|
| 110 |
+
log_global_mean_norm_time_series=True,
|
| 111 |
+
channel_mean_names=channel_mean_names,
|
| 112 |
+
)
|
samudra/aggregator/metrics.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def weighted_sum(
|
| 10 |
+
tensor: torch.Tensor,
|
| 11 |
+
weights: torch.Tensor | None = None,
|
| 12 |
+
dim: tuple[int, ...] = (-2, -1),
|
| 13 |
+
keepdim: bool = False,
|
| 14 |
+
) -> torch.Tensor:
|
| 15 |
+
"""Computes the weighted sum across the specified list of dimensions.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
tensor: torch.Tensor
|
| 19 |
+
weights: Weights to apply to the sum.
|
| 20 |
+
dim: Dimensions to compute the sum over.
|
| 21 |
+
keepdim: Whether the output tensor has `dim` retained or not.
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
a tensor of the weighted sum averaged over the specified dimensions `dim`.
|
| 25 |
+
"""
|
| 26 |
+
if weights is None:
|
| 27 |
+
return tensor.nansum(dim=dim, keepdim=keepdim)
|
| 28 |
+
|
| 29 |
+
weights = weights.to(tensor.device)
|
| 30 |
+
return (tensor * weights).nansum(dim=dim, keepdim=keepdim)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def area_weighted_sum(
|
| 34 |
+
data: torch.Tensor,
|
| 35 |
+
area_weights: torch.Tensor,
|
| 36 |
+
dim: tuple[int, ...] = (-2, -1),
|
| 37 |
+
keepdim: bool = False,
|
| 38 |
+
) -> torch.Tensor:
|
| 39 |
+
return weighted_sum(data, area_weights, dim=dim, keepdim=keepdim)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def weighted_mean(
|
| 43 |
+
tensor: torch.Tensor,
|
| 44 |
+
weights: torch.Tensor | None = None,
|
| 45 |
+
dim: tuple[int, ...] = (-2, -1),
|
| 46 |
+
keepdim: bool = False,
|
| 47 |
+
) -> torch.Tensor:
|
| 48 |
+
"""Computes the weighted mean across the specified list of dimensions.
|
| 49 |
+
|
| 50 |
+
This function treats NaNs as land cells and does not include them in the
|
| 51 |
+
computation.
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
tensor: torch.Tensor
|
| 55 |
+
weights: Weights to apply to the mean.
|
| 56 |
+
dim: Dimensions to compute the mean over.
|
| 57 |
+
keepdim: Whether the output tensor has `dim` retained or not.
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
a tensor of the weighted mean averaged over the specified dimensions `dim`.
|
| 61 |
+
"""
|
| 62 |
+
if weights is None:
|
| 63 |
+
return tensor.nanmean(dim=dim, keepdim=keepdim)
|
| 64 |
+
|
| 65 |
+
weights = weights.to(tensor.device)
|
| 66 |
+
denom = torch.where(torch.isnan(tensor), 0.0, weights.expand(tensor.shape)).sum(
|
| 67 |
+
dim=dim, keepdim=keepdim
|
| 68 |
+
)
|
| 69 |
+
return (tensor * weights).nansum(dim=dim, keepdim=keepdim) / denom
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def area_weighted_mean(
|
| 73 |
+
data: torch.Tensor,
|
| 74 |
+
area_weights: torch.Tensor,
|
| 75 |
+
dim: tuple[int, ...] = (-2, -1),
|
| 76 |
+
keepdim: bool = False,
|
| 77 |
+
) -> torch.Tensor:
|
| 78 |
+
return weighted_mean(data, area_weights, dim=dim, keepdim=keepdim)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def area_weighted_std(
|
| 82 |
+
data: torch.Tensor,
|
| 83 |
+
area_weights: torch.Tensor,
|
| 84 |
+
keepdim: bool = False,
|
| 85 |
+
):
|
| 86 |
+
return area_weighted_mean(
|
| 87 |
+
(data - area_weighted_mean(data, area_weights, keepdim=True)) ** 2,
|
| 88 |
+
area_weights,
|
| 89 |
+
keepdim=keepdim,
|
| 90 |
+
).sqrt()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def area_weighted_rmse(
|
| 94 |
+
target: torch.Tensor, gen: torch.Tensor, area_weights: torch.Tensor
|
| 95 |
+
) -> torch.Tensor:
|
| 96 |
+
area_weights = area_weights.to(target.device)
|
| 97 |
+
return torch.sqrt(area_weighted_mean((gen - target) ** 2, area_weights))
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def area_weighted_mean_bias(
|
| 101 |
+
target: torch.Tensor, gen: torch.Tensor, area_weights: torch.Tensor
|
| 102 |
+
) -> torch.Tensor:
|
| 103 |
+
area_weights = area_weights.to(target.device)
|
| 104 |
+
return area_weighted_mean(gen - target, area_weights)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def gradient_magnitude(
|
| 108 |
+
tensor: torch.Tensor, dim: tuple[int, ...] = (-2, -1)
|
| 109 |
+
) -> torch.Tensor:
|
| 110 |
+
"""Compute the magnitude of gradient across the specified dimensions."""
|
| 111 |
+
gradients = torch.gradient(tensor, dim=dim)
|
| 112 |
+
return torch.sqrt(sum([g**2 for g in gradients])) # type: ignore
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def weighted_mean_gradient_magnitude(
|
| 116 |
+
tensor: torch.Tensor,
|
| 117 |
+
weights: torch.Tensor | None = None,
|
| 118 |
+
dim: tuple[int, ...] = (-2, -1),
|
| 119 |
+
) -> torch.Tensor:
|
| 120 |
+
"""Compute weighted mean of gradient magnitude across the specified dimensions."""
|
| 121 |
+
return weighted_mean(gradient_magnitude(tensor, dim), weights=weights, dim=dim)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def gradient_magnitude_percent_diff(
|
| 125 |
+
target: torch.Tensor,
|
| 126 |
+
gen: torch.Tensor,
|
| 127 |
+
weights: torch.Tensor | None = None,
|
| 128 |
+
dim: tuple[int, ...] = (-2, -1),
|
| 129 |
+
) -> torch.Tensor:
|
| 130 |
+
"""Compute the percent difference of the weighted mean gradient magnitude across
|
| 131 |
+
the specified dimensions.
|
| 132 |
+
"""
|
| 133 |
+
target_grad_mag = weighted_mean_gradient_magnitude(target, weights, dim)
|
| 134 |
+
gen_grad_mag = weighted_mean_gradient_magnitude(gen, weights, dim)
|
| 135 |
+
return 100 * (gen_grad_mag - target_grad_mag) / target_grad_mag
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def area_weighted_gradient_magnitude_percent_diff(
|
| 139 |
+
target: torch.Tensor, gen: torch.Tensor, area_weights: torch.Tensor
|
| 140 |
+
):
|
| 141 |
+
area_weights = area_weights.to(target.device)
|
| 142 |
+
return gradient_magnitude_percent_diff(target, gen, weights=area_weights)
|
samudra/aggregator/plotting.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
import gc
|
| 7 |
+
|
| 8 |
+
import matplotlib.pyplot as plt
|
| 9 |
+
import numpy as np
|
| 10 |
+
from matplotlib.colors import Colormap
|
| 11 |
+
from matplotlib.figure import Figure
|
| 12 |
+
|
| 13 |
+
from samudra.utils.wandb import WandBLogger
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_cmap_limits(data: np.ndarray, diverging=False) -> tuple[float, float]:
|
| 17 |
+
vmin = np.nanmin(data)
|
| 18 |
+
vmax = np.nanmax(data)
|
| 19 |
+
if diverging:
|
| 20 |
+
vmax = max(abs(vmin), abs(vmax))
|
| 21 |
+
vmin = -vmax
|
| 22 |
+
return vmin, vmax
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def plot_imshow(
|
| 26 |
+
data: np.ndarray,
|
| 27 |
+
vmin: float | None = None,
|
| 28 |
+
vmax: float | None = None,
|
| 29 |
+
cmap: Colormap | None = None,
|
| 30 |
+
flip_lat: bool = True,
|
| 31 |
+
use_colorbar: bool = True,
|
| 32 |
+
nan_padding: bool = True,
|
| 33 |
+
) -> Figure:
|
| 34 |
+
"""Plot a 2D array using imshow, ensuring figure size is same as array size."""
|
| 35 |
+
min_ = np.nanmin(data) if vmin is None else vmin
|
| 36 |
+
max_ = np.nanmax(data) if vmax is None else vmax
|
| 37 |
+
|
| 38 |
+
if flip_lat:
|
| 39 |
+
lat_dim = -2
|
| 40 |
+
data = np.flip(data, axis=lat_dim)
|
| 41 |
+
|
| 42 |
+
if use_colorbar:
|
| 43 |
+
height, width = data.shape
|
| 44 |
+
colorbar_width = max(1, int(0.025 * width))
|
| 45 |
+
range_ = np.linspace(min_, max_, height)
|
| 46 |
+
range_ = np.repeat(range_[:, np.newaxis], repeats=colorbar_width, axis=1)
|
| 47 |
+
range_ = np.flipud(range_) # wandb images start from top (and left)
|
| 48 |
+
padding = np.zeros((height, colorbar_width))
|
| 49 |
+
if nan_padding:
|
| 50 |
+
padding = padding + np.nan # Set when using non-diverging map
|
| 51 |
+
data = np.concatenate((data, padding, range_), axis=1)
|
| 52 |
+
|
| 53 |
+
# make figure size (in pixels) be the same as array size
|
| 54 |
+
figsize = np.array(data.T.shape) / plt.rcParams["figure.dpi"]
|
| 55 |
+
fig = Figure(figsize=figsize) # create directly for cleanup when it leaves scope
|
| 56 |
+
ax = fig.add_axes((0, 0, 1, 1))
|
| 57 |
+
ax.imshow(data, cmap=cmap, vmin=vmin, vmax=vmax)
|
| 58 |
+
ax.set_axis_off()
|
| 59 |
+
return fig
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def plot_paneled_data(
|
| 63 |
+
data: list[list[np.ndarray]],
|
| 64 |
+
diverging: bool,
|
| 65 |
+
caption: str | None = None,
|
| 66 |
+
):
|
| 67 |
+
"""Plot a list of 2D data arrays in a paneled plot."""
|
| 68 |
+
if diverging:
|
| 69 |
+
cmap = plt.colormaps.get_cmap("RdBu_r")
|
| 70 |
+
cmap.set_bad(color=(0.7, 0.7, 0.7))
|
| 71 |
+
else:
|
| 72 |
+
cmap = plt.colormaps.get_cmap("viridis")
|
| 73 |
+
cmap.set_bad(color="white")
|
| 74 |
+
vmin = np.inf
|
| 75 |
+
vmax = -np.inf
|
| 76 |
+
for row in data:
|
| 77 |
+
for arr in row:
|
| 78 |
+
vmin = min(vmin, np.nanmin(arr))
|
| 79 |
+
vmax = max(vmax, np.nanmax(arr))
|
| 80 |
+
if diverging:
|
| 81 |
+
vmax = max(abs(vmin), abs(vmax))
|
| 82 |
+
vmin = -vmax
|
| 83 |
+
if caption is not None:
|
| 84 |
+
caption += " "
|
| 85 |
+
else:
|
| 86 |
+
caption = ""
|
| 87 |
+
|
| 88 |
+
caption += f"vmin={vmin:.4g}, vmax={vmax:.4g}."
|
| 89 |
+
|
| 90 |
+
if diverging:
|
| 91 |
+
fill_value = 0.5 * (vmin + vmax)
|
| 92 |
+
else:
|
| 93 |
+
fill_value = vmin
|
| 94 |
+
all_data = _stitch_data_panels(data, fill_value=fill_value)
|
| 95 |
+
|
| 96 |
+
fig = plot_imshow(
|
| 97 |
+
all_data, vmin=vmin, vmax=vmax, cmap=cmap, nan_padding=not diverging
|
| 98 |
+
)
|
| 99 |
+
wandb = WandBLogger.get_instance()
|
| 100 |
+
wandb_image = wandb.Image(fig, caption=caption)
|
| 101 |
+
plt.close(fig)
|
| 102 |
+
|
| 103 |
+
# necessary to avoid CUDA error in some contexts
|
| 104 |
+
# see https://github.com/ai2cm/full-model/issues/740#issuecomment-2086546187
|
| 105 |
+
gc.collect()
|
| 106 |
+
|
| 107 |
+
return wandb_image
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _stitch_data_panels(data: list[list[np.ndarray]], fill_value) -> np.ndarray:
|
| 111 |
+
for row in data:
|
| 112 |
+
if len(row) != len(data[0]):
|
| 113 |
+
raise ValueError("All rows must have the same number of panels.")
|
| 114 |
+
|
| 115 |
+
n_rows = len(data)
|
| 116 |
+
n_cols = len(data[0])
|
| 117 |
+
for row in data:
|
| 118 |
+
for arr in row:
|
| 119 |
+
if arr.shape != data[0][0].shape:
|
| 120 |
+
raise ValueError("All panels must have the same shape.")
|
| 121 |
+
|
| 122 |
+
stitched_data = np.full(
|
| 123 |
+
(
|
| 124 |
+
n_rows * data[0][0].shape[0] + n_rows - 1,
|
| 125 |
+
n_cols * data[0][0].shape[1] + n_cols - 1,
|
| 126 |
+
),
|
| 127 |
+
fill_value=fill_value,
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
# iterate over rows backwards, as the image starts in the bottom left
|
| 131 |
+
# and moves upwards
|
| 132 |
+
for i, row in enumerate(reversed(data)):
|
| 133 |
+
for j, arr in enumerate(row):
|
| 134 |
+
start_row = i * (arr.shape[0] + 1)
|
| 135 |
+
end_row = start_row + arr.shape[0]
|
| 136 |
+
start_col = j * (arr.shape[1] + 1)
|
| 137 |
+
end_col = start_col + arr.shape[1]
|
| 138 |
+
stitched_data[start_row:end_row, start_col:end_col] = arr
|
| 139 |
+
|
| 140 |
+
return stitched_data
|
samudra/aggregator/train.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
|
| 8 |
+
from samudra.aggregator.loss import (
|
| 9 |
+
get_channel_loss_dict,
|
| 10 |
+
get_depth_loss_dict,
|
| 11 |
+
get_variable_loss_dict,
|
| 12 |
+
)
|
| 13 |
+
from samudra.constants import TensorMap
|
| 14 |
+
from samudra.utils.distributed import all_reduce_mean
|
| 15 |
+
from samudra.utils.output import TrainBatchOutput
|
| 16 |
+
from samudra.utils.wandb import Metrics
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TrainAggregator:
|
| 20 |
+
"""Aggregates train statistics for an epoch."""
|
| 21 |
+
|
| 22 |
+
def __init__(self, tensor_map: TensorMap):
|
| 23 |
+
self.tensor_map = tensor_map
|
| 24 |
+
self._n_batches = 0
|
| 25 |
+
self._loss = torch.tensor(torch.nan)
|
| 26 |
+
self._loss_per_channel = torch.tensor(torch.nan)
|
| 27 |
+
|
| 28 |
+
@torch.no_grad()
|
| 29 |
+
def record_batch(self, batch: TrainBatchOutput):
|
| 30 |
+
if self._n_batches == 0:
|
| 31 |
+
self._loss = batch.loss
|
| 32 |
+
self._loss_per_channel = batch.loss_per_channel
|
| 33 |
+
else:
|
| 34 |
+
self._loss += batch.loss
|
| 35 |
+
self._loss_per_channel += batch.loss_per_channel
|
| 36 |
+
self._n_batches += 1
|
| 37 |
+
|
| 38 |
+
@torch.no_grad()
|
| 39 |
+
def get_logs(self, label: str = "train") -> Metrics:
|
| 40 |
+
loss = self._loss / self._n_batches
|
| 41 |
+
|
| 42 |
+
loss_per_channel = self._loss_per_channel / self._n_batches
|
| 43 |
+
depth_loss_dict = get_depth_loss_dict(
|
| 44 |
+
label, loss_per_channel, tensor_map=self.tensor_map
|
| 45 |
+
)
|
| 46 |
+
var_loss_dict = get_variable_loss_dict(
|
| 47 |
+
label, loss_per_channel, tensor_map=self.tensor_map
|
| 48 |
+
)
|
| 49 |
+
channel_loss_dict = get_channel_loss_dict(
|
| 50 |
+
label, loss_per_channel, tensor_map=self.tensor_map
|
| 51 |
+
)
|
| 52 |
+
logs = {
|
| 53 |
+
f"{label}/mean/loss": loss,
|
| 54 |
+
**depth_loss_dict,
|
| 55 |
+
**var_loss_dict,
|
| 56 |
+
**channel_loss_dict,
|
| 57 |
+
}
|
| 58 |
+
meaned_logs = {}
|
| 59 |
+
for key in sorted(logs.keys()):
|
| 60 |
+
meaned_logs[key] = float(all_reduce_mean(logs[key].detach()).cpu().numpy())
|
| 61 |
+
|
| 62 |
+
return meaned_logs
|
samudra/aggregator/validate/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
from .main import ValidateAggregator
|
samudra/aggregator/validate/main.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
|
| 8 |
+
from samudra.aggregator.train import TrainAggregator
|
| 9 |
+
from samudra.aggregator.validate.sub_aggregator import ValidateSubAggregator
|
| 10 |
+
from samudra.constants import TensorMap
|
| 11 |
+
from samudra.utils.data import Normalize, get_aggregator_dicts
|
| 12 |
+
from samudra.utils.output import ValBatchOutput
|
| 13 |
+
from samudra.utils.wandb import Metrics, MetricsDict
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ValidateAggregator(TrainAggregator):
|
| 17 |
+
"""Aggregates Validation Statistics."""
|
| 18 |
+
|
| 19 |
+
def __init__(
|
| 20 |
+
self,
|
| 21 |
+
aggregators: dict[str, ValidateSubAggregator],
|
| 22 |
+
hist: int,
|
| 23 |
+
num_prognostic_channels: int,
|
| 24 |
+
*,
|
| 25 |
+
tensor_map: TensorMap,
|
| 26 |
+
normalize: Normalize,
|
| 27 |
+
):
|
| 28 |
+
super().__init__(tensor_map)
|
| 29 |
+
self._aggregators = aggregators
|
| 30 |
+
self.hist = hist
|
| 31 |
+
self.num_prognostic_channels = num_prognostic_channels
|
| 32 |
+
self.normalize = normalize
|
| 33 |
+
|
| 34 |
+
# TODO(jder): we could remove this by moving from inheritance
|
| 35 |
+
# to composition with the TrainAggregator functionality.
|
| 36 |
+
def record_batch(self, batch):
|
| 37 |
+
raise NotImplementedError(
|
| 38 |
+
"Call record_validation_batch instead of record_batch"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
@torch.no_grad()
|
| 42 |
+
def record_validation_batch(self, batch: ValBatchOutput):
|
| 43 |
+
super().record_batch(batch) # Record losses
|
| 44 |
+
|
| 45 |
+
# If there are no log aggregators, omit doing any extra work.
|
| 46 |
+
if not self._aggregators:
|
| 47 |
+
return
|
| 48 |
+
|
| 49 |
+
# Translate the GridContext mask by removing history.
|
| 50 |
+
target_data = batch.target_data # [B, C*(hist+1), H, W]
|
| 51 |
+
wet = batch.ctx.label_mask # [C*(hist+1), H, W]
|
| 52 |
+
assert wet.shape == target_data.shape[1:], (
|
| 53 |
+
"The wetmask must match the target data shape excluding batch."
|
| 54 |
+
)
|
| 55 |
+
assert wet.shape[0] % (self.hist + 1) == 0, (
|
| 56 |
+
"The wetmask channel count must be divisible by history size."
|
| 57 |
+
)
|
| 58 |
+
first_wetmask_chunk = wet.shape[0] // (self.hist + 1)
|
| 59 |
+
wet = wet[:first_wetmask_chunk] # [C, H, W]
|
| 60 |
+
|
| 61 |
+
if len(target_data) == 0:
|
| 62 |
+
raise ValueError("No data in target_data")
|
| 63 |
+
if len(batch.gen_data) == 0:
|
| 64 |
+
raise ValueError("No data in gen_data")
|
| 65 |
+
|
| 66 |
+
assert target_data.shape[1] == self.num_prognostic_channels
|
| 67 |
+
target_data_dict, target_data_unnorm_dict = get_aggregator_dicts(
|
| 68 |
+
target_data,
|
| 69 |
+
normalize=self.normalize,
|
| 70 |
+
tensor_map=self.tensor_map,
|
| 71 |
+
wet=wet,
|
| 72 |
+
long_rollout=False,
|
| 73 |
+
input_type="prognostic",
|
| 74 |
+
num_prognostic_channels=self.num_prognostic_channels,
|
| 75 |
+
hist=self.hist,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
gen_data_dict, gen_data_unnorm_dict = get_aggregator_dicts(
|
| 79 |
+
batch.gen_data,
|
| 80 |
+
normalize=self.normalize,
|
| 81 |
+
tensor_map=self.tensor_map,
|
| 82 |
+
wet=wet,
|
| 83 |
+
long_rollout=False,
|
| 84 |
+
input_type="prognostic",
|
| 85 |
+
num_prognostic_channels=self.num_prognostic_channels,
|
| 86 |
+
hist=self.hist,
|
| 87 |
+
)
|
| 88 |
+
input_data_dict, input_data_unnorm_dict = get_aggregator_dicts(
|
| 89 |
+
batch.input_data,
|
| 90 |
+
normalize=self.normalize,
|
| 91 |
+
tensor_map=self.tensor_map,
|
| 92 |
+
wet=wet,
|
| 93 |
+
long_rollout=False,
|
| 94 |
+
input_type="input",
|
| 95 |
+
num_prognostic_channels=self.num_prognostic_channels,
|
| 96 |
+
hist=self.hist,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
for agg in self._aggregators.values():
|
| 100 |
+
agg.record_batch(
|
| 101 |
+
loss=batch.loss,
|
| 102 |
+
target_data=target_data_unnorm_dict,
|
| 103 |
+
gen_data=gen_data_unnorm_dict,
|
| 104 |
+
input_data=input_data_unnorm_dict,
|
| 105 |
+
target_data_norm=target_data_dict,
|
| 106 |
+
gen_data_norm=gen_data_dict,
|
| 107 |
+
input_data_norm=input_data_dict,
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
@torch.no_grad()
|
| 111 |
+
def get_logs(self, label: str = "train") -> Metrics:
|
| 112 |
+
logs: MetricsDict = dict(super().get_logs(label))
|
| 113 |
+
for agg_label in self._aggregators:
|
| 114 |
+
for k, v in self._aggregators[agg_label].get_logs(label=agg_label).items():
|
| 115 |
+
logs[f"{label}/{k}"] = v
|
| 116 |
+
|
| 117 |
+
return logs
|
samudra/aggregator/validate/map.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
|
| 8 |
+
from samudra.aggregator.plotting import plot_paneled_data
|
| 9 |
+
from samudra.aggregator.validate.sub_aggregator import ValidateSubAggregator
|
| 10 |
+
from samudra.utils.distributed import all_reduce_mean, is_main_process
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class MapAggregator(ValidateSubAggregator):
|
| 14 |
+
"""
|
| 15 |
+
An aggregator that records the average over batches as function of lat and lon.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
_captions = {
|
| 19 |
+
"full-field": (
|
| 20 |
+
"{name} one step mean full field; "
|
| 21 |
+
"(top) generated and (bottom) target [{units}]"
|
| 22 |
+
),
|
| 23 |
+
"error": (
|
| 24 |
+
"{name} one step mean full field error (generated - target) [{units}]"
|
| 25 |
+
),
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
def __init__(
|
| 29 |
+
self, metadata: dict[str, dict[str, str]] | None = None, hist: int = 0
|
| 30 |
+
):
|
| 31 |
+
"""
|
| 32 |
+
Args:
|
| 33 |
+
metadata: Mapping of variable names their metadata that will
|
| 34 |
+
used in generating logged image captions.
|
| 35 |
+
hist: Number of history steps to include in the snapshot.
|
| 36 |
+
"""
|
| 37 |
+
if metadata is None:
|
| 38 |
+
metadata = {}
|
| 39 |
+
else:
|
| 40 |
+
self._metadata = metadata
|
| 41 |
+
self._n_batches = 0
|
| 42 |
+
self._target_data: dict[str, torch.Tensor] = {}
|
| 43 |
+
self._gen_data: dict[str, torch.Tensor] = {}
|
| 44 |
+
self.hist = hist
|
| 45 |
+
|
| 46 |
+
@torch.no_grad()
|
| 47 |
+
def record_batch(
|
| 48 |
+
self,
|
| 49 |
+
loss,
|
| 50 |
+
target_data,
|
| 51 |
+
gen_data,
|
| 52 |
+
input_data,
|
| 53 |
+
target_data_norm,
|
| 54 |
+
gen_data_norm,
|
| 55 |
+
input_data_norm,
|
| 56 |
+
):
|
| 57 |
+
self._loss = loss
|
| 58 |
+
for name in target_data:
|
| 59 |
+
if name in self._target_data:
|
| 60 |
+
self._target_data[name] += target_data[name].mean(dim=0)
|
| 61 |
+
else:
|
| 62 |
+
self._target_data[name] = target_data[name].mean(dim=0)
|
| 63 |
+
for name in gen_data:
|
| 64 |
+
if name in self._gen_data:
|
| 65 |
+
self._gen_data[name] += gen_data[name].mean(dim=0)
|
| 66 |
+
else:
|
| 67 |
+
self._gen_data[name] = gen_data[name].mean(dim=0)
|
| 68 |
+
self._n_batches += 1
|
| 69 |
+
|
| 70 |
+
@torch.no_grad()
|
| 71 |
+
def get_logs(self, label: str):
|
| 72 |
+
"""
|
| 73 |
+
Returns logs as can be reported to WandB.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
label: Label to prepend to all log keys.
|
| 77 |
+
"""
|
| 78 |
+
time_dim = 0
|
| 79 |
+
target_time = self.hist # Use latest time step
|
| 80 |
+
image_logs = {}
|
| 81 |
+
render_images = is_main_process()
|
| 82 |
+
sorted_names = sorted(list(self._gen_data.keys()))
|
| 83 |
+
for name in sorted_names:
|
| 84 |
+
# use first sample in batch
|
| 85 |
+
gen = (
|
| 86 |
+
all_reduce_mean(
|
| 87 |
+
self._gen_data[name].select(dim=time_dim, index=target_time)
|
| 88 |
+
)
|
| 89 |
+
/ self._n_batches
|
| 90 |
+
)
|
| 91 |
+
target = (
|
| 92 |
+
all_reduce_mean(
|
| 93 |
+
self._target_data[name].select(dim=time_dim, index=target_time)
|
| 94 |
+
)
|
| 95 |
+
/ self._n_batches
|
| 96 |
+
)
|
| 97 |
+
if not render_images:
|
| 98 |
+
continue
|
| 99 |
+
image_logs[f"image-error/{name}"] = plot_paneled_data(
|
| 100 |
+
[[(gen - target).cpu().numpy()]],
|
| 101 |
+
diverging=True,
|
| 102 |
+
caption=self._get_caption("error", name),
|
| 103 |
+
)
|
| 104 |
+
image_logs[f"image-full-field/{name}"] = plot_paneled_data(
|
| 105 |
+
[
|
| 106 |
+
[gen.cpu().numpy()],
|
| 107 |
+
[target.cpu().numpy()],
|
| 108 |
+
],
|
| 109 |
+
diverging=False,
|
| 110 |
+
caption=self._get_caption("full-field", name),
|
| 111 |
+
)
|
| 112 |
+
image_logs = {f"{label}/{key}": image_logs[key] for key in image_logs}
|
| 113 |
+
return image_logs
|
| 114 |
+
|
| 115 |
+
def _get_caption(self, key: str, name: str) -> str:
|
| 116 |
+
if name in self._metadata:
|
| 117 |
+
caption_name = self._metadata[name]["long_name"]
|
| 118 |
+
units = self._metadata[name]["units"]
|
| 119 |
+
else:
|
| 120 |
+
caption_name, units = name, "unknown_units"
|
| 121 |
+
caption = self._captions[key].format(name=caption_name, units=units)
|
| 122 |
+
return caption
|
samudra/aggregator/validate/reduced.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
from collections.abc import Callable
|
| 7 |
+
from functools import partial
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import torch
|
| 11 |
+
|
| 12 |
+
from samudra.aggregator.metrics import (
|
| 13 |
+
area_weighted_gradient_magnitude_percent_diff,
|
| 14 |
+
area_weighted_mean_bias,
|
| 15 |
+
area_weighted_rmse,
|
| 16 |
+
)
|
| 17 |
+
from samudra.aggregator.validate.sub_aggregator import ValidateSubAggregator
|
| 18 |
+
from samudra.utils.device import get_device
|
| 19 |
+
from samudra.utils.distributed import all_reduce_mean
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class AreaWeightedReducedMetric:
|
| 23 |
+
"""
|
| 24 |
+
A wrapper around an area-weighted metric function.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(
|
| 28 |
+
self,
|
| 29 |
+
device: torch.device,
|
| 30 |
+
compute_metric: Callable[[torch.Tensor, torch.Tensor], torch.Tensor],
|
| 31 |
+
):
|
| 32 |
+
self._compute_metric = compute_metric
|
| 33 |
+
self._total: torch.Tensor | None = None
|
| 34 |
+
self._device = device
|
| 35 |
+
|
| 36 |
+
def record(self, target: torch.Tensor, gen: torch.Tensor):
|
| 37 |
+
"""Add a batch of data to the metric.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
target: Target data. Should have shape [batch, time, height, width].
|
| 41 |
+
gen: Generated data. Should have shape [batch, time, height, width].
|
| 42 |
+
"""
|
| 43 |
+
new_value = self._compute_metric(target, gen).mean(dim=0)
|
| 44 |
+
if self._total is None:
|
| 45 |
+
self._total = torch.zeros_like(new_value, device=self._device)
|
| 46 |
+
self._total += new_value
|
| 47 |
+
|
| 48 |
+
def get(self) -> torch.Tensor:
|
| 49 |
+
"""Returns the metric."""
|
| 50 |
+
assert self._total is not None
|
| 51 |
+
return self._total
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class MeanAggregator(ValidateSubAggregator):
|
| 55 |
+
"""
|
| 56 |
+
Aggregator for mean-reduced metrics.
|
| 57 |
+
|
| 58 |
+
These are metrics such as means which reduce to a single float for each batch,
|
| 59 |
+
and then can be averaged across batches to get a single float for the
|
| 60 |
+
entire dataset. This is important because the aggregator uses the mean to combine
|
| 61 |
+
metrics across batches and processors.
|
| 62 |
+
"""
|
| 63 |
+
|
| 64 |
+
def __init__(self, area_weights: torch.Tensor, target_time: int):
|
| 65 |
+
self._n_batches = 0
|
| 66 |
+
self._variable_metrics: dict | None = None
|
| 67 |
+
self._target_time = target_time
|
| 68 |
+
self._area_weights = area_weights
|
| 69 |
+
|
| 70 |
+
def _get_variable_metrics(self, gen_data):
|
| 71 |
+
if self._variable_metrics is None:
|
| 72 |
+
self._variable_metrics = {
|
| 73 |
+
"weighted_rmse": {},
|
| 74 |
+
"weighted_bias": {},
|
| 75 |
+
"weighted_grad_mag_percent_diff": {},
|
| 76 |
+
}
|
| 77 |
+
device = get_device()
|
| 78 |
+
for key in gen_data:
|
| 79 |
+
self._variable_metrics["weighted_rmse"][key] = (
|
| 80 |
+
AreaWeightedReducedMetric(
|
| 81 |
+
device=device,
|
| 82 |
+
compute_metric=partial(
|
| 83 |
+
area_weighted_rmse, area_weights=self._area_weights
|
| 84 |
+
),
|
| 85 |
+
)
|
| 86 |
+
)
|
| 87 |
+
self._variable_metrics["weighted_bias"][key] = (
|
| 88 |
+
AreaWeightedReducedMetric(
|
| 89 |
+
device=device,
|
| 90 |
+
compute_metric=partial(
|
| 91 |
+
area_weighted_mean_bias, area_weights=self._area_weights
|
| 92 |
+
),
|
| 93 |
+
)
|
| 94 |
+
)
|
| 95 |
+
self._variable_metrics["weighted_grad_mag_percent_diff"][key] = (
|
| 96 |
+
AreaWeightedReducedMetric(
|
| 97 |
+
device=device,
|
| 98 |
+
compute_metric=partial(
|
| 99 |
+
area_weighted_gradient_magnitude_percent_diff,
|
| 100 |
+
area_weights=self._area_weights,
|
| 101 |
+
), # noqa: E501
|
| 102 |
+
)
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
return self._variable_metrics
|
| 106 |
+
|
| 107 |
+
@torch.no_grad()
|
| 108 |
+
def record_batch(
|
| 109 |
+
self,
|
| 110 |
+
*,
|
| 111 |
+
loss: torch.Tensor = torch.tensor(np.nan),
|
| 112 |
+
target_data,
|
| 113 |
+
gen_data,
|
| 114 |
+
target_data_norm,
|
| 115 |
+
gen_data_norm,
|
| 116 |
+
input_data: dict[str, torch.Tensor] | None = None,
|
| 117 |
+
input_data_norm: dict[str, torch.Tensor] | None = None,
|
| 118 |
+
i_time_start: int = 0,
|
| 119 |
+
):
|
| 120 |
+
if input_data is None:
|
| 121 |
+
input_data = {}
|
| 122 |
+
if input_data_norm is None:
|
| 123 |
+
input_data_norm = {}
|
| 124 |
+
|
| 125 |
+
variable_metrics = self._get_variable_metrics(gen_data)
|
| 126 |
+
time_dim = 1
|
| 127 |
+
time_len = gen_data[list(gen_data.keys())[0]].shape[time_dim]
|
| 128 |
+
target_time = self._target_time - i_time_start
|
| 129 |
+
if target_time >= 0 and time_len > target_time:
|
| 130 |
+
for name in gen_data.keys():
|
| 131 |
+
target = target_data[name].select(dim=time_dim, index=target_time)
|
| 132 |
+
gen = gen_data[name].select(dim=time_dim, index=target_time)
|
| 133 |
+
for metric in variable_metrics:
|
| 134 |
+
variable_metrics[metric][name].record(
|
| 135 |
+
target=target,
|
| 136 |
+
gen=gen,
|
| 137 |
+
)
|
| 138 |
+
# only increment n_batches if we actually recorded a batch
|
| 139 |
+
self._n_batches += 1
|
| 140 |
+
|
| 141 |
+
def _get_data(self):
|
| 142 |
+
if self._variable_metrics is None or self._n_batches == 0:
|
| 143 |
+
raise ValueError("No batches have been recorded.")
|
| 144 |
+
data: dict[str, torch.Tensor] = {}
|
| 145 |
+
for metric in self._variable_metrics:
|
| 146 |
+
for key in self._variable_metrics[metric]:
|
| 147 |
+
data[f"{metric}/{key}"] = (
|
| 148 |
+
self._variable_metrics[metric][key].get() / self._n_batches
|
| 149 |
+
)
|
| 150 |
+
meaned_data: dict[str, float] = {}
|
| 151 |
+
for key in sorted(data.keys()):
|
| 152 |
+
meaned_data[key] = float(all_reduce_mean(data[key].detach()).cpu().numpy())
|
| 153 |
+
return meaned_data
|
| 154 |
+
|
| 155 |
+
@torch.no_grad()
|
| 156 |
+
def get_logs(self, label: str):
|
| 157 |
+
"""
|
| 158 |
+
Returns logs as can be reported to WandB.
|
| 159 |
+
|
| 160 |
+
Args:
|
| 161 |
+
label: Label to prepend to all log keys.
|
| 162 |
+
"""
|
| 163 |
+
return {
|
| 164 |
+
f"{label}/{key}": data for key, data in sorted(self._get_data().items())
|
| 165 |
+
}
|
samudra/aggregator/validate/snapshot.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
|
| 8 |
+
from samudra.aggregator.plotting import plot_paneled_data
|
| 9 |
+
from samudra.aggregator.validate.sub_aggregator import ValidateSubAggregator
|
| 10 |
+
from samudra.utils.distributed import is_main_process
|
| 11 |
+
from samudra.utils.wandb import Metrics
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class SnapshotAggregator(ValidateSubAggregator):
|
| 15 |
+
"""
|
| 16 |
+
An aggregator that records the first sample of the last batch of data.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
_captions = {
|
| 20 |
+
"full-field": (
|
| 21 |
+
"{name} one step full field for last sample; "
|
| 22 |
+
"(top) generated and (bottom) target [{units}]"
|
| 23 |
+
),
|
| 24 |
+
"residual": (
|
| 25 |
+
"{name} one step residual (prediction - previous time) for last sample; "
|
| 26 |
+
"(top) generated and (bottom) target [{units}]"
|
| 27 |
+
),
|
| 28 |
+
"error": (
|
| 29 |
+
"{name} one step full field error (generated - target) "
|
| 30 |
+
"for last sample [{units}]"
|
| 31 |
+
),
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
def __init__(
|
| 35 |
+
self, metadata: dict[str, dict[str, str]] | None = None, hist: int = 0
|
| 36 |
+
):
|
| 37 |
+
"""
|
| 38 |
+
Args:
|
| 39 |
+
metadata: Mapping of variable names their metadata that will
|
| 40 |
+
used in generating logged image captions.
|
| 41 |
+
hist: Number of history steps to include in the snapshot.
|
| 42 |
+
"""
|
| 43 |
+
if metadata is None:
|
| 44 |
+
metadata = {}
|
| 45 |
+
else:
|
| 46 |
+
self._metadata = metadata
|
| 47 |
+
self.hist = hist
|
| 48 |
+
|
| 49 |
+
@torch.no_grad()
|
| 50 |
+
def record_batch(
|
| 51 |
+
self,
|
| 52 |
+
loss: torch.Tensor,
|
| 53 |
+
target_data,
|
| 54 |
+
gen_data,
|
| 55 |
+
input_data,
|
| 56 |
+
target_data_norm,
|
| 57 |
+
gen_data_norm,
|
| 58 |
+
input_data_norm,
|
| 59 |
+
):
|
| 60 |
+
self._loss = loss
|
| 61 |
+
self._target_data = target_data
|
| 62 |
+
self._gen_data = gen_data
|
| 63 |
+
self._target_data_norm = target_data_norm
|
| 64 |
+
self._gen_data_norm = gen_data_norm
|
| 65 |
+
self._input_data = input_data
|
| 66 |
+
self._input_data_norm = input_data_norm
|
| 67 |
+
|
| 68 |
+
@torch.no_grad()
|
| 69 |
+
def get_logs(self, label: str) -> Metrics:
|
| 70 |
+
"""
|
| 71 |
+
Returns logs as can be reported to WandB.
|
| 72 |
+
|
| 73 |
+
Args:
|
| 74 |
+
label: Label to prepend to all log keys.
|
| 75 |
+
"""
|
| 76 |
+
if not is_main_process():
|
| 77 |
+
return {}
|
| 78 |
+
|
| 79 |
+
time_dim = 1
|
| 80 |
+
target_time = 0 # first output time step
|
| 81 |
+
input_time = self.hist # last input time step
|
| 82 |
+
image_logs = {}
|
| 83 |
+
for name in self._gen_data.keys():
|
| 84 |
+
# use first sample in batch
|
| 85 |
+
gen = self._gen_data[name].select(dim=time_dim, index=target_time)[0].cpu()
|
| 86 |
+
target = (
|
| 87 |
+
self._target_data[name].select(dim=time_dim, index=target_time)[0].cpu()
|
| 88 |
+
)
|
| 89 |
+
input = (
|
| 90 |
+
self._input_data[name].select(dim=time_dim, index=input_time)[0].cpu()
|
| 91 |
+
)
|
| 92 |
+
images = {}
|
| 93 |
+
images["error"] = [[(gen - target).numpy()]]
|
| 94 |
+
images["full-field"] = [[gen.numpy()], [target.numpy()]]
|
| 95 |
+
images["residual"] = [[(gen - input).numpy()], [(target - input).numpy()]]
|
| 96 |
+
for key, data in images.items():
|
| 97 |
+
if key == "error" or key == "residual":
|
| 98 |
+
diverging = True
|
| 99 |
+
else:
|
| 100 |
+
diverging = False
|
| 101 |
+
caption = self._get_caption(key, name)
|
| 102 |
+
wandb_image = plot_paneled_data(data, diverging, caption=caption)
|
| 103 |
+
image_logs[f"image-{key}/{name}"] = wandb_image
|
| 104 |
+
image_logs = {f"{label}/{key}": image_logs[key] for key in image_logs}
|
| 105 |
+
return image_logs
|
| 106 |
+
|
| 107 |
+
def _get_caption(self, key: str, name: str) -> str:
|
| 108 |
+
if name in self._metadata:
|
| 109 |
+
caption_name = self._metadata[name]["long_name"]
|
| 110 |
+
units = self._metadata[name]["units"]
|
| 111 |
+
else:
|
| 112 |
+
caption_name, units = name, "unknown_units"
|
| 113 |
+
caption = self._captions[key].format(name=caption_name, units=units)
|
| 114 |
+
return caption
|
samudra/aggregator/validate/sub_aggregator.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
|
| 2 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
#
|
| 4 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 5 |
+
|
| 6 |
+
from abc import ABC, abstractmethod
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
from samudra.utils.wandb import Metrics
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ValidateSubAggregator(ABC):
|
| 14 |
+
@abstractmethod
|
| 15 |
+
def get_logs(self, label: str) -> Metrics: ...
|
| 16 |
+
|
| 17 |
+
@abstractmethod
|
| 18 |
+
def record_batch(
|
| 19 |
+
self,
|
| 20 |
+
*,
|
| 21 |
+
loss: torch.Tensor,
|
| 22 |
+
target_data,
|
| 23 |
+
gen_data,
|
| 24 |
+
input_data,
|
| 25 |
+
target_data_norm,
|
| 26 |
+
gen_data_norm,
|
| 27 |
+
input_data_norm,
|
| 28 |
+
): ...
|
samudra/backend.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
from samudra.config import DistributedConfig, EvalBackendConfig, TrainBackendConfig
|
| 14 |
+
from samudra.utils.device import set_device
|
| 15 |
+
from samudra.utils.distributed import init_distributed_mode
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _cuda_diagnostics() -> str:
|
| 19 |
+
"""Return a short string that helps debug why CUDA is (not) available."""
|
| 20 |
+
visible = os.getenv("CUDA_VISIBLE_DEVICES", None)
|
| 21 |
+
return (
|
| 22 |
+
"CUDA diagnostics: "
|
| 23 |
+
f"torch={torch.__version__}, "
|
| 24 |
+
f"torch.version.cuda={torch.version.cuda}, "
|
| 25 |
+
f"torch.cuda.is_available()={torch.cuda.is_available()}, "
|
| 26 |
+
f"torch.cuda.device_count()={torch.cuda.device_count()}, "
|
| 27 |
+
f"CUDA_VISIBLE_DEVICES={visible!r}"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def init_train_backend(
|
| 32 |
+
backend: TrainBackendConfig,
|
| 33 |
+
) -> tuple[torch.device, DistributedConfig | None]:
|
| 34 |
+
"""Given backend config, get the device and (if any) distributed configuration."""
|
| 35 |
+
match backend:
|
| 36 |
+
case "cpu":
|
| 37 |
+
device = torch.device("cpu")
|
| 38 |
+
dist_cfg = None
|
| 39 |
+
case "cuda":
|
| 40 |
+
if not torch.cuda.is_available():
|
| 41 |
+
raise RuntimeError(
|
| 42 |
+
"Requested backend='cuda' but CUDA is not available. "
|
| 43 |
+
+ _cuda_diagnostics()
|
| 44 |
+
)
|
| 45 |
+
device = torch.device("cuda")
|
| 46 |
+
dist_cfg = None
|
| 47 |
+
case "nccl":
|
| 48 |
+
if not torch.cuda.is_available():
|
| 49 |
+
raise RuntimeError(
|
| 50 |
+
"Requested backend='nccl' but CUDA is not available. "
|
| 51 |
+
+ _cuda_diagnostics()
|
| 52 |
+
)
|
| 53 |
+
device = torch.device("cuda")
|
| 54 |
+
dist_cfg = init_distributed_mode()
|
| 55 |
+
case "auto" if torch.cuda.is_available():
|
| 56 |
+
logger.info("auto backend detected CUDA")
|
| 57 |
+
device = torch.device("cuda")
|
| 58 |
+
try:
|
| 59 |
+
dist_cfg = init_distributed_mode()
|
| 60 |
+
logger.info("succeeded in initializing distributed mode")
|
| 61 |
+
except RuntimeError as e:
|
| 62 |
+
logger.info(
|
| 63 |
+
f"Failed to initialize distributed mode, running on single GPU.",
|
| 64 |
+
exc_info=e,
|
| 65 |
+
)
|
| 66 |
+
dist_cfg = None
|
| 67 |
+
case "auto":
|
| 68 |
+
logger.warning(
|
| 69 |
+
"auto backend: cuda not found, using CPU. " + _cuda_diagnostics()
|
| 70 |
+
)
|
| 71 |
+
device = torch.device("cpu")
|
| 72 |
+
dist_cfg = None
|
| 73 |
+
case _:
|
| 74 |
+
raise ValueError(f"Invalid backend: {backend}")
|
| 75 |
+
|
| 76 |
+
# We set this globally so we don't need to hand the device around.
|
| 77 |
+
# See https://github.com/m2lines/Samudra/issues/87.
|
| 78 |
+
set_device(device)
|
| 79 |
+
|
| 80 |
+
return device, dist_cfg
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def init_eval_backend(backend: EvalBackendConfig) -> torch.device:
|
| 84 |
+
"""Given evaluation backend config, get the device."""
|
| 85 |
+
match backend:
|
| 86 |
+
case "cpu":
|
| 87 |
+
device = torch.device("cpu")
|
| 88 |
+
case "cuda":
|
| 89 |
+
if not torch.cuda.is_available():
|
| 90 |
+
raise RuntimeError(
|
| 91 |
+
"Requested backend='cuda' but CUDA is not available. "
|
| 92 |
+
+ _cuda_diagnostics()
|
| 93 |
+
)
|
| 94 |
+
device = torch.device("cuda")
|
| 95 |
+
case "auto" if torch.cuda.is_available():
|
| 96 |
+
logger.info("auto backend detected CUDA")
|
| 97 |
+
device = torch.device("cuda")
|
| 98 |
+
case "auto":
|
| 99 |
+
logger.warning(
|
| 100 |
+
"auto backend: cuda not found, using CPU. " + _cuda_diagnostics()
|
| 101 |
+
)
|
| 102 |
+
device = torch.device("cpu")
|
| 103 |
+
case _:
|
| 104 |
+
raise ValueError(f"Invalid backend: {backend}")
|
| 105 |
+
|
| 106 |
+
set_device(device)
|
| 107 |
+
return device
|
samudra/cli.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
"""Console-script entry point: ``samudra <train|eval|viz> CONFIG [OVERRIDES...]``.
|
| 6 |
+
|
| 7 |
+
Installed as the ``samudra`` command (see ``[project.scripts]`` in
|
| 8 |
+
pyproject.toml), so a user who ``pip install samudra`` can run
|
| 9 |
+
``samudra train path/to/config.yaml`` without cloning the repo. Each
|
| 10 |
+
subcommand forwards to the same ``main`` the module entry points use
|
| 11 |
+
(``python -m samudra.train`` etc.), so behaviour is identical.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
_COMMANDS = ("train", "eval", "viz")
|
| 17 |
+
|
| 18 |
+
_HELP = """\
|
| 19 |
+
samudra — train and evaluate emulators of ocean physics
|
| 20 |
+
|
| 21 |
+
Samudra is a PyTorch package for models that auto-regressively predict future
|
| 22 |
+
ocean states — temperature, salinity, horizontal currents, sea-surface height,
|
| 23 |
+
and surface heat flux — learned from the OM4 ocean model at 1°, 1/2°, and 1/4°
|
| 24 |
+
resolution. See https://arxiv.org/abs/2412.03795 for the method.
|
| 25 |
+
|
| 26 |
+
Usage:
|
| 27 |
+
samudra <command> CONFIG [--KEY VALUE ...]
|
| 28 |
+
|
| 29 |
+
Commands:
|
| 30 |
+
train Train a model from a config (checkpointing, W&B logging, multi-GPU).
|
| 31 |
+
eval Roll a trained model out autoregressively and collect metrics.
|
| 32 |
+
viz Render maps, time series, and PDFs from evaluation outputs.
|
| 33 |
+
|
| 34 |
+
CONFIG is a path to a YAML file or the name of a bundled preset such as
|
| 35 |
+
`samudra_om4/train.yaml`. Any config key can be overridden inline (e.g.
|
| 36 |
+
`--epochs 100`); run `samudra <command> --help` for a command's full options.
|
| 37 |
+
|
| 38 |
+
Examples:
|
| 39 |
+
samudra train samudra_om4/train.yaml --experiment.data_root ./data
|
| 40 |
+
samudra eval samudra_om4/eval.yaml --ckpt_path ./checkpoint.pt
|
| 41 |
+
|
| 42 |
+
Docs: https://m2lines.github.io/Samudra/docs/
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def main() -> None:
|
| 47 |
+
"""Dispatch ``samudra <command> ...`` to the matching task entry point."""
|
| 48 |
+
argv = sys.argv[1:]
|
| 49 |
+
if not argv or argv[0] in ("-h", "--help"):
|
| 50 |
+
sys.stdout.write(_HELP)
|
| 51 |
+
raise SystemExit(0)
|
| 52 |
+
if argv[0] not in _COMMANDS:
|
| 53 |
+
sys.stderr.write(f"samudra: unknown command {argv[0]!r}\n\n{_HELP}")
|
| 54 |
+
raise SystemExit(2)
|
| 55 |
+
|
| 56 |
+
command, rest = argv[0], argv[1:]
|
| 57 |
+
# The config loaders parse sys.argv directly, so strip the subcommand token
|
| 58 |
+
# and leave them an argv of `CONFIG [OVERRIDES...]`.
|
| 59 |
+
sys.argv = [f"samudra {command}", *rest]
|
| 60 |
+
|
| 61 |
+
if command == "train":
|
| 62 |
+
from samudra.train import main as train_main
|
| 63 |
+
|
| 64 |
+
train_main()
|
| 65 |
+
elif command == "eval":
|
| 66 |
+
from samudra.eval import main as eval_main
|
| 67 |
+
|
| 68 |
+
eval_main()
|
| 69 |
+
else: # viz
|
| 70 |
+
from samudra.viz.config import VizConfig
|
| 71 |
+
from samudra.viz.config import main as viz_main
|
| 72 |
+
|
| 73 |
+
viz_main(VizConfig.from_yaml_and_cli())
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
if __name__ == "__main__":
|
| 77 |
+
main()
|
samudra/config.py
ADDED
|
@@ -0,0 +1,1339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
import abc
|
| 6 |
+
import datetime
|
| 7 |
+
from functools import cached_property
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Annotated, Literal, Self, assert_never
|
| 10 |
+
|
| 11 |
+
import cftime
|
| 12 |
+
import numpy as np
|
| 13 |
+
import pandas as pd
|
| 14 |
+
import pydantic
|
| 15 |
+
import torch
|
| 16 |
+
import xarray as xr
|
| 17 |
+
from perceiver_pytorch import Perceiver as NaivePerceiver
|
| 18 |
+
from pydantic import (
|
| 19 |
+
Field,
|
| 20 |
+
PlainSerializer,
|
| 21 |
+
PlainValidator,
|
| 22 |
+
WithJsonSchema,
|
| 23 |
+
model_validator,
|
| 24 |
+
)
|
| 25 |
+
from torch import nn
|
| 26 |
+
from torch.nn import GELU
|
| 27 |
+
|
| 28 |
+
from samudra.config_base import BaseConfig, TopLevelConfig
|
| 29 |
+
from samudra.constants import (
|
| 30 |
+
DatasetSpec,
|
| 31 |
+
GridType,
|
| 32 |
+
LoaderVersion,
|
| 33 |
+
build_llc_spec,
|
| 34 |
+
build_om4_spec,
|
| 35 |
+
)
|
| 36 |
+
from samudra.models import Samudra, SamudraMini, SamudraMulti
|
| 37 |
+
from samudra.models.base import BaseModel
|
| 38 |
+
from samudra.models.modules import (
|
| 39 |
+
AvgPool,
|
| 40 |
+
BilinearUpsample,
|
| 41 |
+
CappedGELU,
|
| 42 |
+
ConvBlock,
|
| 43 |
+
ConvNeXtBlock,
|
| 44 |
+
CoreBlock,
|
| 45 |
+
CoreBlockBuilder,
|
| 46 |
+
MaxPool,
|
| 47 |
+
PerceiverDecoder,
|
| 48 |
+
PerceiverEncoder,
|
| 49 |
+
ReLU,
|
| 50 |
+
TransposedConvUpsample,
|
| 51 |
+
UNetBackbone,
|
| 52 |
+
)
|
| 53 |
+
from samudra.models.modules.augment_input import (
|
| 54 |
+
Concat3dCoordinates,
|
| 55 |
+
FourierFeatures2D,
|
| 56 |
+
fourier_features_2d_dim,
|
| 57 |
+
)
|
| 58 |
+
from samudra.models.modules.blocks import ZonallyPeriodicBilinearUpsample
|
| 59 |
+
from samudra.models.modules.encoder import patch_from
|
| 60 |
+
from samudra.utils.data import DataContainer, DataSource, DataSourceSplits
|
| 61 |
+
from samudra.utils.llc import canonicalize_llc_datasets
|
| 62 |
+
from samudra.utils.location import (
|
| 63 |
+
LocalLocation,
|
| 64 |
+
Location,
|
| 65 |
+
ResolvedLocation,
|
| 66 |
+
UnresolvedLocation,
|
| 67 |
+
)
|
| 68 |
+
from samudra.utils.loss import (
|
| 69 |
+
DynamicLoss,
|
| 70 |
+
GradientLoss,
|
| 71 |
+
LossFnWithContext,
|
| 72 |
+
LossMetric,
|
| 73 |
+
loss_fn_from_metric,
|
| 74 |
+
)
|
| 75 |
+
from samudra.utils.profiler import Profiler
|
| 76 |
+
from samudra.utils.schedule import SchedulerConfig
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class WandBConfig(BaseConfig):
|
| 80 |
+
mode: Literal["online", "disabled"] = "disabled"
|
| 81 |
+
project: str = "default"
|
| 82 |
+
entity: str = "ocean_emulators"
|
| 83 |
+
group: str | None = None
|
| 84 |
+
tags: list[str] | None = None
|
| 85 |
+
notes: str | None = None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class JulianDate:
|
| 89 |
+
"""Represents a Julian date as a cftime.datetime at noon on the relevant day.
|
| 90 |
+
|
| 91 |
+
This is the format the OM4 data uses, so we match that here.
|
| 92 |
+
TODO(jder): probably worth asserting the date format when opening the data.
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
datetime: cftime.datetime
|
| 96 |
+
|
| 97 |
+
def __init__(self, value: str):
|
| 98 |
+
parsed = cftime.datetime.strptime(value, "%Y-%m-%d", calendar="julian")
|
| 99 |
+
self.datetime = parsed.replace(hour=12)
|
| 100 |
+
|
| 101 |
+
def __str__(self) -> str:
|
| 102 |
+
return self.datetime.strftime("%Y-%m-%d")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _julian_date_validator(value: str | JulianDate) -> JulianDate:
|
| 106 |
+
return JulianDate(value) if isinstance(value, str) else value
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
JulianDateConfig = Annotated[
|
| 110 |
+
JulianDate,
|
| 111 |
+
PlainValidator(_julian_date_validator),
|
| 112 |
+
PlainSerializer(JulianDate.__str__),
|
| 113 |
+
WithJsonSchema({"type": "string", "format": "date"}),
|
| 114 |
+
]
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# We reuse pydantic's AwareDatetime to match JSONSchema's expected RFC3339 format
|
| 118 |
+
_aware_datetime_adapter = pydantic.TypeAdapter(pydantic.AwareDatetime)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _datetime64_validator(value: str | np.datetime64) -> np.datetime64:
|
| 122 |
+
if isinstance(value, np.datetime64):
|
| 123 |
+
return value.astype("datetime64[ns]")
|
| 124 |
+
parsed = _aware_datetime_adapter.validate_python(value)
|
| 125 |
+
utc = parsed.astimezone(datetime.UTC).replace(tzinfo=None)
|
| 126 |
+
return np.datetime64(utc, "ns")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _serialize_datetime64(value: np.datetime64) -> str:
|
| 130 |
+
return str(np.datetime_as_string(value, unit="ns", timezone="UTC"))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
LlcDatetimeConfig = Annotated[
|
| 134 |
+
np.datetime64,
|
| 135 |
+
PlainValidator(_datetime64_validator),
|
| 136 |
+
PlainSerializer(_serialize_datetime64),
|
| 137 |
+
WithJsonSchema({"type": "string", "format": "date-time"}),
|
| 138 |
+
]
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class Om4TimeConfig(BaseConfig):
|
| 142 |
+
model_config = pydantic.ConfigDict(frozen=True)
|
| 143 |
+
|
| 144 |
+
start: JulianDateConfig
|
| 145 |
+
end: JulianDateConfig
|
| 146 |
+
|
| 147 |
+
@property
|
| 148 |
+
def time_slice(self) -> slice:
|
| 149 |
+
return slice(self.start.datetime, self.end.datetime)
|
| 150 |
+
|
| 151 |
+
def overlaps(self, other: Self) -> bool:
|
| 152 |
+
return (
|
| 153 |
+
self.start.datetime < other.end.datetime
|
| 154 |
+
and self.end.datetime > other.start.datetime
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
def __str__(self) -> str:
|
| 158 |
+
return f"{self.start} to {self.end}"
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
class LlcTimeConfig(BaseConfig):
|
| 162 |
+
model_config = pydantic.ConfigDict(frozen=True)
|
| 163 |
+
|
| 164 |
+
start: LlcDatetimeConfig
|
| 165 |
+
end: LlcDatetimeConfig
|
| 166 |
+
|
| 167 |
+
@property
|
| 168 |
+
def time_slice(self) -> slice:
|
| 169 |
+
return slice(self.start, self.end)
|
| 170 |
+
|
| 171 |
+
def overlaps(self, other: Self) -> bool:
|
| 172 |
+
return bool(self.start < other.end and self.end > other.start)
|
| 173 |
+
|
| 174 |
+
def __str__(self) -> str:
|
| 175 |
+
return f"{self.start} to {self.end}"
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
TimeConfig = Om4TimeConfig | LlcTimeConfig
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
LOCATION_DOCS = (
|
| 182 |
+
"Use a string relative to the `data_root` or use a structured location "
|
| 183 |
+
"see location.py for possible types."
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class BaseDataSourceConfig[SourceTimeConfigT: TimeConfig](BaseConfig, abc.ABC):
|
| 188 |
+
train_time: SourceTimeConfigT = Field(frozen=True)
|
| 189 |
+
val_time: SourceTimeConfigT = Field(frozen=True)
|
| 190 |
+
inference_times: tuple[SourceTimeConfigT, ...] = Field(default=(), frozen=True)
|
| 191 |
+
data_location: Location = Field(
|
| 192 |
+
description="Location of the data; " + LOCATION_DOCS
|
| 193 |
+
)
|
| 194 |
+
data_means_location: Location = Field(
|
| 195 |
+
description="Location of the data means; " + LOCATION_DOCS
|
| 196 |
+
)
|
| 197 |
+
data_stds_location: Location = Field(
|
| 198 |
+
description="Location of the data standard deviations; " + LOCATION_DOCS
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
@property
|
| 202 |
+
@abc.abstractmethod
|
| 203 |
+
def dataset_spec(self) -> DatasetSpec:
|
| 204 |
+
raise NotImplementedError
|
| 205 |
+
|
| 206 |
+
@abc.abstractmethod
|
| 207 |
+
def canonicalize_datasets(
|
| 208 |
+
self,
|
| 209 |
+
data: xr.Dataset,
|
| 210 |
+
means: xr.Dataset,
|
| 211 |
+
stds: xr.Dataset,
|
| 212 |
+
) -> tuple[xr.Dataset, xr.Dataset, xr.Dataset]:
|
| 213 |
+
raise NotImplementedError
|
| 214 |
+
|
| 215 |
+
def build(
|
| 216 |
+
self,
|
| 217 |
+
data_root: ResolvedLocation,
|
| 218 |
+
*,
|
| 219 |
+
use_dask: bool,
|
| 220 |
+
is_primary: bool,
|
| 221 |
+
) -> DataSourceSplits:
|
| 222 |
+
source = self._build_source(
|
| 223 |
+
data_root,
|
| 224 |
+
turn_on_dask=use_dask,
|
| 225 |
+
)
|
| 226 |
+
inference_source = None
|
| 227 |
+
if is_primary and self.inference_times:
|
| 228 |
+
if use_dask:
|
| 229 |
+
full_inference_source = source
|
| 230 |
+
else:
|
| 231 |
+
full_inference_source = self._build_source(
|
| 232 |
+
data_root,
|
| 233 |
+
turn_on_dask=True,
|
| 234 |
+
)
|
| 235 |
+
# TODO: remove multiple inference time ranges altogether (see #813)
|
| 236 |
+
assert len(self.inference_times) == 1, (
|
| 237 |
+
"multiple inference time ranges have been deprecated"
|
| 238 |
+
)
|
| 239 |
+
inference_source = full_inference_source.slice(self.inference_times[0])
|
| 240 |
+
|
| 241 |
+
return DataSourceSplits(
|
| 242 |
+
train=source.slice(self.train_time),
|
| 243 |
+
val=source.slice(self.val_time),
|
| 244 |
+
inference=inference_source,
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
def _build_source(
|
| 248 |
+
self,
|
| 249 |
+
data_root: ResolvedLocation,
|
| 250 |
+
*,
|
| 251 |
+
turn_on_dask: bool,
|
| 252 |
+
) -> DataSource:
|
| 253 |
+
resolved_data_location = data_root.resolve(self.data_location)
|
| 254 |
+
resolved_means_location = data_root.resolve(self.data_means_location)
|
| 255 |
+
resolved_stds_location = data_root.resolve(self.data_stds_location)
|
| 256 |
+
|
| 257 |
+
chunks: dict[str, int] | None = {} if turn_on_dask else None
|
| 258 |
+
data = resolved_data_location.open(chunks)
|
| 259 |
+
means = resolved_means_location.open(chunks)
|
| 260 |
+
stds = resolved_stds_location.open(chunks)
|
| 261 |
+
data, means, stds = self.canonicalize_datasets(
|
| 262 |
+
data,
|
| 263 |
+
means,
|
| 264 |
+
stds,
|
| 265 |
+
)
|
| 266 |
+
dataset_spec = self.dataset_spec
|
| 267 |
+
|
| 268 |
+
source = DataSource.from_datasets(
|
| 269 |
+
data,
|
| 270 |
+
means,
|
| 271 |
+
stds,
|
| 272 |
+
dataset_spec=dataset_spec,
|
| 273 |
+
prognostic_var_names=dataset_spec.prognostic_var_names,
|
| 274 |
+
boundary_var_names=dataset_spec.boundary_var_names,
|
| 275 |
+
name=f"{resolved_data_location}-{turn_on_dask}",
|
| 276 |
+
)
|
| 277 |
+
return source
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
class BaseDataLoadingConfig(BaseConfig):
|
| 281 |
+
def num_pytorch_workers(self) -> int:
|
| 282 |
+
raise NotImplementedError
|
| 283 |
+
|
| 284 |
+
def persistent_pytorch_workers(self) -> bool:
|
| 285 |
+
raise NotImplementedError
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
class CpuDataLoadingConfig(BaseDataLoadingConfig):
|
| 289 |
+
type: Literal["cpu"] = "cpu"
|
| 290 |
+
num_workers: int = Field(default=4, ge=0)
|
| 291 |
+
persistent_workers: bool = True
|
| 292 |
+
|
| 293 |
+
def num_pytorch_workers(self) -> int:
|
| 294 |
+
return self.num_workers
|
| 295 |
+
|
| 296 |
+
def persistent_pytorch_workers(self) -> bool:
|
| 297 |
+
return self.persistent_workers
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
class GpuDataLoadingConfig(BaseDataLoadingConfig):
|
| 301 |
+
type: Literal["gpu"] = "gpu"
|
| 302 |
+
kvikio_task_size: int = Field(default=64 * 1024 * 1024, gt=0)
|
| 303 |
+
kvikio_num_threads: int = Field(default=8, gt=0)
|
| 304 |
+
|
| 305 |
+
def num_pytorch_workers(self) -> int:
|
| 306 |
+
# When loading data direct to GPU, we don't want worker processes.
|
| 307 |
+
# 0 means "load in the main process"
|
| 308 |
+
return 0
|
| 309 |
+
|
| 310 |
+
def persistent_pytorch_workers(self) -> bool:
|
| 311 |
+
return False
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
DataLoadingConfig = Annotated[
|
| 315 |
+
CpuDataLoadingConfig | GpuDataLoadingConfig,
|
| 316 |
+
Field(discriminator="type"),
|
| 317 |
+
]
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
class Om4DataSourceConfig(BaseDataSourceConfig[Om4TimeConfig]):
|
| 321 |
+
type: Literal["om4"] = "om4"
|
| 322 |
+
prognostic_vars_key: str = "thermo_dynamic_all"
|
| 323 |
+
boundary_vars_key: str = "tau_hfds"
|
| 324 |
+
grid_type: GridType = "gaussian"
|
| 325 |
+
|
| 326 |
+
@property
|
| 327 |
+
def dataset_spec(self) -> DatasetSpec:
|
| 328 |
+
return build_om4_spec(
|
| 329 |
+
self.prognostic_vars_key,
|
| 330 |
+
self.boundary_vars_key,
|
| 331 |
+
grid_type=self.grid_type,
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
@pydantic.model_validator(mode="after")
|
| 335 |
+
def validate_time_splits(self) -> Self:
|
| 336 |
+
if self.train_time.overlaps(self.val_time):
|
| 337 |
+
raise ValueError(
|
| 338 |
+
f"Training time range {self.train_time} overlaps "
|
| 339 |
+
f"with validation time range {self.val_time}"
|
| 340 |
+
)
|
| 341 |
+
return self
|
| 342 |
+
|
| 343 |
+
def canonicalize_datasets(
|
| 344 |
+
self,
|
| 345 |
+
data: xr.Dataset,
|
| 346 |
+
means: xr.Dataset,
|
| 347 |
+
stds: xr.Dataset,
|
| 348 |
+
) -> tuple[xr.Dataset, xr.Dataset, xr.Dataset]:
|
| 349 |
+
# TODO: move OM4 canonicalization in here instead of in validation afte.
|
| 350 |
+
return data, means, stds
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
class LlcDataSourceConfig(BaseDataSourceConfig[LlcTimeConfig]):
|
| 354 |
+
type: Literal["llc"] = "llc"
|
| 355 |
+
prognostic_vars_key: str = "single_1"
|
| 356 |
+
boundary_vars_key: str = "single_1"
|
| 357 |
+
face: int = Field(default=1, ge=0)
|
| 358 |
+
i_start: int = Field(default=0, ge=0)
|
| 359 |
+
i_end: int = Field(default=720, gt=0)
|
| 360 |
+
j_start: int = Field(default=0, ge=0)
|
| 361 |
+
j_end: int = Field(default=720, gt=0)
|
| 362 |
+
|
| 363 |
+
@property
|
| 364 |
+
def dataset_spec(self) -> DatasetSpec:
|
| 365 |
+
return build_llc_spec(
|
| 366 |
+
self.prognostic_vars_key,
|
| 367 |
+
self.boundary_vars_key,
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
@pydantic.model_validator(mode="after")
|
| 371 |
+
def validate_time_splits(self) -> Self:
|
| 372 |
+
if self.train_time.overlaps(self.val_time):
|
| 373 |
+
raise ValueError(
|
| 374 |
+
f"Training time range {self.train_time} overlaps "
|
| 375 |
+
f"with validation time range {self.val_time}"
|
| 376 |
+
)
|
| 377 |
+
return self
|
| 378 |
+
|
| 379 |
+
@pydantic.model_validator(mode="after")
|
| 380 |
+
def validate_crop_bounds(self) -> Self:
|
| 381 |
+
if self.i_end <= self.i_start:
|
| 382 |
+
raise ValueError("LLC crop bounds must satisfy i_start < i_end")
|
| 383 |
+
if self.j_end <= self.j_start:
|
| 384 |
+
raise ValueError("LLC crop bounds must satisfy j_start < j_end")
|
| 385 |
+
return self
|
| 386 |
+
|
| 387 |
+
def canonicalize_datasets(
|
| 388 |
+
self,
|
| 389 |
+
data: xr.Dataset,
|
| 390 |
+
means: xr.Dataset,
|
| 391 |
+
stds: xr.Dataset,
|
| 392 |
+
) -> tuple[xr.Dataset, xr.Dataset, xr.Dataset]:
|
| 393 |
+
return canonicalize_llc_datasets(
|
| 394 |
+
data,
|
| 395 |
+
means,
|
| 396 |
+
stds,
|
| 397 |
+
face=self.face,
|
| 398 |
+
i_start=self.i_start,
|
| 399 |
+
i_end=self.i_end,
|
| 400 |
+
j_start=self.j_start,
|
| 401 |
+
j_end=self.j_end,
|
| 402 |
+
dataset_spec=self.dataset_spec,
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
DataSourceConfig = Annotated[
|
| 407 |
+
Om4DataSourceConfig | LlcDataSourceConfig,
|
| 408 |
+
Field(discriminator="type"),
|
| 409 |
+
]
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
class DataConfig(BaseConfig):
|
| 413 |
+
sources: list[DataSourceConfig] = Field(
|
| 414 |
+
description=(
|
| 415 |
+
"Data sources to include, each with explicit data/means/stds "
|
| 416 |
+
"locations. These are resolved relative to data_root."
|
| 417 |
+
),
|
| 418 |
+
min_length=1,
|
| 419 |
+
)
|
| 420 |
+
loading: DataLoadingConfig = Field(default_factory=CpuDataLoadingConfig)
|
| 421 |
+
hist: int = 1
|
| 422 |
+
loader_version: str = str(LoaderVersion.OM4_TORCH.value)
|
| 423 |
+
normalize_before_mask: bool = True
|
| 424 |
+
masked_fill_value: float = 0.0
|
| 425 |
+
concurrent_compute: bool = False
|
| 426 |
+
|
| 427 |
+
def build(
|
| 428 |
+
self,
|
| 429 |
+
data_root: ResolvedLocation,
|
| 430 |
+
) -> DataContainer:
|
| 431 |
+
loader_version = LoaderVersion(self.loader_version)
|
| 432 |
+
use_dask = loader_version != LoaderVersion.OM4_TORCH
|
| 433 |
+
dataset_spec = self.sources[0].dataset_spec
|
| 434 |
+
assert all(source.dataset_spec == dataset_spec for source in self.sources), (
|
| 435 |
+
"All data sources must use the same dataset spec"
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
source_splits = [
|
| 439 |
+
source_cfg.build(
|
| 440 |
+
data_root,
|
| 441 |
+
use_dask=use_dask,
|
| 442 |
+
is_primary=index == 0,
|
| 443 |
+
)
|
| 444 |
+
for index, source_cfg in enumerate(self.sources)
|
| 445 |
+
]
|
| 446 |
+
train_sources = [splits.train for splits in source_splits]
|
| 447 |
+
val_sources = [splits.val for splits in source_splits]
|
| 448 |
+
|
| 449 |
+
return DataContainer(
|
| 450 |
+
train_sources=train_sources,
|
| 451 |
+
val_sources=val_sources,
|
| 452 |
+
inference_source=source_splits[0].inference,
|
| 453 |
+
loader_version=loader_version,
|
| 454 |
+
dataset_spec=dataset_spec,
|
| 455 |
+
)
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
BlockType = Literal["conv_next_block", "conv_block"]
|
| 459 |
+
ActivationType = Literal["relu", "gelu", "capped_gelu"]
|
| 460 |
+
NormType = Literal["batch", "instance", "layer"]
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
class BlockConfig(BaseConfig):
|
| 464 |
+
block_type: BlockType = "conv_next_block"
|
| 465 |
+
kernel_size: int = 3
|
| 466 |
+
activation: ActivationType = "capped_gelu"
|
| 467 |
+
upscale_factor: int = 4
|
| 468 |
+
norm: NormType = "batch"
|
| 469 |
+
pointwise_linear: bool = False
|
| 470 |
+
|
| 471 |
+
def build(self) -> CoreBlockBuilder:
|
| 472 |
+
match self.activation:
|
| 473 |
+
case "relu":
|
| 474 |
+
activation: type[nn.Module] = ReLU
|
| 475 |
+
case "capped_gelu":
|
| 476 |
+
activation = CappedGELU
|
| 477 |
+
case "gelu":
|
| 478 |
+
activation = GELU
|
| 479 |
+
case _:
|
| 480 |
+
assert_never(self.activation)
|
| 481 |
+
|
| 482 |
+
def create_block(
|
| 483 |
+
in_channels: int,
|
| 484 |
+
out_channels: int,
|
| 485 |
+
dilation: int,
|
| 486 |
+
n_layers: int,
|
| 487 |
+
pad: str,
|
| 488 |
+
checkpoint_simple: bool,
|
| 489 |
+
) -> CoreBlock:
|
| 490 |
+
match self.block_type:
|
| 491 |
+
case "conv_block":
|
| 492 |
+
return ConvBlock(
|
| 493 |
+
in_channels=in_channels,
|
| 494 |
+
out_channels=out_channels,
|
| 495 |
+
dilation=dilation,
|
| 496 |
+
n_layers=n_layers,
|
| 497 |
+
pad=pad,
|
| 498 |
+
checkpoint_simple=checkpoint_simple,
|
| 499 |
+
kernel_size=self.kernel_size,
|
| 500 |
+
activation=activation,
|
| 501 |
+
)
|
| 502 |
+
case "conv_next_block":
|
| 503 |
+
return ConvNeXtBlock(
|
| 504 |
+
in_channels=in_channels,
|
| 505 |
+
out_channels=out_channels,
|
| 506 |
+
dilation=dilation,
|
| 507 |
+
n_layers=n_layers,
|
| 508 |
+
pad=pad,
|
| 509 |
+
checkpoint_simple=checkpoint_simple,
|
| 510 |
+
kernel_size=self.kernel_size,
|
| 511 |
+
upscale_factor=self.upscale_factor,
|
| 512 |
+
norm=self.norm,
|
| 513 |
+
activation=activation,
|
| 514 |
+
pointwise_linear=self.pointwise_linear,
|
| 515 |
+
)
|
| 516 |
+
case _:
|
| 517 |
+
assert_never(self.block_type)
|
| 518 |
+
|
| 519 |
+
return create_block
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
PerceiverImpl = Literal["auto", "naive", "flash"]
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
class PerceiverConfig(BaseConfig):
|
| 526 |
+
"""A standard config interface to various perceiver implementations.
|
| 527 |
+
|
| 528 |
+
Builds either a regular Perceiver (for the encoder, via ``build``) or a
|
| 529 |
+
PerceiverIO (for the decoder, via ``build_io``). Both respect the shared
|
| 530 |
+
``implementation`` setting from ``SamudraMultiConfig.perceiver_implementation``.
|
| 531 |
+
"""
|
| 532 |
+
|
| 533 |
+
depth: int = 6
|
| 534 |
+
latent_dim: int = Field(
|
| 535 |
+
default=128,
|
| 536 |
+
description="The small, latent dimension of the Perceiver. This is the `N` dimension for the Perceiver's `O(M*N)` complexity",
|
| 537 |
+
)
|
| 538 |
+
num_latents: int = Field(
|
| 539 |
+
default=512,
|
| 540 |
+
description="The number of latent vectors in the Perceiver. This is the `M` dimension for the Perceiver's `O(M*N)` complexity",
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
def build(
|
| 544 |
+
self,
|
| 545 |
+
in_channels: int,
|
| 546 |
+
out_channels: int,
|
| 547 |
+
max_patch_size: tuple[int, int],
|
| 548 |
+
implementation: PerceiverImpl,
|
| 549 |
+
) -> nn.Module:
|
| 550 |
+
"""Build a regular Perceiver (used by the encoder)."""
|
| 551 |
+
# This is not really a "frequency" but a maximum of the width appears to be reasonable from looking at the code.
|
| 552 |
+
max_freq = max(*max_patch_size)
|
| 553 |
+
|
| 554 |
+
num_freq_bands = 4
|
| 555 |
+
fourier_dim = fourier_features_2d_dim(num_freq_bands)
|
| 556 |
+
# Use the same explicit 2D Fourier features in both implementations so
|
| 557 |
+
# intra-patch positions are encoded equivalently.
|
| 558 |
+
if _use_flash(implementation):
|
| 559 |
+
try:
|
| 560 |
+
from flash_perceiver import Perceiver as FlashPerceiver # type: ignore
|
| 561 |
+
except ImportError as e:
|
| 562 |
+
raise _flash_import_error() from e
|
| 563 |
+
from einops.layers.torch import Rearrange
|
| 564 |
+
|
| 565 |
+
perceiver: nn.Module = nn.Sequential(
|
| 566 |
+
FourierFeatures2D(num_freq_bands=num_freq_bands, max_freq=max_freq),
|
| 567 |
+
Rearrange("b ph pw v -> b (ph pw) v"),
|
| 568 |
+
FlashPerceiver(
|
| 569 |
+
depth=self.depth,
|
| 570 |
+
input_dim=in_channels + fourier_dim,
|
| 571 |
+
output_dim=out_channels,
|
| 572 |
+
output_mode="average",
|
| 573 |
+
latent_dim=self.latent_dim,
|
| 574 |
+
num_latents=self.num_latents,
|
| 575 |
+
use_flash_attn=True,
|
| 576 |
+
weight_tie_layers=True,
|
| 577 |
+
self_per_cross_attn=2,
|
| 578 |
+
),
|
| 579 |
+
)
|
| 580 |
+
elif _use_naive(implementation):
|
| 581 |
+
perceiver = nn.Sequential(
|
| 582 |
+
FourierFeatures2D(num_freq_bands=num_freq_bands, max_freq=max_freq),
|
| 583 |
+
NaivePerceiver(
|
| 584 |
+
# Required by perceiver-pytorch even when its internal
|
| 585 |
+
# Fourier encoding is disabled below.
|
| 586 |
+
num_freq_bands=num_freq_bands,
|
| 587 |
+
max_freq=max_freq,
|
| 588 |
+
depth=self.depth,
|
| 589 |
+
input_axis=2,
|
| 590 |
+
input_channels=in_channels + fourier_dim,
|
| 591 |
+
num_classes=out_channels,
|
| 592 |
+
latent_dim=self.latent_dim,
|
| 593 |
+
num_latents=self.num_latents,
|
| 594 |
+
weight_tie_layers=True,
|
| 595 |
+
fourier_encode_data=False,
|
| 596 |
+
self_per_cross_attn=2,
|
| 597 |
+
),
|
| 598 |
+
)
|
| 599 |
+
else:
|
| 600 |
+
raise ValueError(f"Unknown perceiver implementation: {implementation}.")
|
| 601 |
+
|
| 602 |
+
return perceiver
|
| 603 |
+
|
| 604 |
+
def build_io(
|
| 605 |
+
self,
|
| 606 |
+
in_channels: int,
|
| 607 |
+
queries_dim: int,
|
| 608 |
+
out_channels: int,
|
| 609 |
+
implementation: PerceiverImpl,
|
| 610 |
+
) -> nn.Module:
|
| 611 |
+
"""Build a PerceiverIO (used by the decoder)."""
|
| 612 |
+
if _use_flash(implementation):
|
| 613 |
+
try:
|
| 614 |
+
from flash_perceiver.perceiver import ( # type: ignore
|
| 615 |
+
PerceiverIO as FlashPerceiverIO, # type: ignore
|
| 616 |
+
)
|
| 617 |
+
except ImportError as e:
|
| 618 |
+
raise _flash_import_error() from e
|
| 619 |
+
perceiver_io: nn.Module = FlashPerceiverIO(
|
| 620 |
+
depth=self.depth,
|
| 621 |
+
input_dim=in_channels,
|
| 622 |
+
query_dim=queries_dim,
|
| 623 |
+
proj_dim=out_channels,
|
| 624 |
+
num_latents=self.num_latents,
|
| 625 |
+
latent_dim=self.latent_dim,
|
| 626 |
+
use_flash_attn=True,
|
| 627 |
+
weight_tie_layers=True,
|
| 628 |
+
)
|
| 629 |
+
elif _use_naive(implementation):
|
| 630 |
+
from perceiver_pytorch.perceiver_io import PerceiverIO as NaivePerceiverIO
|
| 631 |
+
|
| 632 |
+
perceiver_io = NaivePerceiverIO(
|
| 633 |
+
depth=self.depth,
|
| 634 |
+
dim=in_channels,
|
| 635 |
+
queries_dim=queries_dim,
|
| 636 |
+
logits_dim=out_channels,
|
| 637 |
+
num_latents=self.num_latents,
|
| 638 |
+
latent_dim=self.latent_dim,
|
| 639 |
+
weight_tie_layers=True,
|
| 640 |
+
decoder_ff=True,
|
| 641 |
+
)
|
| 642 |
+
else:
|
| 643 |
+
raise ValueError(f"Unknown perceiver implementation: {implementation}.")
|
| 644 |
+
|
| 645 |
+
return perceiver_io
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
def _use_flash(implementation: PerceiverImpl) -> bool:
|
| 649 |
+
return (
|
| 650 |
+
implementation == "auto" and torch.cuda.is_available()
|
| 651 |
+
) or implementation == "flash"
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
def _use_naive(implementation: PerceiverImpl) -> bool:
|
| 655 |
+
return (
|
| 656 |
+
implementation == "auto" and not torch.cuda.is_available()
|
| 657 |
+
) or implementation == "naive"
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
def _flash_import_error() -> ValueError:
|
| 661 |
+
return ValueError(
|
| 662 |
+
"`implementation==flash` or flash was automatically chosen for `implementation==auto`, "
|
| 663 |
+
"but the flash attention dependencies could not be imported. "
|
| 664 |
+
"Please run `uv sync --extra cuda` or specify the `naive` attention implementation."
|
| 665 |
+
)
|
| 666 |
+
|
| 667 |
+
|
| 668 |
+
class EncoderConfig(BaseConfig):
|
| 669 |
+
perceiver: PerceiverConfig = PerceiverConfig()
|
| 670 |
+
|
| 671 |
+
def build(
|
| 672 |
+
self,
|
| 673 |
+
in_channels: int,
|
| 674 |
+
out_channels: int,
|
| 675 |
+
patch_extent: tuple[float, float],
|
| 676 |
+
max_lat_size: int,
|
| 677 |
+
max_lon_size: int,
|
| 678 |
+
implementation: PerceiverImpl,
|
| 679 |
+
) -> PerceiverEncoder:
|
| 680 |
+
max_patch_size = patch_from(patch_extent, max_lat_size, max_lon_size)
|
| 681 |
+
return PerceiverEncoder(
|
| 682 |
+
in_channels=in_channels,
|
| 683 |
+
out_channels=out_channels,
|
| 684 |
+
patch_extent=patch_extent,
|
| 685 |
+
perceiver=self.perceiver.build(
|
| 686 |
+
in_channels, out_channels, max_patch_size, implementation
|
| 687 |
+
),
|
| 688 |
+
)
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
class DecoderConfig(BaseConfig):
|
| 692 |
+
"""A PerceiverIO-based decoder configuration.
|
| 693 |
+
|
| 694 |
+
Uses PerceiverIO (with an explicit query mechanism) rather than a regular
|
| 695 |
+
Perceiver. Output pixel positions are encoded as queries, so the output
|
| 696 |
+
size is determined by the query count — not by ``num_latents``.
|
| 697 |
+
|
| 698 |
+
When ``window_patches`` is set, the decoder tiles the output grid into
|
| 699 |
+
spatial blocks of that many patches per side. Each block's PerceiverIO
|
| 700 |
+
call receives only the overlapping latent tokens plus ``context_patches``
|
| 701 |
+
extra rings of neighbors, keeping cost bounded even when the latent grid
|
| 702 |
+
is large (i.e. fine ``patch_extent``).
|
| 703 |
+
"""
|
| 704 |
+
|
| 705 |
+
perceiver: PerceiverConfig = PerceiverConfig()
|
| 706 |
+
queries_dim: int = Field(
|
| 707 |
+
default=64,
|
| 708 |
+
description="Embedding dimension for pixel-position queries in the PerceiverIO decoder head.",
|
| 709 |
+
)
|
| 710 |
+
window_patches: int | None = Field(
|
| 711 |
+
default=4096,
|
| 712 |
+
description="Side length (in patches) of each spatial decode window. "
|
| 713 |
+
"None = decode all patches at once (global attention). "
|
| 714 |
+
"E.g. window_patches=8 means each PerceiverIO call covers an 8x8 block of patches.",
|
| 715 |
+
)
|
| 716 |
+
context_patches: int | None = Field(
|
| 717 |
+
default=1,
|
| 718 |
+
description="Number of extra patch rings around each window to include as data context. "
|
| 719 |
+
"Only used when window_patches is set. None = full context (every window sees all latent tokens).",
|
| 720 |
+
)
|
| 721 |
+
|
| 722 |
+
def build(
|
| 723 |
+
self,
|
| 724 |
+
in_channels: int,
|
| 725 |
+
out_channels: int,
|
| 726 |
+
patch_extent: tuple[float, float],
|
| 727 |
+
implementation: PerceiverImpl,
|
| 728 |
+
) -> PerceiverDecoder:
|
| 729 |
+
return PerceiverDecoder(
|
| 730 |
+
in_channels=in_channels,
|
| 731 |
+
out_channels=out_channels,
|
| 732 |
+
patch_extent=patch_extent,
|
| 733 |
+
queries_dim=self.queries_dim,
|
| 734 |
+
perceiver_io=self.perceiver.build_io(
|
| 735 |
+
in_channels, self.queries_dim, out_channels, implementation
|
| 736 |
+
),
|
| 737 |
+
window_patches=self.window_patches,
|
| 738 |
+
context_patches=self.context_patches,
|
| 739 |
+
)
|
| 740 |
+
|
| 741 |
+
|
| 742 |
+
DownSamplingBlocks = Literal["avg_pool", "max_pool"]
|
| 743 |
+
UpSamplingBlocks = Literal[
|
| 744 |
+
"bilinear_upsample", "transposed_conv", "zonally_periodic_upsample"
|
| 745 |
+
]
|
| 746 |
+
Checkpointing = Literal["all", "simple"]
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
class UNetBackboneConfig(BaseConfig):
|
| 750 |
+
ch_width: list[int] = [200, 250, 300, 400]
|
| 751 |
+
dilation: list[int] = [1, 2, 4, 8]
|
| 752 |
+
n_layers: list[int] = [1, 1, 1, 1]
|
| 753 |
+
core_block: BlockConfig = BlockConfig()
|
| 754 |
+
down_sampling_block: DownSamplingBlocks = "avg_pool"
|
| 755 |
+
up_sampling_block: UpSamplingBlocks = "zonally_periodic_upsample"
|
| 756 |
+
drop_path_rate: float = Field(
|
| 757 |
+
default=0.0,
|
| 758 |
+
description="Shortcut dropout rate. The chance we turn off skip connections in the UNet. Reasonable values are 0.1-0.3. Use 0.0 to disable.",
|
| 759 |
+
)
|
| 760 |
+
|
| 761 |
+
def build(
|
| 762 |
+
self,
|
| 763 |
+
in_channels: int,
|
| 764 |
+
pad: str,
|
| 765 |
+
checkpointing: Checkpointing | None,
|
| 766 |
+
) -> UNetBackbone:
|
| 767 |
+
assert len(self.ch_width) == len(self.dilation) == len(self.n_layers), (
|
| 768 |
+
"`ch_width`, `dilation`, and `n_layers` must have the same length."
|
| 769 |
+
)
|
| 770 |
+
|
| 771 |
+
def create_upsampling_block(in_channels: int, out_channels: int):
|
| 772 |
+
match self.up_sampling_block:
|
| 773 |
+
case "bilinear_upsample":
|
| 774 |
+
return BilinearUpsample(
|
| 775 |
+
in_channels=in_channels, out_channels=out_channels
|
| 776 |
+
)
|
| 777 |
+
case "transposed_conv":
|
| 778 |
+
return TransposedConvUpsample(
|
| 779 |
+
in_channels=in_channels, out_channels=out_channels
|
| 780 |
+
)
|
| 781 |
+
case "zonally_periodic_upsample":
|
| 782 |
+
return ZonallyPeriodicBilinearUpsample()
|
| 783 |
+
case _:
|
| 784 |
+
assert_never(self.up_sampling_block)
|
| 785 |
+
|
| 786 |
+
match self.down_sampling_block:
|
| 787 |
+
case "avg_pool":
|
| 788 |
+
downsampling_block: nn.Module = AvgPool()
|
| 789 |
+
case "max_pool":
|
| 790 |
+
downsampling_block = MaxPool()
|
| 791 |
+
case _:
|
| 792 |
+
assert_never(self.down_sampling_block)
|
| 793 |
+
|
| 794 |
+
return UNetBackbone(
|
| 795 |
+
in_channels=in_channels,
|
| 796 |
+
ch_width=self.ch_width,
|
| 797 |
+
dilation=self.dilation,
|
| 798 |
+
n_layers=self.n_layers,
|
| 799 |
+
pad=pad,
|
| 800 |
+
create_block=self.core_block.build(),
|
| 801 |
+
downsampling_block=downsampling_block,
|
| 802 |
+
create_upsampling_block=create_upsampling_block,
|
| 803 |
+
checkpointing=checkpointing,
|
| 804 |
+
drop_path_rate=self.drop_path_rate,
|
| 805 |
+
)
|
| 806 |
+
|
| 807 |
+
|
| 808 |
+
class BaseModelConfig(BaseConfig, abc.ABC):
|
| 809 |
+
pred_residuals: bool = False
|
| 810 |
+
last_kernel_size: int = 3
|
| 811 |
+
pad: str = "circular"
|
| 812 |
+
|
| 813 |
+
checkpointing: Checkpointing | None = Field(
|
| 814 |
+
default=None,
|
| 815 |
+
description="""Strategy for storing activations for the model for use in
|
| 816 |
+
the backward pass. If not set, the model will store all activations in memory
|
| 817 |
+
(fast but lots of memory). If set to 'all', the model will recompute each
|
| 818 |
+
top-level layer (CoreBlocks, scaling layers, etc.) in the backward pass.
|
| 819 |
+
If set to 'simple', the model will recompute only cheap layers like scales
|
| 820 |
+
and nonlinearities.""",
|
| 821 |
+
)
|
| 822 |
+
|
| 823 |
+
gradient_detach_interval: int = Field(
|
| 824 |
+
default=0,
|
| 825 |
+
description="""Interval for detaching gradients in autoregressive training. `0` means no detaching.""",
|
| 826 |
+
)
|
| 827 |
+
|
| 828 |
+
add_3d_coordinates: bool = Field(
|
| 829 |
+
default=False,
|
| 830 |
+
description="Add 3d coordinates representing position on the Earth (cartesian coordinates on a unit sphere) to the input channels.",
|
| 831 |
+
)
|
| 832 |
+
|
| 833 |
+
@abc.abstractmethod
|
| 834 |
+
def build(
|
| 835 |
+
self,
|
| 836 |
+
prog_channels: int,
|
| 837 |
+
boundary_channels: int,
|
| 838 |
+
out_channels: int,
|
| 839 |
+
hist: int,
|
| 840 |
+
srcs: list[DataSource],
|
| 841 |
+
) -> BaseModel:
|
| 842 |
+
pass
|
| 843 |
+
|
| 844 |
+
|
| 845 |
+
class SamudraConfig(BaseModelConfig):
|
| 846 |
+
unet: UNetBackboneConfig = UNetBackboneConfig()
|
| 847 |
+
pos_channels: int = Field(
|
| 848 |
+
default=0,
|
| 849 |
+
description="""Number of channels used for a learned positional embedding""",
|
| 850 |
+
)
|
| 851 |
+
use_bfloat16: bool = Field(
|
| 852 |
+
default=False,
|
| 853 |
+
description="Use bfloat16 for most layers rather than float32.",
|
| 854 |
+
)
|
| 855 |
+
|
| 856 |
+
def build(
|
| 857 |
+
self,
|
| 858 |
+
prog_channels: int,
|
| 859 |
+
boundary_channels: int,
|
| 860 |
+
out_channels: int,
|
| 861 |
+
hist: int,
|
| 862 |
+
srcs: list[DataSource],
|
| 863 |
+
) -> Samudra:
|
| 864 |
+
if len(srcs) != 1:
|
| 865 |
+
raise ValueError(
|
| 866 |
+
"Samudra only supports training at a single scale! Please configure exactly one data source."
|
| 867 |
+
)
|
| 868 |
+
src = srcs[0]
|
| 869 |
+
in_channels = prog_channels + boundary_channels
|
| 870 |
+
total_in_channels = (
|
| 871 |
+
in_channels + self.pos_channels + (3 if self.add_3d_coordinates else 0)
|
| 872 |
+
)
|
| 873 |
+
add_3d_coordinates = Concat3dCoordinates() if self.add_3d_coordinates else None
|
| 874 |
+
return Samudra(
|
| 875 |
+
in_channels=total_in_channels,
|
| 876 |
+
out_channels=out_channels,
|
| 877 |
+
pred_residuals=self.pred_residuals,
|
| 878 |
+
last_kernel_size=self.last_kernel_size,
|
| 879 |
+
pad=self.pad,
|
| 880 |
+
unet=self.unet.build(
|
| 881 |
+
in_channels=total_in_channels,
|
| 882 |
+
pad=self.pad,
|
| 883 |
+
checkpointing=self.checkpointing,
|
| 884 |
+
),
|
| 885 |
+
pos_channels=self.pos_channels,
|
| 886 |
+
add_3d_coordinates=add_3d_coordinates,
|
| 887 |
+
hist=hist,
|
| 888 |
+
grid_size=src.grid_size,
|
| 889 |
+
gradient_detach_interval=self.gradient_detach_interval,
|
| 890 |
+
use_bfloat16=self.use_bfloat16,
|
| 891 |
+
)
|
| 892 |
+
|
| 893 |
+
|
| 894 |
+
class SamudraMultiConfig(BaseModelConfig):
|
| 895 |
+
encoder: EncoderConfig = EncoderConfig()
|
| 896 |
+
processor: UNetBackboneConfig = UNetBackboneConfig()
|
| 897 |
+
decoder: DecoderConfig = DecoderConfig()
|
| 898 |
+
perceiver_implementation: PerceiverImpl = Field(
|
| 899 |
+
default="auto",
|
| 900 |
+
description="Perceiver attention implementation shared by the encoder and decoder. "
|
| 901 |
+
"'auto' selects flash attention when CUDA is available, otherwise naive.",
|
| 902 |
+
)
|
| 903 |
+
patch_extent: list[float] = Field(
|
| 904 |
+
default=[6.0, 10.0],
|
| 905 |
+
description="Target physical extent of each patch in degrees [height_deg, width_deg]. "
|
| 906 |
+
"Shared by the encoder and decoder for consistent spatial semantics.",
|
| 907 |
+
)
|
| 908 |
+
embedding_dim: int = 128
|
| 909 |
+
use_bfloat16: bool = Field(
|
| 910 |
+
default=True,
|
| 911 |
+
description="Use bfloat16 for most layers rather than float32. Required for flash attention.",
|
| 912 |
+
)
|
| 913 |
+
|
| 914 |
+
def build(
|
| 915 |
+
self,
|
| 916 |
+
prog_channels: int,
|
| 917 |
+
boundary_channels: int,
|
| 918 |
+
out_channels: int,
|
| 919 |
+
hist: int,
|
| 920 |
+
srcs: list[DataSource],
|
| 921 |
+
) -> SamudraMulti:
|
| 922 |
+
assert len(self.patch_extent) == 2, "patch_extent must be a pair of floats."
|
| 923 |
+
extent = self.patch_extent[0], self.patch_extent[1]
|
| 924 |
+
|
| 925 |
+
all_grid_sizes = [s.grid_size for s in srcs]
|
| 926 |
+
max_lat_size, max_lon_size = (
|
| 927 |
+
max(g[0] for g in all_grid_sizes),
|
| 928 |
+
max(g[1] for g in all_grid_sizes),
|
| 929 |
+
)
|
| 930 |
+
|
| 931 |
+
impl = self.perceiver_implementation
|
| 932 |
+
if _use_flash(impl) and not self.use_bfloat16:
|
| 933 |
+
raise ValueError(
|
| 934 |
+
"Perceiver implementation resolves to flash attention. "
|
| 935 |
+
"Please set `use_bfloat16=True` or `perceiver_implementation='naive'`."
|
| 936 |
+
)
|
| 937 |
+
|
| 938 |
+
in_channels = prog_channels + boundary_channels
|
| 939 |
+
total_in_channels = in_channels + (3 if self.add_3d_coordinates else 0)
|
| 940 |
+
|
| 941 |
+
encoder = self.encoder.build(
|
| 942 |
+
total_in_channels,
|
| 943 |
+
self.embedding_dim,
|
| 944 |
+
extent,
|
| 945 |
+
max_lat_size,
|
| 946 |
+
max_lon_size,
|
| 947 |
+
impl,
|
| 948 |
+
)
|
| 949 |
+
processor = self.processor.build(
|
| 950 |
+
self.embedding_dim,
|
| 951 |
+
self.pad,
|
| 952 |
+
self.checkpointing,
|
| 953 |
+
)
|
| 954 |
+
decoder = self.decoder.build(
|
| 955 |
+
processor.out_channels,
|
| 956 |
+
out_channels,
|
| 957 |
+
extent,
|
| 958 |
+
impl,
|
| 959 |
+
)
|
| 960 |
+
|
| 961 |
+
add_3d_coordinates = Concat3dCoordinates() if self.add_3d_coordinates else None
|
| 962 |
+
return SamudraMulti(
|
| 963 |
+
in_channels=total_in_channels,
|
| 964 |
+
out_channels=out_channels,
|
| 965 |
+
pred_residuals=self.pred_residuals,
|
| 966 |
+
last_kernel_size=self.last_kernel_size,
|
| 967 |
+
pad=self.pad,
|
| 968 |
+
encoder=encoder,
|
| 969 |
+
processor=processor,
|
| 970 |
+
decoder=decoder,
|
| 971 |
+
add_3d_coordinates=add_3d_coordinates,
|
| 972 |
+
hist=hist,
|
| 973 |
+
checkpointing=self.checkpointing,
|
| 974 |
+
gradient_detach_interval=self.gradient_detach_interval,
|
| 975 |
+
use_bfloat16=self.use_bfloat16,
|
| 976 |
+
)
|
| 977 |
+
|
| 978 |
+
|
| 979 |
+
class SamudraMiniConfig(BaseModelConfig):
|
| 980 |
+
perceiver: PerceiverConfig = PerceiverConfig()
|
| 981 |
+
perceiver_implementation: PerceiverImpl = Field(
|
| 982 |
+
default="auto",
|
| 983 |
+
description="Perceiver attention implementation for the single PerceiverIO model. "
|
| 984 |
+
"'auto' selects flash attention when CUDA is available, otherwise naive.",
|
| 985 |
+
)
|
| 986 |
+
embedding_dim: int = Field(
|
| 987 |
+
default=128,
|
| 988 |
+
description="Dimension of data-token embeddings before PerceiverIO.",
|
| 989 |
+
)
|
| 990 |
+
queries_dim: int = Field(
|
| 991 |
+
default=128,
|
| 992 |
+
description="Dimension of PerceiverIO output queries.",
|
| 993 |
+
)
|
| 994 |
+
coordinate_embedding_dim: int = Field(
|
| 995 |
+
default=64,
|
| 996 |
+
description="Hidden dimension used by learned 3D Cartesian coordinate embeddings.",
|
| 997 |
+
)
|
| 998 |
+
query_chunk_size: int | None = Field(
|
| 999 |
+
default=None,
|
| 1000 |
+
description="Optional chunk size for query decoding. If set, PerceiverIO is called "
|
| 1001 |
+
"over query chunks to reduce memory use.",
|
| 1002 |
+
)
|
| 1003 |
+
use_bfloat16: bool = Field(
|
| 1004 |
+
default=True,
|
| 1005 |
+
description="Use bfloat16 for most layers rather than float32. Required for flash attention.",
|
| 1006 |
+
)
|
| 1007 |
+
|
| 1008 |
+
def build(
|
| 1009 |
+
self,
|
| 1010 |
+
prog_channels: int,
|
| 1011 |
+
boundary_channels: int,
|
| 1012 |
+
out_channels: int,
|
| 1013 |
+
hist: int,
|
| 1014 |
+
srcs: list[DataSource],
|
| 1015 |
+
) -> SamudraMini:
|
| 1016 |
+
if self.add_3d_coordinates:
|
| 1017 |
+
raise ValueError(
|
| 1018 |
+
"SamudraMini always uses learned Cartesian coordinate embeddings. "
|
| 1019 |
+
"Please set `add_3d_coordinates=False`."
|
| 1020 |
+
)
|
| 1021 |
+
|
| 1022 |
+
impl = self.perceiver_implementation
|
| 1023 |
+
if _use_flash(impl) and not self.use_bfloat16:
|
| 1024 |
+
raise ValueError(
|
| 1025 |
+
"Perceiver implementation resolves to flash attention. "
|
| 1026 |
+
"Please set `use_bfloat16=True` or `perceiver_implementation='naive'`."
|
| 1027 |
+
)
|
| 1028 |
+
|
| 1029 |
+
in_channels = prog_channels + boundary_channels
|
| 1030 |
+
perceiver_io = self.perceiver.build_io(
|
| 1031 |
+
self.embedding_dim,
|
| 1032 |
+
self.queries_dim,
|
| 1033 |
+
out_channels,
|
| 1034 |
+
impl,
|
| 1035 |
+
)
|
| 1036 |
+
return SamudraMini(
|
| 1037 |
+
in_channels=in_channels,
|
| 1038 |
+
out_channels=out_channels,
|
| 1039 |
+
pred_residuals=self.pred_residuals,
|
| 1040 |
+
last_kernel_size=self.last_kernel_size,
|
| 1041 |
+
pad=self.pad,
|
| 1042 |
+
input_embedding_dim=self.embedding_dim,
|
| 1043 |
+
coordinate_embedding_dim=self.coordinate_embedding_dim,
|
| 1044 |
+
queries_dim=self.queries_dim,
|
| 1045 |
+
query_chunk_size=self.query_chunk_size,
|
| 1046 |
+
perceiver_io=perceiver_io,
|
| 1047 |
+
hist=hist,
|
| 1048 |
+
checkpointing=self.checkpointing,
|
| 1049 |
+
gradient_detach_interval=self.gradient_detach_interval,
|
| 1050 |
+
use_bfloat16=self.use_bfloat16,
|
| 1051 |
+
)
|
| 1052 |
+
|
| 1053 |
+
|
| 1054 |
+
AnyModelConfig = SamudraConfig | SamudraMultiConfig | SamudraMiniConfig
|
| 1055 |
+
|
| 1056 |
+
|
| 1057 |
+
class DistributedConfig(BaseConfig):
|
| 1058 |
+
dist_url: str | None = None
|
| 1059 |
+
world_size: int | None = None
|
| 1060 |
+
rank: int | None = None
|
| 1061 |
+
gpu: int | None = None
|
| 1062 |
+
dist_backend: str | None = None
|
| 1063 |
+
|
| 1064 |
+
|
| 1065 |
+
class ExperimentConfig(BaseConfig):
|
| 1066 |
+
name: str = "cm4_samudra"
|
| 1067 |
+
rand_seed: int = 1
|
| 1068 |
+
base_output_dir: str = "train"
|
| 1069 |
+
# we require this to be set by the user but have optional here
|
| 1070 |
+
# so we can leave it out of config files
|
| 1071 |
+
data_root: Location | None = None
|
| 1072 |
+
wandb: WandBConfig
|
| 1073 |
+
|
| 1074 |
+
@cached_property
|
| 1075 |
+
def output_dir(self) -> Path:
|
| 1076 |
+
return Path(self.base_output_dir) / f"{self.name}"
|
| 1077 |
+
|
| 1078 |
+
@cached_property
|
| 1079 |
+
def nets_dir(self) -> Path:
|
| 1080 |
+
return self.output_dir / "saved_nets"
|
| 1081 |
+
|
| 1082 |
+
@cached_property
|
| 1083 |
+
def resolved_data_root(self) -> ResolvedLocation:
|
| 1084 |
+
# Default to the current directory when no data_root is given. Absolute
|
| 1085 |
+
# data locations (e.g. an s3:// demo source) ignore the root entirely, so
|
| 1086 |
+
# they run flagless; relative locations resolve against cwd and still
|
| 1087 |
+
# fail loudly at open() if the data isn't there.
|
| 1088 |
+
default_root = LocalLocation(path=Path.cwd())
|
| 1089 |
+
if self.data_root is None:
|
| 1090 |
+
return default_root
|
| 1091 |
+
return default_root.resolve(self.data_root)
|
| 1092 |
+
|
| 1093 |
+
|
| 1094 |
+
class ProfilerConfig(BaseConfig):
|
| 1095 |
+
# How often (in batches processed) to take a snapshot of the CUDA memory
|
| 1096 |
+
# (None = no snapshots)
|
| 1097 |
+
cuda_snapshot_frequency: int | None = None
|
| 1098 |
+
|
| 1099 |
+
def build(self, output_dir: Path, device: torch.device) -> Profiler:
|
| 1100 |
+
if self.cuda_snapshot_frequency is not None and device.type != "cuda":
|
| 1101 |
+
raise ValueError(
|
| 1102 |
+
"cuda_snapshot_frequency is only supported on CUDA devices, got "
|
| 1103 |
+
f"{device.type}"
|
| 1104 |
+
)
|
| 1105 |
+
return Profiler(output_dir, self.cuda_snapshot_frequency)
|
| 1106 |
+
|
| 1107 |
+
|
| 1108 |
+
# See backend.py for how these are turned into concrete devices
|
| 1109 |
+
TrainBackendConfig = Literal["cpu", "cuda", "nccl", "auto"]
|
| 1110 |
+
|
| 1111 |
+
|
| 1112 |
+
class DynamicLossConfig(pydantic.BaseModel):
|
| 1113 |
+
type: Literal["dynamic"] = "dynamic"
|
| 1114 |
+
metric: LossMetric = "mse"
|
| 1115 |
+
limit: float | None = Field(
|
| 1116 |
+
description="The ratio of the largest weight to the smallest weight across all channels which we'll allow. Default of None means no limit.",
|
| 1117 |
+
default=None,
|
| 1118 |
+
ge=1.0,
|
| 1119 |
+
)
|
| 1120 |
+
|
| 1121 |
+
|
| 1122 |
+
class GradientLossConfig(pydantic.BaseModel):
|
| 1123 |
+
type: Literal["gradient"] = "gradient"
|
| 1124 |
+
# at the moment this metric is only used for the non-gradient loss
|
| 1125 |
+
# (and would take a bit of refactoring to make it work for the gradient loss too)
|
| 1126 |
+
# so we fix it to MAE for now until it's clear we what flexibility is needed here.
|
| 1127 |
+
# TODO(#497): support other metrics for the gradient loss
|
| 1128 |
+
metric: Literal["mae"] = "mae"
|
| 1129 |
+
alpha: float = Field(
|
| 1130 |
+
description="Scaling factor for the gradient penalty term (alpha in the gradient-weighted loss).",
|
| 1131 |
+
default=0.1,
|
| 1132 |
+
ge=0.0,
|
| 1133 |
+
)
|
| 1134 |
+
|
| 1135 |
+
|
| 1136 |
+
Loss = LossMetric | DynamicLossConfig | GradientLossConfig
|
| 1137 |
+
|
| 1138 |
+
|
| 1139 |
+
def build_loss_fn(
|
| 1140 |
+
loss_cfg: Loss,
|
| 1141 |
+
device: torch.device,
|
| 1142 |
+
num_channels: int,
|
| 1143 |
+
pad_mode: str,
|
| 1144 |
+
) -> LossFnWithContext:
|
| 1145 |
+
match loss_cfg:
|
| 1146 |
+
case str():
|
| 1147 |
+
return loss_fn_from_metric(loss_cfg)
|
| 1148 |
+
case DynamicLossConfig(metric=metric, limit=limit):
|
| 1149 |
+
loss_fn = loss_fn_from_metric(metric)
|
| 1150 |
+
return DynamicLoss(
|
| 1151 |
+
loss_fn=loss_fn,
|
| 1152 |
+
limit=limit,
|
| 1153 |
+
device=device,
|
| 1154 |
+
num_channels=num_channels,
|
| 1155 |
+
)
|
| 1156 |
+
case GradientLossConfig(metric=metric, alpha=alpha):
|
| 1157 |
+
loss_fn = loss_fn_from_metric(metric)
|
| 1158 |
+
return GradientLoss(
|
| 1159 |
+
loss_fn=loss_fn,
|
| 1160 |
+
gradient_weight=alpha,
|
| 1161 |
+
pad_mode=pad_mode,
|
| 1162 |
+
)
|
| 1163 |
+
case _:
|
| 1164 |
+
assert_never(loss_cfg)
|
| 1165 |
+
|
| 1166 |
+
|
| 1167 |
+
class TrainConfig(TopLevelConfig):
|
| 1168 |
+
# Training parameters
|
| 1169 |
+
disk_mode: bool = True
|
| 1170 |
+
pin_mem: bool = True
|
| 1171 |
+
save_freq: int = 5
|
| 1172 |
+
validation_image_log_freq: int = Field(
|
| 1173 |
+
default=10,
|
| 1174 |
+
ge=1,
|
| 1175 |
+
description=(
|
| 1176 |
+
"How often to log expensive validation images. Epochs are 1-based, so "
|
| 1177 |
+
"a value of 10 logs on epochs 1, 11, 21, ..."
|
| 1178 |
+
),
|
| 1179 |
+
)
|
| 1180 |
+
epochs: int = 120
|
| 1181 |
+
preemptible: bool = True
|
| 1182 |
+
batch_size: int = 2
|
| 1183 |
+
learning_rate: float = 2e-4
|
| 1184 |
+
gradient_accumulation_steps: int = 1
|
| 1185 |
+
scheduler: SchedulerConfig | None = None
|
| 1186 |
+
loss: Loss = "mse"
|
| 1187 |
+
finetune: bool = False
|
| 1188 |
+
resume_ckpt_path: str | None = None
|
| 1189 |
+
debug: bool = False
|
| 1190 |
+
test_using_ema: bool = True
|
| 1191 |
+
ema_decay: float = 0.999
|
| 1192 |
+
faster_decay_at_start: bool = True
|
| 1193 |
+
delayed_loss_estimate: bool = False
|
| 1194 |
+
backend: TrainBackendConfig = "auto"
|
| 1195 |
+
|
| 1196 |
+
# Profiling parameters
|
| 1197 |
+
profiler: ProfilerConfig = ProfilerConfig()
|
| 1198 |
+
|
| 1199 |
+
# Data parameters at root level
|
| 1200 |
+
data_stride: list[int] = [1]
|
| 1201 |
+
steps: list[int] = [4]
|
| 1202 |
+
step_transition: list[int] = []
|
| 1203 |
+
inference_epochs: list[int] = [-1]
|
| 1204 |
+
|
| 1205 |
+
# Config components
|
| 1206 |
+
experiment: ExperimentConfig
|
| 1207 |
+
data: DataConfig
|
| 1208 |
+
model: AnyModelConfig
|
| 1209 |
+
|
| 1210 |
+
def prepare_output_dirs(self) -> None:
|
| 1211 |
+
self.experiment.nets_dir.mkdir(parents=True, exist_ok=True)
|
| 1212 |
+
self.experiment.output_dir.mkdir(parents=True, exist_ok=True)
|
| 1213 |
+
|
| 1214 |
+
|
| 1215 |
+
class ObsMetricsConfig(BaseConfig):
|
| 1216 |
+
"""Where the observation products live, and over what period to score.
|
| 1217 |
+
|
| 1218 |
+
Supplying this block is what turns observation metrics on: an eval job then
|
| 1219 |
+
compares its rollout against DUACS, OISST and ARGO-IAP once the rollout is
|
| 1220 |
+
on disk. Omit it (or pass `--observations=null`) to skip that phase.
|
| 1221 |
+
"""
|
| 1222 |
+
|
| 1223 |
+
duacs_location: Location = Field(
|
| 1224 |
+
default=UnresolvedLocation(path="obs/duacs.zarr"),
|
| 1225 |
+
description="DUACS surface geostrophic velocity, on its native grid.",
|
| 1226 |
+
)
|
| 1227 |
+
oisst_location: Location = Field(
|
| 1228 |
+
default=UnresolvedLocation(path="obs/oisst.zarr"),
|
| 1229 |
+
description="OISST sea-surface temperature, on its native grid.",
|
| 1230 |
+
)
|
| 1231 |
+
argo_iap_location: Location = Field(
|
| 1232 |
+
default=UnresolvedLocation(path="obs/argo-iap.zarr"),
|
| 1233 |
+
description="ARGO-IAP gridded temperature, on its native grid.",
|
| 1234 |
+
)
|
| 1235 |
+
rmse_start: str = Field(
|
| 1236 |
+
default="2015-01-01",
|
| 1237 |
+
description=(
|
| 1238 |
+
"Start of the primary scoring window. Must begin a complete calendar "
|
| 1239 |
+
"year: the score and its bootstrap both use equal-year blocks."
|
| 1240 |
+
),
|
| 1241 |
+
)
|
| 1242 |
+
rmse_end: str = Field(
|
| 1243 |
+
default="2022-12-31",
|
| 1244 |
+
description="End of the primary scoring window; must end a complete calendar year.",
|
| 1245 |
+
)
|
| 1246 |
+
bootstrap_samples: int = Field(
|
| 1247 |
+
default=10_000,
|
| 1248 |
+
ge=0,
|
| 1249 |
+
description="Calendar-year block-bootstrap draws for the 95% CI; 0 disables.",
|
| 1250 |
+
)
|
| 1251 |
+
velocity_kind: Literal["absolute", "anomaly"] = Field(
|
| 1252 |
+
default="absolute",
|
| 1253 |
+
description=(
|
| 1254 |
+
"Which DUACS geostrophic velocity to compare against. 'absolute' "
|
| 1255 |
+
"matches the model velocity derived from zos."
|
| 1256 |
+
),
|
| 1257 |
+
)
|
| 1258 |
+
baselines: list[str] = Field(
|
| 1259 |
+
default_factory=lambda: ["om4"],
|
| 1260 |
+
description=(
|
| 1261 |
+
"Reference rollouts scored alongside the model. 'om4' scores the "
|
| 1262 |
+
"ground-truth OM4 data already staged for the eval, which is what "
|
| 1263 |
+
"makes the model's own numbers interpretable."
|
| 1264 |
+
),
|
| 1265 |
+
)
|
| 1266 |
+
|
| 1267 |
+
@model_validator(mode="after")
|
| 1268 |
+
def _check(self) -> "ObsMetricsConfig":
|
| 1269 |
+
unknown = set(self.baselines) - {"om4"}
|
| 1270 |
+
if unknown:
|
| 1271 |
+
raise ValueError(f"Unknown baselines {sorted(unknown)}; supported: ['om4']")
|
| 1272 |
+
if self.window[0] > self.window[1]:
|
| 1273 |
+
raise ValueError(
|
| 1274 |
+
f"rmse_start ({self.rmse_start}) must not be after rmse_end "
|
| 1275 |
+
f"({self.rmse_end})"
|
| 1276 |
+
)
|
| 1277 |
+
return self
|
| 1278 |
+
|
| 1279 |
+
@property
|
| 1280 |
+
def window(self) -> tuple[pd.Timestamp, pd.Timestamp]:
|
| 1281 |
+
"""The scoring window, with a date-only end bound taken to end-of-day.
|
| 1282 |
+
|
| 1283 |
+
OM4 samples are stamped at 12:00, so a bare `2022-12-31` end bound would
|
| 1284 |
+
exclude that day's sample. Currently masked because the observation
|
| 1285 |
+
products end earlier and the window gets trimmed to the shared span, but
|
| 1286 |
+
it becomes a silent one-sample loss the moment coverage extends past
|
| 1287 |
+
`rmse_end` -- and the 7.5-day tolerance in the complete-calendar-year
|
| 1288 |
+
check is far too loose to notice.
|
| 1289 |
+
"""
|
| 1290 |
+
end = pd.Timestamp(self.rmse_end)
|
| 1291 |
+
if end == end.normalize():
|
| 1292 |
+
end = end + pd.Timedelta(days=1) - pd.Timedelta(1, "ns")
|
| 1293 |
+
return pd.Timestamp(self.rmse_start), end
|
| 1294 |
+
|
| 1295 |
+
|
| 1296 |
+
# See backend.py for how these are turned into concrete devices
|
| 1297 |
+
EvalBackendConfig = Literal["cpu", "cuda", "auto"]
|
| 1298 |
+
|
| 1299 |
+
|
| 1300 |
+
class EvalConfig(TopLevelConfig):
|
| 1301 |
+
# Basic parameters
|
| 1302 |
+
debug: bool = False
|
| 1303 |
+
save_zarr: bool = False
|
| 1304 |
+
disk_mode: bool = True
|
| 1305 |
+
# we require this to be set by the user but have optional here
|
| 1306 |
+
# so we can leave it out of config files
|
| 1307 |
+
ckpt_path: str | None = None
|
| 1308 |
+
num_model_steps_forward: int = 200
|
| 1309 |
+
backend: EvalBackendConfig = "auto"
|
| 1310 |
+
|
| 1311 |
+
# Config components
|
| 1312 |
+
experiment: ExperimentConfig
|
| 1313 |
+
data: DataConfig
|
| 1314 |
+
model: AnyModelConfig
|
| 1315 |
+
observations: ObsMetricsConfig | None = Field(
|
| 1316 |
+
default=None,
|
| 1317 |
+
description=(
|
| 1318 |
+
"Observation-based metrics computed after the rollout finishes. "
|
| 1319 |
+
"Present means enabled; omit to skip the phase."
|
| 1320 |
+
),
|
| 1321 |
+
)
|
| 1322 |
+
|
| 1323 |
+
@pydantic.model_validator(mode="after")
|
| 1324 |
+
def _observations_need_a_saved_rollout(self) -> Self:
|
| 1325 |
+
# Caught here rather than after the rollout: observation metrics score a
|
| 1326 |
+
# rollout read back from disk, and discovering the misconfiguration at
|
| 1327 |
+
# the end would waste the whole job.
|
| 1328 |
+
if self.observations is not None and not self.save_zarr:
|
| 1329 |
+
raise ValueError(
|
| 1330 |
+
"observations requires save_zarr=true: the metrics are computed "
|
| 1331 |
+
"from the predictions.zarr the rollout writes."
|
| 1332 |
+
)
|
| 1333 |
+
return self
|
| 1334 |
+
|
| 1335 |
+
def prepare_output_dirs(self) -> None:
|
| 1336 |
+
self.experiment.output_dir.mkdir(parents=True, exist_ok=True)
|
| 1337 |
+
|
| 1338 |
+
|
| 1339 |
+
AnyTopLevelConfig = TrainConfig | EvalConfig
|
samudra/config_base.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import importlib.resources
|
| 7 |
+
import os
|
| 8 |
+
import textwrap
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any, Self
|
| 11 |
+
|
| 12 |
+
import yaml
|
| 13 |
+
from pydantic import BaseModel, ConfigDict
|
| 14 |
+
from pydantic.fields import FieldInfo
|
| 15 |
+
from pydantic_settings import (
|
| 16 |
+
BaseSettings,
|
| 17 |
+
CliSettingsSource,
|
| 18 |
+
PydanticBaseSettingsSource,
|
| 19 |
+
SettingsConfigDict,
|
| 20 |
+
YamlConfigSettingsSource,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def register_include_constructor():
|
| 25 |
+
"""Set up yaml.safe_load to include other yaml files via !include."""
|
| 26 |
+
|
| 27 |
+
def include_constructor(loader: yaml.Loader, node: yaml.Node) -> Any:
|
| 28 |
+
if hasattr(loader.stream, "name"):
|
| 29 |
+
name = loader.stream.name # type: ignore
|
| 30 |
+
else:
|
| 31 |
+
raise ValueError(
|
| 32 |
+
"To support includes, you must load a file object, not a string"
|
| 33 |
+
)
|
| 34 |
+
filename = os.path.normpath(os.path.join(os.path.dirname(name), node.value))
|
| 35 |
+
with open(filename) as f:
|
| 36 |
+
return yaml.safe_load(f)
|
| 37 |
+
|
| 38 |
+
# This is arguably unsafe, but we don't parse untrusted YAML
|
| 39 |
+
yaml.loader.SafeLoader.add_constructor("!include", include_constructor)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
register_include_constructor()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def resolve_config_path(config: str) -> Path:
|
| 46 |
+
"""Resolve a config argument to a file on disk.
|
| 47 |
+
|
| 48 |
+
A real filesystem path wins, so a checkout behaves exactly as before. If it
|
| 49 |
+
doesn't exist, fall back to a preset bundled in the installed package (e.g.
|
| 50 |
+
``samudra_om4/train.yaml``), so ``samudra train samudra_om4/train.yaml``
|
| 51 |
+
works with no checkout. The bundled configs install as real co-located
|
| 52 |
+
files, so their relative ``!include``s resolve unchanged.
|
| 53 |
+
"""
|
| 54 |
+
path = Path(config).expanduser()
|
| 55 |
+
if not path.exists():
|
| 56 |
+
bundled = importlib.resources.files("samudra") / "configs" / config
|
| 57 |
+
if bundled.is_file():
|
| 58 |
+
path = Path(str(bundled))
|
| 59 |
+
return path.resolve()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class BaseConfig(BaseModel):
|
| 63 |
+
"""
|
| 64 |
+
Base class for all configs.
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
model_config = ConfigDict(extra="forbid")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class TopLevelConfig(BaseSettings):
|
| 71 |
+
"""
|
| 72 |
+
Base class for top-level configs (ie tasks like train or eval).
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
model_config = SettingsConfigDict(nested_model_default_partial_update=True)
|
| 76 |
+
|
| 77 |
+
def __init__(self, *args, **kwargs):
|
| 78 |
+
super().__init__(*args, **kwargs)
|
| 79 |
+
|
| 80 |
+
@classmethod
|
| 81 |
+
def settings_customise_sources(
|
| 82 |
+
cls,
|
| 83 |
+
settings_cls: type[BaseSettings],
|
| 84 |
+
init_settings: PydanticBaseSettingsSource,
|
| 85 |
+
env_settings: PydanticBaseSettingsSource,
|
| 86 |
+
dotenv_settings: PydanticBaseSettingsSource,
|
| 87 |
+
file_secret_settings: PydanticBaseSettingsSource,
|
| 88 |
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
| 89 |
+
# We don't need env/dotenv/secrets, yaml & CLI are injected in from_yaml_and_cli
|
| 90 |
+
return (init_settings,)
|
| 91 |
+
|
| 92 |
+
@classmethod
|
| 93 |
+
def from_yaml_and_cli(
|
| 94 |
+
cls,
|
| 95 |
+
args_to_parse: list[str] | None = None,
|
| 96 |
+
) -> Self:
|
| 97 |
+
"""Load config from YAML & CLI with validation."""
|
| 98 |
+
parser = argparse.ArgumentParser(
|
| 99 |
+
description=cls.__doc__,
|
| 100 |
+
epilog=textwrap.dedent(
|
| 101 |
+
"""
|
| 102 |
+
CONFIG may be a path to a YAML file or, when samudra is installed,
|
| 103 |
+
the name of a bundled preset such as `samudra_om4/train.yaml`.
|
| 104 |
+
YAML files can include other YAML files using the !include tag,
|
| 105 |
+
as in `data: !include ../data/om4.yaml` (resolved relative to the
|
| 106 |
+
including file). You can also replace any JSON argument listed above
|
| 107 |
+
with a YAML file by specifying it with an @ symbol,
|
| 108 |
+
eg `--some_param=@../data/om4.yaml`.
|
| 109 |
+
"""
|
| 110 |
+
),
|
| 111 |
+
)
|
| 112 |
+
parser.add_argument(
|
| 113 |
+
"config",
|
| 114 |
+
type=str,
|
| 115 |
+
help="Path to a config YAML file, or a bundled preset name",
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
cli_source = IncludeYamlCliSettingsSource(
|
| 119 |
+
cls,
|
| 120 |
+
root_parser=parser,
|
| 121 |
+
# If args_to_parse is None, we parse argv, which is what `True` does
|
| 122 |
+
cli_parse_args=args_to_parse if args_to_parse is not None else True,
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# We do this after creating CliSettingsSource (which populates the parser)
|
| 126 |
+
# so the help is complete on error.
|
| 127 |
+
args = parser.parse_args(args_to_parse)
|
| 128 |
+
|
| 129 |
+
# Then we read the YAML file specified in the CLI. A bare path resolves
|
| 130 |
+
# against the filesystem; if it's missing we try a preset bundled in the
|
| 131 |
+
# installed package before giving up.
|
| 132 |
+
# Note that by default, YamlConfigSettingsSource will ignore missing files
|
| 133 |
+
config_path = resolve_config_path(args.config)
|
| 134 |
+
if not config_path.exists():
|
| 135 |
+
raise FileNotFoundError(
|
| 136 |
+
f"Config file `{args.config}` (full path: {config_path}) not found"
|
| 137 |
+
)
|
| 138 |
+
yaml_values = YamlConfigSettingsSource(cls, yaml_file=config_path)()
|
| 139 |
+
|
| 140 |
+
return cls(
|
| 141 |
+
_cli_settings_source=cli_source,
|
| 142 |
+
**yaml_values,
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
def save_yaml(self, save_path: Path) -> None:
|
| 146 |
+
"""Save config to YAML file."""
|
| 147 |
+
with open(save_path, "w") as f:
|
| 148 |
+
yaml.dump(self.model_dump(), f)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class IncludeYamlCliSettingsSource(CliSettingsSource):
|
| 152 |
+
"""CliSettingsSource which permits @filename.yaml for JSON arguments."""
|
| 153 |
+
|
| 154 |
+
def __init__(self, *args, **kwargs):
|
| 155 |
+
super().__init__(*args, **kwargs)
|
| 156 |
+
|
| 157 |
+
def decode_complex_value(
|
| 158 |
+
self, field_name: str, field: FieldInfo, value: Any
|
| 159 |
+
) -> Any:
|
| 160 |
+
if isinstance(value, str) and value.startswith("@"):
|
| 161 |
+
# Resolve like the positional config: a real path wins, otherwise
|
| 162 |
+
# fall back to a bundled preset (so `--data @data/om4_demo.yaml`
|
| 163 |
+
# works without a checkout).
|
| 164 |
+
with open(resolve_config_path(value[1:])) as f:
|
| 165 |
+
return yaml.safe_load(f)
|
| 166 |
+
return super().decode_complex_value(field_name, field, value)
|
samudra/config_schema.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
import re
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import get_args, get_origin
|
| 11 |
+
|
| 12 |
+
import pydantic
|
| 13 |
+
import yaml
|
| 14 |
+
|
| 15 |
+
from samudra.config import EvalConfig, TrainConfig
|
| 16 |
+
from samudra.viz import VizConfig
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def get_pydantic_models(
|
| 20 |
+
model: type[pydantic.BaseModel],
|
| 21 |
+
models: dict[str, type[pydantic.BaseModel]] | None = None,
|
| 22 |
+
seen: set[int] | None = None,
|
| 23 |
+
) -> dict[str, type[pydantic.BaseModel]]:
|
| 24 |
+
"""Recursively find all Pydantic models in a model's fields.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
model: The Pydantic model to start from
|
| 28 |
+
models: Collected models keyed by model name
|
| 29 |
+
seen: Visited annotation objects keyed by identity
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
Dictionary of model names to model classes
|
| 33 |
+
"""
|
| 34 |
+
if models is None:
|
| 35 |
+
models = {}
|
| 36 |
+
if seen is None:
|
| 37 |
+
seen = set()
|
| 38 |
+
|
| 39 |
+
_collect_pydantic_models(model, models, seen)
|
| 40 |
+
return models
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _collect_pydantic_models(
|
| 44 |
+
annotation: object,
|
| 45 |
+
models: dict[str, type[pydantic.BaseModel]],
|
| 46 |
+
seen: set[int],
|
| 47 |
+
) -> None:
|
| 48 |
+
annotation_id = id(annotation)
|
| 49 |
+
if annotation_id in seen:
|
| 50 |
+
return
|
| 51 |
+
seen.add(annotation_id)
|
| 52 |
+
|
| 53 |
+
if isinstance(annotation, type) and issubclass(annotation, pydantic.BaseModel):
|
| 54 |
+
models[annotation.__name__] = annotation
|
| 55 |
+
for field in annotation.model_fields.values():
|
| 56 |
+
_collect_pydantic_models(field.annotation, models, seen)
|
| 57 |
+
return
|
| 58 |
+
|
| 59 |
+
# We check if this is a parameterized type and recurse if so (eg Union[int, str])
|
| 60 |
+
# (Union is the origin in that case.)
|
| 61 |
+
origin = get_origin(annotation)
|
| 62 |
+
if origin is None:
|
| 63 |
+
return
|
| 64 |
+
|
| 65 |
+
for arg in get_args(annotation):
|
| 66 |
+
_collect_pydantic_models(arg, models, seen)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def generate_schemas(
|
| 70 |
+
output_dir: Path,
|
| 71 |
+
models: dict[str, type[pydantic.BaseModel]],
|
| 72 |
+
) -> None:
|
| 73 |
+
"""Generate JSON schemas for all Pydantic models and save them to output_dir.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
output_dir: Directory to save the generated schemas
|
| 77 |
+
models: Dictionary of model names to model classes
|
| 78 |
+
"""
|
| 79 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 80 |
+
|
| 81 |
+
for model_name, model in models.items():
|
| 82 |
+
schema = model.model_json_schema()
|
| 83 |
+
output_path = output_dir / f"{model_name}.json"
|
| 84 |
+
|
| 85 |
+
# Check if file exists and content is different
|
| 86 |
+
if output_path.exists():
|
| 87 |
+
with open(output_path) as f:
|
| 88 |
+
existing_schema = json.load(f)
|
| 89 |
+
if existing_schema == schema:
|
| 90 |
+
continue
|
| 91 |
+
|
| 92 |
+
# Write only if content differs or file doesn't exist
|
| 93 |
+
with open(output_path, "w") as f:
|
| 94 |
+
json.dump(schema, f, indent=2)
|
| 95 |
+
print(f"🆕 Updated schema for {model_name} at {output_path}")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def validate_config_files(
|
| 99 |
+
config_dir: Path,
|
| 100 |
+
models: dict[str, type[pydantic.BaseModel]],
|
| 101 |
+
) -> bool:
|
| 102 |
+
"""Validate YAML configuration files against their Pydantic models.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
config_dir: Directory containing YAML configuration files to validate
|
| 106 |
+
models: Dictionary of model names to model classes
|
| 107 |
+
"""
|
| 108 |
+
valid = True
|
| 109 |
+
|
| 110 |
+
# Find all YAML files in the config directory
|
| 111 |
+
yaml_files = list(config_dir.rglob("*.yaml"))
|
| 112 |
+
|
| 113 |
+
for yaml_file in yaml_files:
|
| 114 |
+
# Read the YAML file
|
| 115 |
+
with open(yaml_file) as f:
|
| 116 |
+
yaml_content = f.read()
|
| 117 |
+
|
| 118 |
+
# Extract the schema path from the YAML file's first line
|
| 119 |
+
schema_match = re.search(
|
| 120 |
+
r"\s*#\s+yaml-language-server:\s+\$schema=(.*\.json)", yaml_content
|
| 121 |
+
)
|
| 122 |
+
if not schema_match:
|
| 123 |
+
print(f"⚠️ No schema specified in {yaml_file}")
|
| 124 |
+
continue
|
| 125 |
+
|
| 126 |
+
schema_name = Path(schema_match.group(1)).stem
|
| 127 |
+
if schema_name not in models:
|
| 128 |
+
print(f"✗ Model {schema_name} not found for {yaml_file}")
|
| 129 |
+
valid = False
|
| 130 |
+
continue
|
| 131 |
+
|
| 132 |
+
# Load and validate the YAML content
|
| 133 |
+
try:
|
| 134 |
+
with open(yaml_file) as f:
|
| 135 |
+
config = yaml.safe_load(f)
|
| 136 |
+
# Validate using Pydantic model
|
| 137 |
+
models[schema_name].model_validate(config)
|
| 138 |
+
print(f"✅ {yaml_file} is a valid {schema_name}")
|
| 139 |
+
except Exception as e:
|
| 140 |
+
print(f"❌ {yaml_file} is an invalid {schema_name}: {str(e)}")
|
| 141 |
+
valid = False
|
| 142 |
+
|
| 143 |
+
return valid
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def main():
|
| 147 |
+
parser = argparse.ArgumentParser(
|
| 148 |
+
description="Generate JSON schemas and validate config files"
|
| 149 |
+
)
|
| 150 |
+
parser.add_argument(
|
| 151 |
+
"--output-dir",
|
| 152 |
+
type=Path,
|
| 153 |
+
default=Path("src/samudra/configs/schemas"),
|
| 154 |
+
help="Directory to save the generated schemas",
|
| 155 |
+
)
|
| 156 |
+
parser.add_argument(
|
| 157 |
+
"--validate-dir",
|
| 158 |
+
type=Path,
|
| 159 |
+
default=Path("src/samudra/configs"),
|
| 160 |
+
help="Directory of config files to validate",
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
args = parser.parse_args()
|
| 164 |
+
|
| 165 |
+
# Get all available models
|
| 166 |
+
models = get_pydantic_models(TrainConfig)
|
| 167 |
+
models.update(get_pydantic_models(EvalConfig))
|
| 168 |
+
models.update(get_pydantic_models(VizConfig))
|
| 169 |
+
|
| 170 |
+
generate_schemas(args.output_dir, models)
|
| 171 |
+
|
| 172 |
+
if not validate_config_files(args.validate_dir, models):
|
| 173 |
+
sys.exit(1)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
if __name__ == "__main__":
|
| 177 |
+
main()
|
samudra/configs/data/llc.yaml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Ocean Emulator Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/DataConfig.json
|
| 6 |
+
|
| 7 |
+
sources:
|
| 8 |
+
- type: llc
|
| 9 |
+
face: 1
|
| 10 |
+
i_start: 0
|
| 11 |
+
i_end: 720
|
| 12 |
+
j_start: 0
|
| 13 |
+
j_end: 720
|
| 14 |
+
train_time:
|
| 15 |
+
start: "2011-09-10T12:00:00Z"
|
| 16 |
+
end: "2012-09-01T12:00:00Z"
|
| 17 |
+
val_time:
|
| 18 |
+
start: "2012-09-01T12:00:00Z"
|
| 19 |
+
end: "2012-11-15T12:00:00Z"
|
| 20 |
+
data_location: LLC.zarr
|
| 21 |
+
data_means_location: LLC_means.zarr
|
| 22 |
+
data_stds_location: LLC_stds.zarr
|
| 23 |
+
concurrent_compute: false
|
| 24 |
+
loading:
|
| 25 |
+
type: cpu
|
| 26 |
+
num_workers: 4
|
samudra/configs/data/obs.yaml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# Observation-based evaluation metrics, scored once the rollout is on disk.
|
| 6 |
+
# Including this file is what enables them; omit it to skip the phase.
|
| 7 |
+
#
|
| 8 |
+
# The three products are published anonymously readable, so an eval run needs
|
| 9 |
+
# neither credentials nor a staging step to produce these metrics. They were
|
| 10 |
+
# built from the providers by `python -m ocean_preprocessing.obs_preprocessing`
|
| 11 |
+
# (see data/README.md), and each store records its own source, attribution and
|
| 12 |
+
# provenance in its zarr attrs.
|
| 13 |
+
#
|
| 14 |
+
# To read a locally staged copy instead -- worth it on repeat runs, since DUACS
|
| 15 |
+
# alone is 46 GB -- set OSN_OBS_PATH in the skypilot eval job and override the
|
| 16 |
+
# locations to the relative paths `obs/duacs.zarr` and so on, which resolve
|
| 17 |
+
# against experiment.data_root.
|
| 18 |
+
|
| 19 |
+
duacs_location: &obs_s3
|
| 20 |
+
type: s3
|
| 21 |
+
bucket: m2lines-pubs
|
| 22 |
+
path: Samudra/v2026-07/obs/duacs.zarr
|
| 23 |
+
endpoint_url: https://nyu1.osn.mghpcc.org
|
| 24 |
+
anon: true
|
| 25 |
+
oisst_location:
|
| 26 |
+
<<: *obs_s3
|
| 27 |
+
path: Samudra/v2026-07/obs/oisst.zarr
|
| 28 |
+
argo_iap_location:
|
| 29 |
+
<<: *obs_s3
|
| 30 |
+
path: Samudra/v2026-07/obs/argo-iap.zarr
|
| 31 |
+
|
| 32 |
+
# Eight complete calendar years, so the score and its block bootstrap share
|
| 33 |
+
# equal-weight blocks.
|
| 34 |
+
rmse_start: "2015-01-01"
|
| 35 |
+
rmse_end: "2022-12-31"
|
samudra/configs/data/om4.yaml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/DataConfig.json
|
| 6 |
+
|
| 7 |
+
sources:
|
| 8 |
+
- type: om4
|
| 9 |
+
prognostic_vars_key: thermo_dynamic_all
|
| 10 |
+
boundary_vars_key: tau_hfds_hfds_anom
|
| 11 |
+
train_time:
|
| 12 |
+
start: "1975-01-03"
|
| 13 |
+
end: "2013-10-05"
|
| 14 |
+
val_time:
|
| 15 |
+
start: "2013-10-05"
|
| 16 |
+
end: "2014-10-05"
|
| 17 |
+
inference_times:
|
| 18 |
+
- start: "2014-10-10"
|
| 19 |
+
end: "2022-12-24"
|
| 20 |
+
data_location: OM4.zarr
|
| 21 |
+
data_means_location: OM4_means.zarr
|
| 22 |
+
data_stds_location: OM4_stds.zarr
|
| 23 |
+
concurrent_compute: true
|
| 24 |
+
loading:
|
| 25 |
+
type: cpu
|
samudra/configs/data/om4_demo.yaml
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/DataConfig.json
|
| 6 |
+
|
| 7 |
+
# Two-degree (2°) OM4 demo data source that streams straight from the public
|
| 8 |
+
# m2lines-pubs bucket on OSN over S3 — no local download or credentials needed
|
| 9 |
+
# (anon: true reads the public bucket anonymously). The locations below are
|
| 10 |
+
# absolute (type: s3), so they are read as-is and `experiment.data_root` is
|
| 11 |
+
# unused — you can omit it entirely.
|
| 12 |
+
|
| 13 |
+
sources:
|
| 14 |
+
- type: om4
|
| 15 |
+
prognostic_vars_key: thermo_dynamic_all
|
| 16 |
+
# tau_hfds (tauuo, tauvo, hfds) rather than the samudra_om4 presets'
|
| 17 |
+
# tau_hfds_hfds_anom: the public 2deg data has no `hfds_anomalies` variable.
|
| 18 |
+
# This is a self-contained train -> eval -> viz demo (train a fresh model on
|
| 19 |
+
# this data); its boundary channels differ from the released checkpoints, so
|
| 20 |
+
# don't eval a preset-trained checkpoint against this source.
|
| 21 |
+
boundary_vars_key: tau_hfds
|
| 22 |
+
train_time:
|
| 23 |
+
start: "1975-01-03"
|
| 24 |
+
end: "2013-10-05"
|
| 25 |
+
val_time:
|
| 26 |
+
start: "2013-10-05"
|
| 27 |
+
end: "2014-10-05"
|
| 28 |
+
inference_times:
|
| 29 |
+
- start: "2014-10-10"
|
| 30 |
+
end: "2022-12-24"
|
| 31 |
+
data_location:
|
| 32 |
+
type: s3
|
| 33 |
+
endpoint_url: "https://nyu1.osn.mghpcc.org"
|
| 34 |
+
anon: true
|
| 35 |
+
bucket: m2lines-pubs
|
| 36 |
+
path: Samudra/v2026-07/om4_twodeg/OM4.zarr
|
| 37 |
+
data_means_location:
|
| 38 |
+
type: s3
|
| 39 |
+
endpoint_url: "https://nyu1.osn.mghpcc.org"
|
| 40 |
+
anon: true
|
| 41 |
+
bucket: m2lines-pubs
|
| 42 |
+
path: Samudra/v2026-07/om4_twodeg/OM4_means.zarr
|
| 43 |
+
data_stds_location:
|
| 44 |
+
type: s3
|
| 45 |
+
endpoint_url: "https://nyu1.osn.mghpcc.org"
|
| 46 |
+
anon: true
|
| 47 |
+
bucket: m2lines-pubs
|
| 48 |
+
path: Samudra/v2026-07/om4_twodeg/OM4_stds.zarr
|
| 49 |
+
concurrent_compute: true
|
| 50 |
+
loading:
|
| 51 |
+
type: cpu
|
samudra/configs/samudra_mini_om4/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!--
|
| 2 |
+
SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
|
| 4 |
+
SPDX-License-Identifier: CC-BY-4.0
|
| 5 |
+
-->
|
| 6 |
+
|
| 7 |
+
# SamudraMini OM4
|
| 8 |
+
|
| 9 |
+
These configurations are for SamudraMini, a single PerceiverIO model that treats
|
| 10 |
+
each `(lat, lon)` point as a token and uses learned embeddings of 3D Cartesian
|
| 11 |
+
coordinates for both data tokens and output queries.
|
samudra/configs/samudra_mini_om4/model.yaml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/SamudraMiniConfig.json
|
| 6 |
+
|
| 7 |
+
pred_residuals: false
|
| 8 |
+
last_kernel_size: 3
|
| 9 |
+
pad: "circular"
|
| 10 |
+
checkpointing: "all"
|
| 11 |
+
perceiver_implementation: "auto"
|
| 12 |
+
embedding_dim: 128
|
| 13 |
+
queries_dim: 128
|
| 14 |
+
coordinate_embedding_dim: 64
|
| 15 |
+
query_chunk_size: 8192
|
| 16 |
+
|
| 17 |
+
perceiver:
|
| 18 |
+
depth: 4
|
| 19 |
+
latent_dim: 96
|
| 20 |
+
num_latents: 256
|
samudra/configs/samudra_mini_om4/train.yaml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/TrainConfig.json
|
| 6 |
+
|
| 7 |
+
debug: false
|
| 8 |
+
disk_mode: true
|
| 9 |
+
pin_mem: true
|
| 10 |
+
save_freq: 5
|
| 11 |
+
epochs: 70
|
| 12 |
+
batch_size: 2
|
| 13 |
+
learning_rate: 0.0006
|
| 14 |
+
scheduler: { type: cosine }
|
| 15 |
+
loss:
|
| 16 |
+
type: dynamic
|
| 17 |
+
metric: mse
|
| 18 |
+
finetune: false
|
| 19 |
+
resume_ckpt_path: null
|
| 20 |
+
inference_epochs: []
|
| 21 |
+
data_stride: [1]
|
| 22 |
+
steps: [4]
|
| 23 |
+
step_transition: []
|
| 24 |
+
preemptible: false
|
| 25 |
+
|
| 26 |
+
backend: auto
|
| 27 |
+
|
| 28 |
+
experiment:
|
| 29 |
+
name: samudra_mini_om4
|
| 30 |
+
rand_seed: 15
|
| 31 |
+
base_output_dir: .LOCAL
|
| 32 |
+
wandb:
|
| 33 |
+
mode: disabled
|
| 34 |
+
project: default
|
| 35 |
+
data: !include ../data/om4.yaml
|
| 36 |
+
model: !include model.yaml
|
samudra/configs/samudra_multi_om4/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!--
|
| 2 |
+
SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
|
| 4 |
+
SPDX-License-Identifier: CC-BY-4.0
|
| 5 |
+
-->
|
| 6 |
+
|
| 7 |
+
# samudra-multi OM4
|
| 8 |
+
|
| 9 |
+
These configurations are for the samudra-multi (encoder-processor-decoder) model,
|
| 10 |
+
trained on OM4 data. The intent is for this to support heterogeneous
|
| 11 |
+
resolutions and modalities.
|
samudra/configs/samudra_multi_om4/eval.yaml
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/EvalConfig.json
|
| 6 |
+
#
|
| 7 |
+
# Eval config for the multi-scale samudra-multi model.
|
| 8 |
+
#
|
| 9 |
+
# The eval code runs inference on the *first* data source only.
|
| 10 |
+
# To evaluate all three scales, run three separate eval jobs and
|
| 11 |
+
# reorder the sources list so the target resolution is first.
|
| 12 |
+
# All three sources must always be present so the model builds
|
| 13 |
+
# with the correct max grid size for the encoder patch grid.
|
| 14 |
+
# The max grid size affects the rotary positional embedding
|
| 15 |
+
# dimension for the encoder's Perceiver. If it is set wrong,
|
| 16 |
+
# it would likely cause hard-to-debug model level errors.
|
| 17 |
+
# Setting multiple data sources lets us use checkpoints for
|
| 18 |
+
# samudra-multi models that use multiple scales.
|
| 19 |
+
|
| 20 |
+
debug: false
|
| 21 |
+
save_zarr: true
|
| 22 |
+
disk_mode: true
|
| 23 |
+
num_model_steps_forward: 25
|
| 24 |
+
|
| 25 |
+
experiment:
|
| 26 |
+
name: samudra_multi_om4_eval
|
| 27 |
+
rand_seed: 15
|
| 28 |
+
base_output_dir: .LOCAL
|
| 29 |
+
wandb:
|
| 30 |
+
mode: disabled
|
| 31 |
+
project: default
|
| 32 |
+
|
| 33 |
+
data:
|
| 34 |
+
concurrent_compute: true
|
| 35 |
+
sources:
|
| 36 |
+
# First source is used for inference. Reorder to eval a different scale.
|
| 37 |
+
- type: om4
|
| 38 |
+
prognostic_vars_key: thermo_dynamic_all
|
| 39 |
+
boundary_vars_key: tau_hfds
|
| 40 |
+
train_time: &train_time
|
| 41 |
+
start: "1975-01-03"
|
| 42 |
+
end: "2013-10-05"
|
| 43 |
+
val_time: &val_time
|
| 44 |
+
start: "2013-10-05"
|
| 45 |
+
end: "2014-10-05"
|
| 46 |
+
inference_times: &inference_times
|
| 47 |
+
- start: "2014-10-05"
|
| 48 |
+
end: "2015-10-05"
|
| 49 |
+
data_location: om4_halfdeg_v4/OM4.zarr
|
| 50 |
+
data_means_location: om4_halfdeg_v4/OM4_means.zarr
|
| 51 |
+
data_stds_location: om4_halfdeg_v4/OM4_stds.zarr
|
| 52 |
+
- type: om4
|
| 53 |
+
prognostic_vars_key: thermo_dynamic_all
|
| 54 |
+
boundary_vars_key: tau_hfds
|
| 55 |
+
train_time: *train_time
|
| 56 |
+
val_time: *val_time
|
| 57 |
+
inference_times: *inference_times
|
| 58 |
+
data_location: om4_onedeg_v3/OM4.zarr
|
| 59 |
+
data_means_location: om4_onedeg_v3/OM4_means.zarr
|
| 60 |
+
data_stds_location: om4_onedeg_v3/OM4_stds.zarr
|
| 61 |
+
- type: om4
|
| 62 |
+
prognostic_vars_key: thermo_dynamic_all
|
| 63 |
+
boundary_vars_key: tau_hfds
|
| 64 |
+
train_time: *train_time
|
| 65 |
+
val_time: *val_time
|
| 66 |
+
inference_times: *inference_times
|
| 67 |
+
data_location: om4_quarterdeg_v2/OM4.zarr
|
| 68 |
+
data_means_location: om4_quarterdeg_v2/OM4_means.zarr
|
| 69 |
+
data_stds_location: om4_quarterdeg_v2/OM4_stds.zarr
|
| 70 |
+
|
| 71 |
+
model: !include model.yaml
|
samudra/configs/samudra_multi_om4/model.yaml
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/SamudraMultiConfig.json
|
| 6 |
+
|
| 7 |
+
pred_residuals: false
|
| 8 |
+
last_kernel_size: 3
|
| 9 |
+
pad: "circular"
|
| 10 |
+
checkpointing: "all"
|
| 11 |
+
embedding_dim: 128 # Reduced from 280
|
| 12 |
+
perceiver_implementation: "auto"
|
| 13 |
+
# TODO(alxmrs): How fine of a resolution should each patch be? This produces a 60x72 latent grid.
|
| 14 |
+
patch_extent: [3.0, 5.0]
|
| 15 |
+
|
| 16 |
+
encoder:
|
| 17 |
+
perceiver:
|
| 18 |
+
depth: 2
|
| 19 |
+
latent_dim: 64
|
| 20 |
+
num_latents: 256
|
| 21 |
+
|
| 22 |
+
decoder:
|
| 23 |
+
perceiver:
|
| 24 |
+
depth: 2
|
| 25 |
+
latent_dim: 64
|
| 26 |
+
num_latents: 256
|
| 27 |
+
queries_dim: 64 # on the small end, but typical for PercieverIO models (usually in the ~100s-1000s).
|
| 28 |
+
window_patches: 12 # Must evenly divide latent grid (60x72). Enables real spatial windowing.
|
| 29 |
+
context_patches: 1 # Look at this many window_patches around the target patch.
|
| 30 |
+
|
| 31 |
+
processor:
|
| 32 |
+
ch_width: [380, 480, 520]
|
| 33 |
+
dilation: [1, 1, 1]
|
| 34 |
+
n_layers: [1, 1, 1]
|
| 35 |
+
|
| 36 |
+
core_block:
|
| 37 |
+
block_type: "conv_next_block"
|
| 38 |
+
kernel_size: 3
|
| 39 |
+
activation: "capped_gelu"
|
| 40 |
+
upscale_factor: 2
|
| 41 |
+
norm: "instance"
|
| 42 |
+
pointwise_linear: true
|
| 43 |
+
|
| 44 |
+
down_sampling_block: "avg_pool"
|
| 45 |
+
up_sampling_block: "zonally_periodic_upsample"
|
samudra/configs/samudra_multi_om4/train.yaml
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/TrainConfig.json
|
| 6 |
+
|
| 7 |
+
debug: false
|
| 8 |
+
disk_mode: true
|
| 9 |
+
pin_mem: true
|
| 10 |
+
save_freq: 5
|
| 11 |
+
epochs: 70
|
| 12 |
+
batch_size: 2
|
| 13 |
+
learning_rate: 0.0006
|
| 14 |
+
scheduler: { type: cosine }
|
| 15 |
+
loss:
|
| 16 |
+
type: dynamic
|
| 17 |
+
metric: mse
|
| 18 |
+
limit: 20
|
| 19 |
+
finetune: false
|
| 20 |
+
resume_ckpt_path: null
|
| 21 |
+
inference_epochs: []
|
| 22 |
+
data_stride: [1]
|
| 23 |
+
steps: [4]
|
| 24 |
+
step_transition: []
|
| 25 |
+
preemptible: false
|
| 26 |
+
|
| 27 |
+
backend: auto
|
| 28 |
+
|
| 29 |
+
experiment:
|
| 30 |
+
name: samudra_multi_om4
|
| 31 |
+
rand_seed: 15
|
| 32 |
+
base_output_dir: .LOCAL
|
| 33 |
+
wandb:
|
| 34 |
+
mode: disabled
|
| 35 |
+
project: default
|
| 36 |
+
data:
|
| 37 |
+
concurrent_compute: true
|
| 38 |
+
loading:
|
| 39 |
+
type: cpu
|
| 40 |
+
num_workers: 4
|
| 41 |
+
sources:
|
| 42 |
+
- type: om4
|
| 43 |
+
prognostic_vars_key: thermo_dynamic_all
|
| 44 |
+
boundary_vars_key: tau_hfds
|
| 45 |
+
train_time: &train_time
|
| 46 |
+
start: "1975-01-03"
|
| 47 |
+
end: "2013-10-05"
|
| 48 |
+
val_time: &val_time
|
| 49 |
+
start: "2013-10-05"
|
| 50 |
+
end: "2014-10-05"
|
| 51 |
+
inference_times: &inference_times
|
| 52 |
+
- start: "2014-10-05"
|
| 53 |
+
end: "2015-10-05"
|
| 54 |
+
data_location: om4_quarterdeg_v2/OM4.zarr
|
| 55 |
+
data_means_location: om4_quarterdeg_v2/OM4_means.zarr
|
| 56 |
+
data_stds_location: om4_quarterdeg_v2/OM4_stds.zarr
|
| 57 |
+
- type: om4
|
| 58 |
+
prognostic_vars_key: thermo_dynamic_all
|
| 59 |
+
boundary_vars_key: tau_hfds
|
| 60 |
+
train_time: *train_time
|
| 61 |
+
val_time: *val_time
|
| 62 |
+
inference_times: *inference_times
|
| 63 |
+
data_location: om4_halfdeg_v4/OM4.zarr
|
| 64 |
+
data_means_location: om4_halfdeg_v4/OM4_means.zarr
|
| 65 |
+
data_stds_location: om4_halfdeg_v4/OM4_stds.zarr
|
| 66 |
+
- type: om4
|
| 67 |
+
prognostic_vars_key: thermo_dynamic_all
|
| 68 |
+
boundary_vars_key: tau_hfds
|
| 69 |
+
train_time: *train_time
|
| 70 |
+
val_time: *val_time
|
| 71 |
+
inference_times: *inference_times
|
| 72 |
+
data_location: om4_onedeg_v3/OM4.zarr
|
| 73 |
+
data_means_location: om4_onedeg_v3/OM4_means.zarr
|
| 74 |
+
data_stds_location: om4_onedeg_v3/OM4_stds.zarr
|
| 75 |
+
model: !include model.yaml
|
samudra/configs/samudra_om4/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!--
|
| 2 |
+
SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
|
| 4 |
+
SPDX-License-Identifier: CC-BY-4.0
|
| 5 |
+
-->
|
| 6 |
+
|
| 7 |
+
# Samudra OM4
|
| 8 |
+
|
| 9 |
+
These configurations are our current WIP Samudra-style (UNet) model,
|
| 10 |
+
trained on OM4 data.
|
samudra/configs/samudra_om4/eval.yaml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/EvalConfig.json
|
| 6 |
+
|
| 7 |
+
debug: false
|
| 8 |
+
save_zarr: true
|
| 9 |
+
disk_mode: true
|
| 10 |
+
num_model_steps_forward: 25
|
| 11 |
+
|
| 12 |
+
experiment:
|
| 13 |
+
name: samudra_om4_eval
|
| 14 |
+
rand_seed: 15
|
| 15 |
+
base_output_dir: .LOCAL
|
| 16 |
+
wandb:
|
| 17 |
+
mode: disabled
|
| 18 |
+
project: default
|
| 19 |
+
data: !include ../data/om4.yaml
|
| 20 |
+
model: !include model.yaml
|
| 21 |
+
observations: !include ../data/obs.yaml
|
samudra/configs/samudra_om4/model.yaml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/SamudraConfig.json
|
| 6 |
+
|
| 7 |
+
checkpointing: all
|
| 8 |
+
pred_residuals: false
|
| 9 |
+
last_kernel_size: 3
|
| 10 |
+
pad: "circular"
|
| 11 |
+
|
| 12 |
+
unet:
|
| 13 |
+
ch_width: [280, 380, 480, 520]
|
| 14 |
+
dilation: [1, 2, 4, 8]
|
| 15 |
+
n_layers: [1, 1, 1, 1]
|
| 16 |
+
|
| 17 |
+
core_block:
|
| 18 |
+
block_type: "conv_next_block"
|
| 19 |
+
kernel_size: 3
|
| 20 |
+
activation: "capped_gelu"
|
| 21 |
+
upscale_factor: 2
|
| 22 |
+
norm: "batch"
|
| 23 |
+
|
| 24 |
+
down_sampling_block: "avg_pool"
|
| 25 |
+
up_sampling_block: "zonally_periodic_upsample"
|
samudra/configs/samudra_om4/train.yaml
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/TrainConfig.json
|
| 6 |
+
|
| 7 |
+
debug: false
|
| 8 |
+
disk_mode: true
|
| 9 |
+
pin_mem: true
|
| 10 |
+
save_freq: 5
|
| 11 |
+
epochs: 70
|
| 12 |
+
batch_size: 4
|
| 13 |
+
learning_rate: 0.0006
|
| 14 |
+
gradient_accumulation_steps: 1
|
| 15 |
+
scheduler: { type: cosine }
|
| 16 |
+
loss:
|
| 17 |
+
type: dynamic
|
| 18 |
+
metric: mse
|
| 19 |
+
finetune: false
|
| 20 |
+
resume_ckpt_path: null
|
| 21 |
+
inference_epochs: []
|
| 22 |
+
data_stride: [1]
|
| 23 |
+
steps: [4]
|
| 24 |
+
step_transition: []
|
| 25 |
+
preemptible: false
|
| 26 |
+
|
| 27 |
+
backend: auto
|
| 28 |
+
|
| 29 |
+
experiment:
|
| 30 |
+
name: samudra_om4
|
| 31 |
+
rand_seed: 15
|
| 32 |
+
base_output_dir: .LOCAL
|
| 33 |
+
wandb:
|
| 34 |
+
mode: disabled
|
| 35 |
+
project: default
|
| 36 |
+
data: !include ../data/om4.yaml
|
| 37 |
+
model: !include model.yaml
|
samudra/configs/samudra_om4/viz.yaml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/VizConfig.json
|
| 6 |
+
|
| 7 |
+
base_output_dir: ".LOCAL"
|
| 8 |
+
name: "samudra_om4_viz"
|
| 9 |
+
dataset_name: "OM4"
|
| 10 |
+
# Ground truth comes from this data source (same shape as train/eval), so
|
| 11 |
+
# `--data @data/om4_demo.yaml` points viz at the demo data just like the others.
|
| 12 |
+
data: !include ../data/om4.yaml
|
| 13 |
+
basins_location:
|
| 14 |
+
type: s3
|
| 15 |
+
anon: true
|
| 16 |
+
bucket: "m2lines-pubs"
|
| 17 |
+
path: "Samudra/basins/basin_masks_original.zarr"
|
| 18 |
+
endpoint_url: "https://nyu1.osn.mghpcc.org"
|
| 19 |
+
groundtruth_time_range:
|
| 20 |
+
start: "2014-10-20"
|
| 21 |
+
end: "2022-12-24"
|
| 22 |
+
runs:
|
| 23 |
+
- name: "samudra_om4_eval"
|
| 24 |
+
location: ".LOCAL/samudra_om4_eval/predictions.zarr"
|
| 25 |
+
observations: !include ../data/obs.yaml
|
samudra/configs/samudra_om4_v1/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!--
|
| 2 |
+
SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
|
| 4 |
+
SPDX-License-Identifier: CC-BY-4.0
|
| 5 |
+
-->
|
| 6 |
+
|
| 7 |
+
# Samudra OM4 V1
|
| 8 |
+
|
| 9 |
+
These configurations are our replication of the [original Samudra model](https://arxiv.org/abs/2412.03795)
|
| 10 |
+
trained on OM4 data.
|
samudra/configs/samudra_om4_v1/eval.yaml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/EvalConfig.json
|
| 6 |
+
|
| 7 |
+
debug: false
|
| 8 |
+
save_zarr: true
|
| 9 |
+
disk_mode: true
|
| 10 |
+
num_model_steps_forward: 25
|
| 11 |
+
|
| 12 |
+
experiment:
|
| 13 |
+
name: samudra_om4_v1_eval
|
| 14 |
+
rand_seed: 15
|
| 15 |
+
base_output_dir: .LOCAL
|
| 16 |
+
wandb:
|
| 17 |
+
mode: disabled
|
| 18 |
+
project: default
|
| 19 |
+
data: !include ../data/om4.yaml
|
| 20 |
+
model: !include model.yaml
|
samudra/configs/samudra_om4_v1/model.yaml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/SamudraConfig.json
|
| 6 |
+
|
| 7 |
+
pred_residuals: false
|
| 8 |
+
last_kernel_size: 3
|
| 9 |
+
pad: "circular"
|
| 10 |
+
checkpointing: "all"
|
| 11 |
+
|
| 12 |
+
unet:
|
| 13 |
+
ch_width: [200, 250, 300, 400]
|
| 14 |
+
dilation: [1, 2, 4, 8]
|
| 15 |
+
n_layers: [1, 1, 1, 1]
|
| 16 |
+
|
| 17 |
+
core_block:
|
| 18 |
+
block_type: "conv_next_block"
|
| 19 |
+
kernel_size: 3
|
| 20 |
+
activation: "capped_gelu"
|
| 21 |
+
upscale_factor: 4
|
| 22 |
+
norm: "batch"
|
| 23 |
+
|
| 24 |
+
down_sampling_block: "avg_pool"
|
| 25 |
+
up_sampling_block: "bilinear_upsample"
|
samudra/configs/samudra_om4_v1/train.yaml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/TrainConfig.json
|
| 6 |
+
|
| 7 |
+
debug: false
|
| 8 |
+
disk_mode: true
|
| 9 |
+
pin_mem: true
|
| 10 |
+
save_freq: 5
|
| 11 |
+
epochs: 70
|
| 12 |
+
batch_size: 4
|
| 13 |
+
learning_rate: 0.0006
|
| 14 |
+
scheduler: { type: cosine }
|
| 15 |
+
loss: mse
|
| 16 |
+
finetune: false
|
| 17 |
+
resume_ckpt_path: null
|
| 18 |
+
inference_epochs: []
|
| 19 |
+
data_stride: [1]
|
| 20 |
+
steps: [4]
|
| 21 |
+
step_transition: []
|
| 22 |
+
preemptible: false
|
| 23 |
+
|
| 24 |
+
backend: auto
|
| 25 |
+
|
| 26 |
+
experiment:
|
| 27 |
+
name: samudra_om4_v1
|
| 28 |
+
rand_seed: 15
|
| 29 |
+
base_output_dir: .LOCAL
|
| 30 |
+
wandb:
|
| 31 |
+
mode: disabled
|
| 32 |
+
project: default
|
| 33 |
+
data: !include ../data/om4.yaml
|
| 34 |
+
model: !include model.yaml
|
samudra/configs/samudra_om4_v1/viz.yaml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/VizConfig.json
|
| 6 |
+
|
| 7 |
+
base_output_dir: ".LOCAL"
|
| 8 |
+
name: "samudra_om4_v1_viz"
|
| 9 |
+
dataset_name: "OM4"
|
| 10 |
+
groundtruth_location: "OM4.zarr"
|
| 11 |
+
basins_location:
|
| 12 |
+
type: s3
|
| 13 |
+
anon: true
|
| 14 |
+
bucket: "m2lines-pubs"
|
| 15 |
+
path: "Samudra/basins/basin_masks_original.zarr"
|
| 16 |
+
endpoint_url: "https://nyu1.osn.mghpcc.org"
|
| 17 |
+
groundtruth_time_range:
|
| 18 |
+
start: "2014-10-20"
|
| 19 |
+
end: "2022-12-24"
|
| 20 |
+
runs:
|
| 21 |
+
- name: "samudra_om4_v1_eval"
|
| 22 |
+
location: ".LOCAL/samudra_om4_v1_eval/predictions.zarr"
|
samudra/configs/samudra_om4_v2/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!--
|
| 2 |
+
SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 3 |
+
|
| 4 |
+
SPDX-License-Identifier: CC-BY-4.0
|
| 5 |
+
-->
|
| 6 |
+
|
| 7 |
+
# Samudra OM4 V2
|
| 8 |
+
|
| 9 |
+
These configurations are the v2 version of Samudra-style (UNet) model,
|
| 10 |
+
trained on OM4 data. Major changes from the v1 model are that it is
|
| 11 |
+
wider and uses a dynamically-weighted loss.
|
samudra/configs/samudra_om4_v2/eval.yaml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/EvalConfig.json
|
| 6 |
+
|
| 7 |
+
debug: false
|
| 8 |
+
save_zarr: true
|
| 9 |
+
disk_mode: true
|
| 10 |
+
num_model_steps_forward: 25
|
| 11 |
+
|
| 12 |
+
experiment:
|
| 13 |
+
name: samudra_om4_v2_eval
|
| 14 |
+
rand_seed: 15
|
| 15 |
+
base_output_dir: .LOCAL
|
| 16 |
+
wandb:
|
| 17 |
+
mode: disabled
|
| 18 |
+
project: default
|
| 19 |
+
data: !include ../data/om4.yaml
|
| 20 |
+
model: !include model.yaml
|
samudra/configs/samudra_om4_v2/model.yaml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: 2026 Samudra Authors
|
| 2 |
+
#
|
| 3 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
|
| 5 |
+
# yaml-language-server: $schema=../schemas/SamudraConfig.json
|
| 6 |
+
|
| 7 |
+
checkpointing: all
|
| 8 |
+
pred_residuals: false
|
| 9 |
+
last_kernel_size: 3
|
| 10 |
+
pad: "circular"
|
| 11 |
+
|
| 12 |
+
unet:
|
| 13 |
+
ch_width: [280, 380, 480, 520]
|
| 14 |
+
dilation: [1, 2, 4, 8]
|
| 15 |
+
n_layers: [1, 1, 1, 1]
|
| 16 |
+
|
| 17 |
+
core_block:
|
| 18 |
+
block_type: "conv_next_block"
|
| 19 |
+
kernel_size: 3
|
| 20 |
+
activation: "capped_gelu"
|
| 21 |
+
upscale_factor: 2
|
| 22 |
+
norm: "batch"
|
| 23 |
+
|
| 24 |
+
down_sampling_block: "avg_pool"
|
| 25 |
+
up_sampling_block: "zonally_periodic_upsample"
|