Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
e4bebd7
check nan of pred in compute_masked_loss in utils
SarahAlidoost Sep 23, 2026
06643dc
change model to predict residuals
SarahAlidoost Sep 23, 2026
7f02ef9
improve decoder, add residual_scale to decoder
SarahAlidoost Sep 23, 2026
2c876b4
make dataloader shuffle false in predict
SarahAlidoost Sep 29, 2026
f2cc65b
fix model copy in train
SarahAlidoost Sep 29, 2026
df34eca
refactor train and predict
SarahAlidoost Oct 5, 2026
dababfb
replace l1_loss with mse to fix generalization
SarahAlidoost Oct 5, 2026
f3185ba
refactor the model
SarahAlidoost Oct 5, 2026
7a871a3
add decoder_residual_scale to tune
SarahAlidoost Oct 5, 2026
0686884
fix scripts accordingly
SarahAlidoost Oct 5, 2026
1a6810b
add optimizer_weight_decay to tune
SarahAlidoost Oct 5, 2026
10d3ffe
fix scripts
SarahAlidoost Oct 5, 2026
16ae16e
set calculate_residuals False in data preparation
SarahAlidoost Oct 5, 2026
fd03fda
initialize the conv head in the model
SarahAlidoost Oct 7, 2026
1517769
normalize residuals before loss calculation
SarahAlidoost Oct 7, 2026
b549d90
add res_std to training script
SarahAlidoost Oct 7, 2026
0e07312
fix training script
SarahAlidoost Oct 7, 2026
94f129b
fix the decoder
SarahAlidoost Oct 12, 2026
0a61d9f
fix best_loss in train module
SarahAlidoost Oct 12, 2026
c6e4602
fix loss averaging in train and predict
SarahAlidoost Oct 20, 2026
995ff0b
fix saving checkpoint in train
SarahAlidoost Oct 20, 2026
70d3418
refactor compute_masked_loss function, add function compute_anom_sacl…
SarahAlidoost Oct 27, 2026
a955181
add tests for utils
SarahAlidoost Oct 27, 2026
461fe95
remove geo embedings from dataset, add data augmentation to dataset, …
SarahAlidoost Oct 27, 2026
a07a2fe
fix loss calculation in predict module
SarahAlidoost Oct 27, 2026
a8446f6
fix loss calculation in train module
SarahAlidoost Oct 27, 2026
ebc0446
remove GeoPositionScaleEmbedding and improve TemporalAttentionAggrega…
SarahAlidoost Oct 27, 2026
59d3b0b
add spatial context to the model
SarahAlidoost Oct 27, 2026
95e48f1
fix load_model in utils
SarahAlidoost Oct 27, 2026
9874146
fix init file
SarahAlidoost Nov 6, 2026
f620246
add train_scale to the prediction
SarahAlidoost Nov 6, 2026
b9cc0e8
update tune module
SarahAlidoost Nov 6, 2026
9494e66
remove unused import
SarahAlidoost Nov 6, 2026
8fa6261
run ruff
SarahAlidoost Nov 6, 2026
0e505cb
fix tune restore code
SarahAlidoost Nov 9, 2026
f0f4543
fix docstrings
SarahAlidoost Nov 9, 2026
b6c7fbb
update code math description
SarahAlidoost Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions climanet/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from climanet import st_encoder_decoder
from climanet.dataset import STDataset
from climanet.st_encoder_decoder import SpatioTemporalModel
from climanet.utils import regrid_to_boundary_centered_grid

__all__ = ["STDataset", "regrid_to_boundary_centered_grid", "st_encoder_decoder"]
__all__ = [
"STDataset",
"SpatioTemporalModel",
"regrid_to_boundary_centered_grid",
]
150 changes: 81 additions & 69 deletions climanet/dataset.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import warnings
from dataclasses import dataclass

import dask
import numpy as np
import torch
import xarray as xr
from torch.utils.data import Dataset

from .geo_embedding_utils import (
calculate_sh_geo_pos_embeddings,
compute_patch_geo_pos_embedding,
compute_patch_scale_features,
)


