| """ClimODE's official global neural transport model. |
| |
| The equations and layer layout follow Aalto-QuML/ClimODE. The small wrapper |
| at the bottom adds configuration-friendly construction and checkpoint loading; |
| the core ``ClimateEncoderFreeUncertain`` class intentionally keeps the |
| official state and tensor layout. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import importlib |
| from pathlib import Path |
| import sys |
| from typing import Any, Sequence |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
| try: |
| from torchdiffeq import odeint as _torchdiffeq_odeint |
| except ImportError: |
| _torchdiffeq_odeint = None |
|
|
|
|
| def _euler_odeint(func, y0: torch.Tensor, t: torch.Tensor) -> torch.Tensor: |
| """Equivalent fixed-step Euler solver used only when torchdiffeq is absent.""" |
|
|
| states = [y0] |
| for index in range(1, len(t)): |
| previous = states[-1] |
| dt = t[index] - t[index - 1] |
| states.append(previous + dt * func(t[index - 1], previous)) |
| return torch.stack(states, dim=0) |
|
|
|
|
| def odeint(func, y0: torch.Tensor, t: torch.Tensor, method: str = "euler", **kwargs): |
| if _torchdiffeq_odeint is not None: |
| return _torchdiffeq_odeint(func, y0, t, method=method, **kwargs) |
| if method != "euler": |
| raise ImportError( |
| "torchdiffeq is required for solver=%r; install the official dependency." |
| % method |
| ) |
| return _euler_odeint(func, y0, t) |
|
|
|
|
| class OptimVelocity(nn.Module): |
| """Learn the initial per-channel velocity used to start the ODE system.""" |
|
|
| def __init__(self, num_years: int, height: int, width: int, out_channels: int = 5): |
| super().__init__() |
| self.out_channels = out_channels |
| self.v_x = nn.Parameter( |
| torch.randn(num_years, 1, out_channels, height, width) |
| ) |
| self.v_y = nn.Parameter( |
| torch.randn(num_years, 1, out_channels, height, width) |
| ) |
|
|
| def forward(self, data: torch.Tensor): |
| u_y = torch.gradient(data, dim=3)[0] |
| u_x = torch.gradient(data, dim=4)[0] |
| divergence = torch.gradient(self.v_y, dim=3)[0] + torch.gradient( |
| self.v_x, dim=4 |
| )[0] |
| adv = self.v_x * u_x + self.v_y * u_y + data * divergence |
| return adv, self.v_x, self.v_y |
|
|
|
|
| class BoundaryPad(nn.Module): |
| """Reflect at the poles and wrap around the longitude seam.""" |
|
|
| def forward(self, value: torch.Tensor) -> torch.Tensor: |
| return F.pad(F.pad(value, (0, 0, 1, 1), "reflect"), (1, 1, 0, 0), "circular") |
|
|
|
|
| class ResidualBlock(nn.Module): |
| def __init__(self, in_channels: int, out_channels: int): |
| super().__init__() |
| self.activation = nn.LeakyReLU(0.3) |
| self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=0) |
| self.bn1 = nn.BatchNorm2d(out_channels) |
| self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=0) |
| self.bn2 = nn.BatchNorm2d(out_channels) |
| self.drop = nn.Dropout(p=0.1) |
| self.shortcut = ( |
| nn.Conv2d(in_channels, out_channels, kernel_size=1) |
| if in_channels != out_channels |
| else nn.Identity() |
| ) |
|
|
| def forward(self, value: torch.Tensor) -> torch.Tensor: |
| value_padded = F.pad( |
| F.pad(value, (0, 0, 1, 1), "reflect"), (1, 1, 0, 0), "circular" |
| ) |
| hidden = self.activation(self.bn1(self.conv1(value_padded))) |
| hidden = F.pad( |
| F.pad(hidden, (0, 0, 1, 1), "reflect"), (1, 1, 0, 0), "circular" |
| ) |
| hidden = self.activation(self.bn2(self.conv2(hidden))) |
| return self.drop(hidden) + self.shortcut(value) |
|
|
|
|
| class ClimateResNet2D(nn.Module): |
| def __init__( |
| self, |
| num_channels: int, |
| layers: Sequence[int], |
| hidden_size: Sequence[int], |
| ): |
| super().__init__() |
| if len(layers) != len(hidden_size): |
| raise ValueError("layers and hidden_size must have equal lengths") |
| self.layer_cnn = nn.ModuleList( |
| [nn.Sequential(*blocks_for_layer) for blocks_for_layer in self._split_blocks(layers, hidden_size, num_channels)] |
| ) |
|
|
| @staticmethod |
| def _split_blocks( |
| layers: Sequence[int], hidden_size: Sequence[int], num_channels: int |
| ) -> list[list[nn.Module]]: |
| groups: list[list[nn.Module]] = [] |
| in_channels = num_channels |
| for repetitions, out_channels in zip(layers, hidden_size): |
| group = [ResidualBlock(in_channels, out_channels)] |
| group.extend( |
| ResidualBlock(out_channels, out_channels) |
| for _ in range(1, int(repetitions)) |
| ) |
| groups.append(group) |
| in_channels = out_channels |
| return groups |
|
|
| def forward(self, value: torch.Tensor) -> torch.Tensor: |
| output = value.float() |
| for layer in self.layer_cnn: |
| output = layer(output) |
| return output |
|
|
|
|
| class SelfAttnConv(nn.Module): |
| """Official key-query-value attention convolution.""" |
|
|
| def __init__(self, in_channels: int, out_channels: int): |
| super().__init__() |
| self.query = self._conv(in_channels, in_channels // 8, stride=1) |
| self.key = self._key_conv(in_channels, in_channels // 8, stride=2) |
| self.value = self._key_conv(in_channels, out_channels, stride=2) |
| self.post_map = nn.Sequential( |
| nn.Conv2d(out_channels, out_channels, kernel_size=1, stride=1, padding=0) |
| ) |
| self.out_ch = out_channels |
|
|
| @staticmethod |
| def _conv(n_in: int, n_out: int, stride: int) -> nn.Sequential: |
| return nn.Sequential( |
| BoundaryPad(), |
| nn.Conv2d(n_in, n_in // 2, kernel_size=3, stride=stride, padding=0), |
| nn.LeakyReLU(0.3), |
| BoundaryPad(), |
| nn.Conv2d(n_in // 2, n_out, kernel_size=3, stride=stride, padding=0), |
| nn.LeakyReLU(0.3), |
| BoundaryPad(), |
| nn.Conv2d(n_out, n_out, kernel_size=3, stride=stride, padding=0), |
| ) |
|
|
| @staticmethod |
| def _key_conv(n_in: int, n_out: int, stride: int) -> nn.Sequential: |
| return nn.Sequential( |
| nn.Conv2d(n_in, n_in // 2, kernel_size=3, stride=stride, padding=0), |
| nn.LeakyReLU(0.3), |
| nn.Conv2d(n_in // 2, n_out, kernel_size=3, stride=stride, padding=0), |
| nn.LeakyReLU(0.3), |
| nn.Conv2d(n_out, n_out, kernel_size=3, stride=1, padding=0), |
| ) |
|
|
| def forward(self, value: torch.Tensor) -> torch.Tensor: |
| size = value.size() |
| value = value.float() |
| query = self.query(value).flatten(-2, -1) |
| key = self.key(value).flatten(-2, -1) |
| val = self.value(value).flatten(-2, -1) |
| beta = F.softmax(torch.bmm(query.transpose(1, 2), key), dim=1) |
| output = torch.bmm(val, beta.transpose(1, 2)) |
| output = output.view(-1, self.out_ch, size[-2], size[-1]).contiguous() |
| return self.post_map(output) |
|
|
|
|
| class ClimateEncoderFreeUncertain(nn.Module): |
| """Official global ``Climate_encoder_free_uncertain`` implementation.""" |
|
|
| def __init__( |
| self, |
| num_channels: int = 5, |
| const_channels: int = 2, |
| out_types: int = 5, |
| method: str = "euler", |
| use_att: bool = True, |
| use_err: bool = True, |
| use_pos: bool = False, |
| ): |
| super().__init__() |
| self.layers = [5, 3, 2] |
| self.hidden = [128, 64, 2 * out_types] |
| input_channels = 30 + out_types * int(use_pos) + 34 * (1 - int(use_pos)) |
| self.vel_f = ClimateResNet2D(input_channels, self.layers, self.hidden) |
| if use_att: |
| self.vel_att = SelfAttnConv(input_channels, 10) |
| self.gamma = nn.Parameter(torch.tensor([0.1])) |
|
|
| self.scales = num_channels |
| self.const_channel = const_channels |
| self.out_ch = out_types |
| self.past_samples: torch.Tensor | int = 0 |
| self.const_info: torch.Tensor | int = 0 |
| self.lat_map: torch.Tensor | int = 0 |
| self.lon_map: torch.Tensor | int = 0 |
| self.method = method |
| err_in = 9 + out_types * int(use_pos) + 34 * (1 - int(use_pos)) |
| if use_err: |
| self.noise_net = ClimateResNet2D(err_in, [3, 2, 2], [128, 64, 2 * out_types]) |
| if use_pos: |
| self.pos_enc = ClimateResNet2D(4, [2, 1, 1], [32, 16, out_types]) |
| self.att = use_att |
| self.err = use_err |
| self.pos = use_pos |
| self.pos_feat: torch.Tensor | int = 0 |
| self.lsm: torch.Tensor | int = 0 |
| self.oro: torch.Tensor | int = 0 |
|
|
| def update_param(self, params: Sequence[torch.Tensor]) -> None: |
| if len(params) != 4: |
| raise ValueError("update_param expects past_samples, constants, latitude, longitude") |
| self.past_samples, self.const_info, self.lat_map, self.lon_map = params |
|
|
| def _time_features( |
| self, t: torch.Tensor, height: int, width: int, batch_size: int |
| ) -> tuple[torch.Tensor, ...]: |
| t_emb = ((t * 100) % 24).view(1, 1, 1, 1).expand(batch_size, 1, height, width) |
| sin_t = torch.sin(torch.pi * t_emb / 12 - torch.pi / 2) |
| cos_t = torch.cos(torch.pi * t_emb / 12 - torch.pi / 2) |
| sin_season = torch.sin(torch.pi * t_emb / (12 * 365) - torch.pi / 2) |
| cos_season = torch.cos(torch.pi * t_emb / (12 * 365) - torch.pi / 2) |
| return t_emb, torch.cat([sin_t, cos_t], dim=1), torch.cat( |
| [sin_season, cos_season], dim=1 |
| ) |
|
|
| def pde(self, t: torch.Tensor, state: torch.Tensor) -> torch.Tensor: |
| height, width = state.shape[-2:] |
| ds = state[:, -self.out_ch :].view(-1, self.out_ch, height, width).float() |
| velocity = state[:, : 2 * self.out_ch].view( |
| -1, 2 * self.out_ch, height, width |
| ).float() |
| t_emb, day_emb, season_emb = self._time_features( |
| t, height, width, ds.shape[0] |
| ) |
| ds_grad_x = torch.gradient(ds, dim=3)[0] |
| ds_grad_y = torch.gradient(ds, dim=2)[0] |
| nabla_u = torch.cat([ds_grad_x, ds_grad_y], dim=1) |
|
|
| if self.pos: |
| combined = torch.cat( |
| [t_emb / 24, day_emb, season_emb, nabla_u, velocity, ds, self.pos_feat], |
| dim=1, |
| ) |
| else: |
| cos_lat, sin_lat = torch.cos(self.new_lat_map), torch.sin(self.new_lat_map) |
| cos_lon, sin_lon = torch.cos(self.new_lon_map), torch.sin(self.new_lon_map) |
| time_cyclic = torch.cat([day_emb, season_emb], dim=1) |
| pos_feats = torch.cat( |
| [ |
| cos_lat, |
| cos_lon, |
| sin_lat, |
| sin_lon, |
| sin_lat * cos_lon, |
| sin_lat * sin_lon, |
| ], |
| dim=1, |
| ) |
| pos_time = self.get_time_pos_embedding(time_cyclic, pos_feats) |
| combined = torch.cat( |
| [ |
| t_emb / 24, |
| day_emb, |
| season_emb, |
| nabla_u, |
| velocity, |
| ds, |
| self.new_lat_map, |
| self.new_lon_map, |
| self.lsm, |
| self.oro, |
| pos_feats, |
| pos_time, |
| ], |
| dim=1, |
| ) |
|
|
| dv = self.vel_f(combined) |
| if self.att: |
| dv = dv + self.gamma * self.vel_att(combined) |
| v_x = velocity[:, : self.out_ch] |
| v_y = velocity[:, self.out_ch :] |
| advection = v_x * ds_grad_x + v_y * ds_grad_y |
| advection = advection + ds * ( |
| torch.gradient(v_x, dim=3)[0] + torch.gradient(v_y, dim=2)[0] |
| ) |
| return torch.cat([dv, advection], dim=1) |
|
|
| @staticmethod |
| def get_time_pos_embedding( |
| time_features: torch.Tensor, position_features: torch.Tensor |
| ) -> torch.Tensor: |
| outputs = [feature.unsqueeze(1) * position_features for feature in time_features.unbind(1)] |
| return torch.cat(outputs, dim=1) |
|
|
| def noise_net_contrib( |
| self, |
| time: torch.Tensor, |
| pos_enc: torch.Tensor, |
| s_final: torch.Tensor, |
| height: int, |
| width: int, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| t_emb = (time % 24).view(-1, 1, 1, 1, 1) |
| sin_t = torch.sin(torch.pi * t_emb / 12 - torch.pi / 2).expand( |
| len(s_final), s_final.shape[1], 1, height, width |
| ) |
| cos_t = torch.cos(torch.pi * t_emb / 12 - torch.pi / 2).expand( |
| len(s_final), s_final.shape[1], 1, height, width |
| ) |
| sin_season = torch.sin(torch.pi * t_emb / (12 * 365) - torch.pi / 2).expand( |
| len(s_final), s_final.shape[1], 1, height, width |
| ) |
| cos_season = torch.cos(torch.pi * t_emb / (12 * 365) - torch.pi / 2).expand( |
| len(s_final), s_final.shape[1], 1, height, width |
| ) |
| pos_rep = pos_enc.expand(len(s_final), s_final.shape[1], -1, height, width) |
| pos_rep = pos_rep.flatten(start_dim=0, end_dim=1) |
| time_cyclic = torch.cat([sin_t, cos_t, sin_season, cos_season], dim=2) |
| time_cyclic = time_cyclic.flatten(start_dim=0, end_dim=1) |
| pos_time = self.get_time_pos_embedding(time_cyclic, pos_rep[:, 2:-2]) |
| combined = torch.cat( |
| [time_cyclic, s_final.flatten(start_dim=0, end_dim=1), pos_rep, pos_time], |
| dim=1, |
| ) |
| final_out = self.noise_net(combined).view( |
| len(time), -1, 2 * self.out_ch, height, width |
| ) |
| mean = s_final + final_out[:, :, : self.out_ch] |
| std = F.softplus(final_out[:, :, self.out_ch :]) |
| return mean, std |
|
|
| def forward( |
| self, |
| time_steps: torch.Tensor, |
| data: torch.Tensor, |
| atol: float = 0.1, |
| rtol: float = 0.1, |
| ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: |
| if not isinstance(self.past_samples, torch.Tensor): |
| raise RuntimeError("Call update_param before forward") |
| height, width = self.past_samples.shape[-2:] |
| values = data.float().view(-1, self.out_ch, height, width) |
| final_data = torch.cat([self.past_samples, values], dim=1) |
| init_time = time_steps[0].item() * 6 |
| final_time = time_steps[-1].item() * 6 |
| steps_val = final_time - init_time |
|
|
| if self.pos: |
| lat_map = self.lat_map.unsqueeze(0) * torch.pi / 180 |
| lon_map = self.lon_map.unsqueeze(0) * torch.pi / 180 |
| pos_rep = torch.cat([lat_map.unsqueeze(0), lon_map.unsqueeze(0), self.const_info], dim=1) |
| self.pos_feat = self.pos_enc(pos_rep).expand( |
| values.shape[0], -1, values.shape[-2], values.shape[-1] |
| ) |
| final_pos_enc = self.pos_feat |
| else: |
| self.oro = self.const_info[0, 0] |
| self.lsm = self.const_info[0, 1] |
| self.lsm = self.lsm.unsqueeze(0).expand(values.shape[0], -1, height, width) |
| self.oro = F.normalize(self.oro).unsqueeze(0).expand(values.shape[0], -1, height, width) |
| self.new_lat_map = self.lat_map.expand(values.shape[0], 1, height, width) * torch.pi / 180 |
| self.new_lon_map = self.lon_map.expand(values.shape[0], 1, height, width) * torch.pi / 180 |
| cos_lat, sin_lat = torch.cos(self.new_lat_map), torch.sin(self.new_lat_map) |
| cos_lon, sin_lon = torch.cos(self.new_lon_map), torch.sin(self.new_lon_map) |
| pos_feats = torch.cat( |
| [cos_lat, cos_lon, sin_lat, sin_lon, sin_lat * cos_lon, sin_lat * sin_lon], |
| dim=1, |
| ) |
| final_pos_enc = torch.cat( |
| [self.new_lat_map, self.new_lon_map, pos_feats, self.lsm, self.oro], dim=1 |
| ) |
|
|
| integration_steps = max(int(steps_val) + 1, 1) |
| new_time_steps = torch.linspace( |
| init_time, final_time, steps=integration_steps, device=values.device |
| ) |
| ode_time = 0.01 * new_time_steps.float() |
| final_result = odeint( |
| self.pde, |
| final_data, |
| ode_time, |
| method=self.method, |
| atol=atol, |
| rtol=rtol, |
| ) |
| s_final = final_result[:, :, -self.out_ch :].view( |
| len(ode_time), -1, self.out_ch, height, width |
| ) |
| sampled = s_final[0 : len(s_final) : 6] |
| if self.err: |
| mean, std = self.noise_net_contrib( |
| time_steps, final_pos_enc, sampled, height, width |
| ) |
| return mean, std, sampled |
| return sampled, torch.zeros_like(sampled), sampled |
|
|
|
|
| class ClimODE(ClimateEncoderFreeUncertain): |
| """Configuration-friendly public model name.""" |
|
|
| def __init__( |
| self, |
| num_channels: int = 5, |
| const_channels: int = 2, |
| out_types: int = 5, |
| method: str = "euler", |
| use_attention: bool = True, |
| use_uncertainty: bool = True, |
| use_positional_encoder: bool = False, |
| ): |
| super().__init__( |
| num_channels=num_channels, |
| const_channels=const_channels, |
| out_types=out_types, |
| method=method, |
| use_att=use_attention, |
| use_err=use_uncertainty, |
| use_pos=use_positional_encoder, |
| ) |
|
|
|
|
| def _register_checkpoint_compat_modules() -> None: |
| """Expose legacy top-level module names used by official pickle files.""" |
|
|
| for legacy_name, current_name in ( |
| ("model_function", "model.model_function"), |
| ("model_utils", "model.model_utils"), |
| ): |
| module = importlib.import_module(current_name) |
| sys.modules.setdefault(legacy_name, module) |
|
|
|
|
| def load_checkpoint(path: str | Path, map_location: str | torch.device = "cpu") -> nn.Module: |
| """Load an official full-object checkpoint or a state-dict checkpoint.""" |
|
|
| checkpoint_path = Path(path) |
| if not checkpoint_path.is_file(): |
| raise FileNotFoundError(checkpoint_path) |
| _register_checkpoint_compat_modules() |
| try: |
| checkpoint = torch.load(checkpoint_path, map_location=map_location, weights_only=False) |
| except TypeError: |
| checkpoint = torch.load(checkpoint_path, map_location=map_location) |
| if isinstance(checkpoint, nn.Module): |
| return checkpoint |
| model = ClimODE() |
| if isinstance(checkpoint, dict): |
| state_dict = checkpoint.get("state_dict", checkpoint.get("model", checkpoint)) |
| else: |
| state_dict = checkpoint |
| model.load_state_dict(state_dict) |
| return model |
|
|
|
|
| |
| Climate_encoder_free_uncertain = ClimateEncoderFreeUncertain |
| Climate_ResNet_2D = ClimateResNet2D |
| Self_attn_conv = SelfAttnConv |
| boundarypad = BoundaryPad |
|
|