@dataclass
class DataLoaderConfig:
Expand Down Expand Up @@ -49,10 +44,13 @@ def __init__(
spatial_dims: tuple[str, str] = ("lat", "lon"),
crop_size: tuple[int, int, int] = (1, 16, 16), # (Month, lat, lon)
stride: tuple[int, int] = None,
sh_embed_dim: int = 96, # sh_embed_dim should <= (sh_order_L + 1)**2
sh_order_L: int = 10,
verbose: bool = False,
load_lazy: bool = False,
augment: bool = False,
p_aug: float = 0.5,
drop_range=(0.1, 0.7),
min_obs=6,
target_scale=1.0,
):
"""Initialize the dataset with daily and monthly data, and optional land mask.

Expand All @@ -71,12 +69,20 @@ def __init__(
reshaped internally to have a month dimension, and the data crops are
extracted accordingly.
stride: Tuple of (stride_height, stride_width) in pixels. If None, defaults to crop_size (non-overlapping data crops).
sh_pos_table: Optional path to precomputed spherical harmonics position embeddings.
sh_embed_dim: Dimension of the spherical harmonics embedding.
sh_order_L: Order of the spherical harmonics.
verbose: If True, print dataset creation details.
load_lazy: If True, use data lazily with zarr backend. This may slow down getitem but saves memory.
augment: If True, apply data augmentation during training.
p_aug: Probability of applying augmentation.
drop_range: Tuple specifying the range of values to drop for augmentation.
min_obs: Minimum number of observations required for a valid crop.
target_scale: Scaling factor for the target variable.
"""
self.augment = augment
self.p_aug = p_aug
self.drop_range = drop_range
self.min_obs = min_obs
self.target_scale = target_scale

self.spatial_dims = spatial_dims
self.crop_size = crop_size
self.input_da = input_da
Expand All @@ -89,8 +95,6 @@ def __init__(

self.stride = stride if stride is not None else (crop_size[1], crop_size[2])

self.sh_embed_dim = sh_embed_dim
self.sh_order_L = sh_order_L
self.verbose = verbose
self.load_lazy = load_lazy

Expand Down Expand Up @@ -145,12 +149,6 @@ def __init__(
M, H, W = self.input_da.shape[0], self.input_da.shape[2], self.input_da.shape[3]
self.crop_indices = self._compute_crop_indices(M, H, W)

self.sh_embed_dim_t = torch.tensor(self.sh_embed_dim)
self.harmonic_order_t = torch.tensor(self.sh_order_L)

self.geo_pos_t = calculate_sh_geo_pos_embeddings(
self.lat_coords, self.lon_coords, self.sh_order_L, self.sh_embed_dim
)

def _compute_crop_indices(self, M: int, H: int, W: int) -> list:
"""Generate crop start indices with coverage warning (overlap support)."""
Expand Down Expand Up @@ -229,6 +227,14 @@ def __len__(self):
return len(self.crop_indices)

def __getitem__(self, idx):
"""Use dask to synchronously get the item.

This ensures that any lazy computations are executed immediately.
"""
with dask.config.set(scheduler="synchronous"):
return self._getitem_impl(idx)

def _getitem_impl(self, idx):
"""Get a spatiotemporal crop sample based on the index."""

if idx < 0 or idx >= len(self.crop_indices):
Expand Down Expand Up @@ -314,15 +320,16 @@ def __getitem__(self, idx):
daily_mask_t_crop = daily_nan_mask_t_crop & (
~land_t_crop.unsqueeze(0).unsqueeze(0).unsqueeze(0)
)
if self.augment and torch.rand(1).item() < self.p_aug:
m_new, monthly_t_crop = self._apply_gap_aug(
daily_t_crop[0], daily_mask_t_crop[0], land_t_crop, monthly_t_crop
)
daily_mask_t_crop = m_new.unsqueeze(0)

# Extract lat/lon coordinates for this crop
lat_crop = self.lat_coords[i : i + ph] # (H,) -> (pH,)
lon_crop = self.lon_coords[j : j + pw] # (W,) -> (pW,)

# compute geo_pos_embedding and scale_feature for each model patch within the dataset crop
geo_pos_embedding_t = self._compute_patch_geo_pos_embedding(i, j) # (Hp*Wp, sh_embed_dim)
scale_feature_t = self._compute_patch_scale_features(i, j) # (Hp*Wp, scale_dim)

# Convert to dictionary
return {
"input_data": daily_t_crop, # (C=1, pm, T=31, pH, pW)
Expand All @@ -331,10 +338,6 @@ def __getitem__(self, idx):
"land_mask": land_t_crop, # (pH,pW) True=Land
"input_data_timef": daily_timef_crop, # (pm, T=31, 3)
"padded_days_mask": padded_days_mask_crop, # (pm, T=31) True=padded
"scale_feature": scale_feature_t, # (Hp*Wp, scale_dim)
"geo_pos_embedding": geo_pos_embedding_t, # (Hp*Wp, sh_embed_dim)
"sh_embed_dim": self.sh_embed_dim_t,
"harmonic_order": self.harmonic_order_t,
"coords": torch.tensor([m, i, j]),
"lat": lat_crop, # (pH,)
"lon": lon_crop, # (pW,)
Expand All @@ -343,48 +346,57 @@ def __getitem__(self, idx):
def __getitems__(self, indices):
return [self.__getitem__(i) for i in indices]

def _compute_patch_geo_pos_embedding(self, i: int, j: int):
_, ph, pw = self.crop_size
_, model_patch_h, model_patch_w = self.model_patch_size
Hp = ph // model_patch_h
Wp = pw // model_patch_w

geo_pos_embeddings_list = []
for hi in range(Hp):
for wi in range(Wp):
# Geo embedding for this model patch within the dataset patch
h_start = i + hi * model_patch_h
h_end = h_start + model_patch_h
w_start = j + wi * model_patch_w
w_end = w_start + model_patch_w

patch_geo = compute_patch_geo_pos_embedding(
self.geo_pos_t[h_start:h_end, w_start:w_end],
self.lat_coords[h_start:h_end],
def _extra_gaps(self, M, T, H, W):
"""Return bool tensor (M,T,H,W), True = additionally hide."""
nd = T // 24 # number of days in the input data

# Sample a random drop probability within the specified range
drop_prob = torch.empty(1).uniform_(*self.drop_range).item()

extra = torch.zeros(M, T, H, W, dtype=torch.bool)
modes = torch.randperm(3)[: torch.randint(1, 3, (1,)).item()] # 1-2 modes
for mode in modes.tolist():
if mode == 0: # independent hours (weak, but cheap)
extra |= torch.rand(M, T, H, W) < drop_prob * 0.5
elif mode == 1: # spatially coherent whole-day outages (cloud-like)
rand_mask = torch.rand(M * nd, 1, max(H // 8, 2), max(W // 8, 2))
rand_mask = torch.nn.functional.interpolate(
rand_mask, size=(H, W), mode="bilinear", align_corners=False
)
geo_pos_embeddings_list.append(patch_geo)
day = (rand_mask < torch.quantile(rand_mask, drop_prob)).view(M, nd, H, W)
extra |= day.repeat_interleave(24, dim=1)
else: # drop a set of hours-of-day (diurnal sampling bias)
nbins = torch.randint(4, 13, (1,)).item()
bins = torch.zeros(24, dtype=torch.bool)
bins[torch.randperm(24)[:nbins]] = True
extra |= bins.repeat(nd).view(1, T, 1, 1).expand(M, T, H, W)
return extra

def _apply_gap_aug(self, x, mask, land, target):
"""Apply gap augmentation to the input data.

# Shape: (Hp*Wp, sh_embed_dim)
return torch.stack(geo_pos_embeddings_list)

def _compute_patch_scale_features(self, i: int, j: int):
_, ph, pw = self.crop_size
_, model_patch_h, model_patch_w = self.model_patch_size
Hp = ph // model_patch_h
Wp = pw // model_patch_w

scale_features_list = []
for hi in range(Hp):
for wi in range(Wp):
h_start = i + hi * model_patch_h
h_end = h_start + model_patch_h
w_start = j + wi * model_patch_w
w_end = w_start + model_patch_w

patch_scale = compute_patch_scale_features(
self.lat_coords[h_start:h_end],
self.lon_coords[w_start:w_end],
)
scale_features_list.append(patch_scale)
Args:
x (torch.Tensor): Input data of shape (M, T, H, W).
mask (torch.Tensor): Boolean mask indicating missing values.
land (torch.Tensor): Boolean mask indicating land areas.
target (torch.Tensor): Target data of shape (M, H, W) already normalized.

return torch.stack(scale_features_list) # (Hp*Wp, scale_dim)
Returns:
tuple: Updated mask and new target after applying gap augmentation.
"""
# x, mask: (M,T,H,W); land: (H,W); target: (M,H,W) already normalized
M, T, H, W = x.shape
ocean = ~land
obs = ~mask
mean0 = (x * obs).sum(1) / obs.sum(1).clamp(min=1)
anom = (x - mean0[:, None]) * obs

extra = self._extra_gaps(M, T, H, W) & ocean & obs
keep = obs & ~extra
cnt = keep.sum(1)

delta = -(anom * keep).sum(1) / cnt.clamp(min=1)
new_target = target + delta / self.target_scale
new_target = torch.where(cnt >= self.min_obs, new_target,
torch.full_like(new_target, float("nan")))
return mask | extra, new_target
46 changes: 14 additions & 32 deletions climanet/predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

from climanet.dataset import DataLoaderConfig
from climanet.utils import (
compute_masked_loss,
load_model,
run_one_batch,
setup_logging,
)

Expand Down Expand Up @@ -74,28 +74,6 @@ def _save_netcdf(
ds_pred.sel(time=[t]).to_netcdf(file_name)


def _move_batch_to_device(batch: dict, device: str):
use_cuda = device == "cuda"
return {k: v.to(device, non_blocking=use_cuda) for k, v in batch.items()}


def _run_one_batch(model: torch.nn.Module, batch: dict, device: str):
batch = _move_batch_to_device(batch, device)
pred = model(
batch["input_data"],
batch["input_data_mask"],
batch["input_data_timef"],
batch["land_mask"],
batch["geo_pos_embedding"],
batch["scale_feature"],
batch["padded_days_mask"],
) # (B, M, H, W)

# Compute masked loss
loss = compute_masked_loss(pred, batch["monthly_data"], batch["land_mask"])
return loss, pred


def predict_monthly_var(
model: torch.nn.Module | str,
dataset: Dataset,
Expand All @@ -121,6 +99,11 @@ def predict_monthly_var(
if isinstance(model, str | Path):
model = load_model(model, device)

model = model.module if hasattr(model, "module") else model

# Standard deviation used for target normalization during training
target_scale = float(model.config["target_scale"])

model.to(device)
model.eval()

Expand All @@ -132,7 +115,7 @@ def predict_monthly_var(
dataloader = DataLoader(
dataset,
batch_size=dataloader_config.batch_size,
shuffle=dataloader_config.shuffle,
shuffle=False, # no shuffling during prediction/validation
pin_memory=use_cuda,
num_workers=dataloader_config.num_workers, # for data loading
persistent_workers=dataloader_config.persistent_workers, # keep workers alive between epochs
Expand All @@ -150,26 +133,25 @@ def predict_monthly_var(
M, H, W = base_dataset.crop_size
all_predictions = torch.empty(len(dataset), M, H, W, device=device)

# Set up logging
if prediction_config.store_logs:
writer = setup_logging(run_dir)

with torch.inference_mode():
idx = 0
average_loss = 0.0
total_num = 0.0
total_denom = 0
for i, batch in enumerate(dataloader):
loss, predictions = _run_one_batch(model, batch, device)
average_loss += loss.detach()
loss, num, denom, predictions = run_one_batch(model, batch, device)
total_num += num.detach()
total_denom += denom.detach()

all_predictions[idx : idx + predictions.size(0)] = predictions.detach()
all_predictions[idx : idx + predictions.size(0)] = predictions.detach() * target_scale
idx += predictions.size(0)

if prediction_config.verbose:
print(
f"Processed batch {i + 1}/{num_batches}, with loss: {loss.item():.4f}"
)

average_loss = average_loss.item() / num_batches
average_loss = (total_num / total_denom.clamp_min(1)).item()

if prediction_config.verbose:
print(f"Average loss over all batches: {average_loss:.4f}")
Expand Down
Loading
Loading