| """ |
| Stormer: A Transformer-based Global Weather Forecasting Model. |
| |
| Reference: |
| - "Stormer: A Transformer-based Data-driven Model for Global Weather Forecasting" |
| - Official repo: https://github.com/microsoft/stormer |
| |
| This implementation: |
| - Removes dependency on xformers (replaced with torch.nn.functional.scaled_dot_product_attention) |
| - Removes dependency on timm (PatchEmbed, Mlp, trunc_normal_ reimplemented) |
| - Compatible with onescience framework |
| - Follows official code logic and precision exactly |
| """ |
|
|
| import math |
| import numpy as np |
| from dataclasses import dataclass |
| from functools import lru_cache |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
| from onescience.models.meta import ModelMetaData |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
| CONSTANTS = [ |
| "anisotropy_of_sub_gridscale_orography", |
| "orography", |
| "land_sea_mask", |
| "slt", |
| "lattitude", |
| "longitude", |
| "angle_of_sub_gridscale_orography", |
| "geopotential_at_surface", |
| "high_vegetation_cover", |
| "lake_cover", |
| "lake_depth", |
| "low_vegetation_cover", |
| "slope_of_sub_gridscale_orography", |
| "soil_type", |
| "standard_deviation_of_filtered_subgrid_orography", |
| "standard_deviation_of_orography", |
| "type_of_high_vegetation", |
| "type_of_low_vegetation", |
| ] |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class MetaData(ModelMetaData): |
| name: str = "Stormer" |
| jit: bool = False |
| cuda_graphs: bool = True |
| amp: bool = True |
| amp_cpu: bool = None |
| amp_gpu: bool = None |
| onnx_cpu: bool = False |
| onnx_gpu: bool = True |
| onnx_runtime: bool = True |
| var_dim: int = 1 |
| func_torch: bool = False |
| auto_grad: bool = False |
|
|
|
|
| |
| |
| |
|
|
| def _trunc_normal_(tensor, mean=0., std=1., a=-2., b=2.): |
| """Truncated normal initialization (replaces timm's trunc_normal_). |
| |
| Operates on tensor.data to avoid issues with requires_grad=True parameters. |
| """ |
| def norm_cdf(x): |
| return (1. + math.erf(x / math.sqrt(2.))) / 2. |
|
|
| |
| t = tensor.data if hasattr(tensor, 'data') else tensor |
|
|
| if mean < a - 2 * std or mean > b + 2 * std: |
| import warnings |
| warnings.warn("mean is more than 2 std from [a, b] in trunc_normal_. " |
| "The distribution of values may be incorrect.", |
| stacklevel=2) |
|
|
| l = norm_cdf((a - mean) / std) |
| u = norm_cdf((b - mean) / std) |
|
|
| t.uniform_(2 * l - 1, 2 * u - 1) |
| t.erfinv_() |
| t.mul_(std * math.sqrt(2.)) |
| t.add_(mean) |
| t.clamp_(min=a, max=b) |
|
|
|
|
| def trunc_normal_(tensor, std=0.02): |
| """Drop-in replacement for timm's trunc_normal_.""" |
| _trunc_normal_(tensor, mean=0., std=std, a=-2., b=2.) |
|
|
|
|
| |
| |
| |
|
|
| class Mlp(nn.Module): |
| """MLP with GELU activation (replaces timm's Mlp).""" |
| def __init__(self, in_features, hidden_features=None, out_features=None, |
| act_layer=nn.GELU, drop=0.): |
| super().__init__() |
| out_features = out_features or in_features |
| hidden_features = hidden_features or in_features |
| self.fc1 = nn.Linear(in_features, hidden_features) |
| self.act = act_layer() |
| self.fc2 = nn.Linear(hidden_features, out_features) |
| self.drop = nn.Dropout(drop) |
|
|
| def forward(self, x): |
| x = self.fc1(x) |
| x = self.act(x) |
| x = self.drop(x) |
| x = self.fc2(x) |
| x = self.drop(x) |
| return x |
|
|
|
|
| class PatchEmbed(nn.Module): |
| """2D Image to Patch Embedding (replaces timm's PatchEmbed). |
| |
| Splits image into patches and embeds each patch via Conv2d. |
| """ |
| def __init__(self, patch_size=2, in_chans=1, embed_dim=1024): |
| super().__init__() |
| self.patch_size = (patch_size, patch_size) if isinstance(patch_size, int) else patch_size |
| self.proj = nn.Conv2d(in_chans, embed_dim, |
| kernel_size=self.patch_size, stride=self.patch_size) |
| self.num_patches = None |
|
|
| def forward(self, x): |
| B, C, H, W = x.shape |
| x = self.proj(x) |
| x = x.flatten(2).transpose(1, 2) |
| return x |
|
|
|
|
| |
| |
| |
|
|
| def get_1d_sincos_pos_embed_from_grid(embed_dim, pos): |
| """1D sine-cosine position embedding from grid positions.""" |
| assert embed_dim % 2 == 0 |
| omega = np.arange(embed_dim // 2, dtype=float) |
| omega /= embed_dim / 2.0 |
| omega = 1.0 / 10000 ** omega |
|
|
| pos = pos.reshape(-1) |
| out = np.einsum("m,d->md", pos, omega) |
|
|
| emb_sin = np.sin(out) |
| emb_cos = np.cos(out) |
| emb = np.concatenate([emb_sin, emb_cos], axis=1) |
| return emb |
|
|
|
|
| def get_2d_sincos_pos_embed(embed_dim, grid_size_h, grid_size_w, cls_token=False): |
| """2D sine-cosine position embedding.""" |
| grid_h = np.arange(grid_size_h, dtype=np.float32) |
| grid_w = np.arange(grid_size_w, dtype=np.float32) |
| grid = np.meshgrid(grid_w, grid_h) |
| grid = np.stack(grid, axis=0) |
| grid = grid.reshape([2, 1, grid_size_h, grid_size_w]) |
|
|
| assert embed_dim % 2 == 0 |
| emb_h = get_1d_sincos_pos_embed_from_grid(embed_dim // 2, grid[0]) |
| emb_w = get_1d_sincos_pos_embed_from_grid(embed_dim // 2, grid[1]) |
| emb = np.concatenate([emb_h, emb_w], axis=1) |
|
|
| if cls_token: |
| emb = np.concatenate([np.zeros([1, embed_dim]), emb], axis=0) |
| return emb |
|
|
|
|
| |
| |
| |
|
|
| def _chunked_attention(q, k, v, scale, chunk_size=1024): |
| """Query-chunked scaled dot-product attention. |
| |
| Processes queries in chunks to limit peak memory to O(chunk_size × N) |
| instead of O(N × N). This works around: |
| - FlashAttention library not being available on DCU/HIP |
| - OOM from materializing the full N×N attention matrix |
| |
| Args: |
| q: (B, num_heads, N, head_dim) |
| k: (B, num_heads, N, head_dim) |
| v: (B, num_heads, N, head_dim) |
| scale: attention scale factor |
| chunk_size: number of query tokens per chunk |
| |
| Returns: |
| (B, num_heads, N, head_dim) |
| """ |
| B, H, N, D = q.shape |
| out = torch.empty_like(q) |
|
|
| for chunk_start in range(0, N, chunk_size): |
| chunk_end = min(chunk_start + chunk_size, N) |
| q_chunk = q[:, :, chunk_start:chunk_end] |
|
|
| |
| attn = torch.matmul(q_chunk, k.transpose(-2, -1)) * scale |
| attn = F.softmax(attn, dim=-1) |
|
|
| |
| out[:, :, chunk_start:chunk_end] = torch.matmul(attn, v) |
|
|
| return out |
|
|
|
|
| |
| |
| |
|
|
| def modulate(x, shift, scale): |
| """Adaptive layer norm modulation: x * (1 + scale) + shift.""" |
| return x * (1 + scale.unsqueeze(1)) + shift.unsqueeze(1) |
|
|
|
|
| class TimestepEmbedder(nn.Module): |
| """Embeds scalar timesteps (time intervals) into vector representations.""" |
| def __init__(self, hidden_size): |
| super().__init__() |
| self.mlp = nn.Linear(1, hidden_size) |
|
|
| def forward(self, t): |
| return self.mlp(t.unsqueeze(-1)) |
|
|
|
|
| |
| |
| |
|
|
| class MemEffAttention(nn.Module): |
| """Multi-head attention with memory-efficient chunked implementation. |
| |
| Uses query-chunked attention to avoid materializing the full N×N |
| attention matrix, working around both FlashAttention library |
| unavailability and OOM issues on memory-constrained hardware. |
| """ |
|
|
| def __init__( |
| self, |
| dim: int, |
| num_heads: int = 8, |
| qkv_bias: bool = False, |
| proj_bias: bool = True, |
| attn_drop: float = 0.0, |
| proj_drop: float = 0.0, |
| chunk_size: int = 1024, |
| ) -> None: |
| super().__init__() |
| self.num_heads = num_heads |
| head_dim = dim // num_heads |
| self.scale = head_dim ** -0.5 |
| self.chunk_size = chunk_size |
|
|
| self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias) |
| self.attn_drop = nn.Dropout(attn_drop) |
| self.proj = nn.Linear(dim, dim, bias=proj_bias) |
| self.proj_drop = nn.Dropout(proj_drop) |
|
|
| def forward(self, x, attn_bias=None): |
| B, N, C = x.shape |
| qkv = self.qkv(x).reshape(B, N, 3, self.num_heads, C // self.num_heads) |
|
|
| |
| q, k, v = torch.unbind(qkv, dim=2) |
|
|
| |
| q = q.permute(0, 2, 1, 3) |
| k = k.permute(0, 2, 1, 3) |
| v = v.permute(0, 2, 1, 3) |
|
|
| |
| x = _chunked_attention(q, k, v, self.scale, self.chunk_size) |
|
|
| x = x.permute(0, 2, 1, 3).reshape(B, N, C) |
|
|
| x = self.proj(x) |
| x = self.proj_drop(x) |
| return x |
|
|
|
|
| |
| |
| |
|
|
| class Block(nn.Module): |
| """A transformer block with adaptive layer norm zero (adaLN-Zero) conditioning.""" |
|
|
| def __init__(self, hidden_size, num_heads, mlp_ratio=4.0, **block_kwargs): |
| super().__init__() |
| self.norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) |
| self.attn = MemEffAttention( |
| hidden_size, num_heads=num_heads, qkv_bias=True, **block_kwargs |
| ) |
| self.norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) |
| mlp_hidden_dim = int(hidden_size * mlp_ratio) |
| approx_gelu = lambda: nn.GELU(approximate="tanh") |
| self.mlp = Mlp( |
| in_features=hidden_size, |
| hidden_features=mlp_hidden_dim, |
| act_layer=approx_gelu, |
| drop=0, |
| ) |
| self.adaLN_modulation = nn.Sequential( |
| nn.SiLU(), |
| nn.Linear(hidden_size, 6 * hidden_size, bias=True), |
| ) |
|
|
| def forward(self, x, c): |
| shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = ( |
| self.adaLN_modulation(c).chunk(6, dim=1) |
| ) |
| x = x + gate_msa.unsqueeze(1) * self.attn( |
| modulate(self.norm1(x), shift_msa, scale_msa) |
| ) |
| x = x + gate_mlp.unsqueeze(1) * self.mlp( |
| modulate(self.norm2(x), shift_mlp, scale_mlp) |
| ) |
| return x |
|
|
|
|
| |
| |
| |
|
|
| class FinalLayer(nn.Module): |
| """Final layer with adaLN modulation, maps embeddings to pixel outputs.""" |
|
|
| def __init__(self, hidden_size, patch_size, out_channels): |
| super().__init__() |
| self.norm_final = nn.Identity() |
| self.linear = nn.Linear( |
| hidden_size, patch_size * patch_size * out_channels, bias=True |
| ) |
| self.adaLN_modulation = nn.Sequential( |
| nn.SiLU(), |
| nn.Linear(hidden_size, 2 * hidden_size, bias=True), |
| ) |
|
|
| def forward(self, x, c): |
| shift, scale = self.adaLN_modulation(c).chunk(2, dim=1) |
| x = modulate(self.norm_final(x), shift, scale) |
| x = self.linear(x) |
| return x |
|
|
|
|
| |
| |
| |
|
|
| class WeatherEmbedding(nn.Module): |
| """Variable-specific patch embedding with cross-attention aggregation. |
| |
| Each variable gets its own PatchEmbed. Variable tokens are aggregated |
| via a learnable query + single-layer cross-attention. |
| """ |
|
|
| def __init__( |
| self, |
| variables, |
| img_size, |
| patch_size=2, |
| embed_dim=1024, |
| num_heads=16, |
| ): |
| super().__init__() |
|
|
| self.img_size = img_size |
| self.patch_size = patch_size |
| self.variables = variables |
|
|
| |
| self.token_embeds = nn.ModuleList([ |
| PatchEmbed(patch_size, 1, embed_dim) for _ in range(len(variables)) |
| ]) |
| self.num_patches = (img_size[0] // patch_size) * (img_size[1] // patch_size) |
|
|
| |
| self.channel_embed, self.channel_map = self._create_var_embedding(embed_dim) |
|
|
| |
| self.channel_query = nn.Parameter( |
| torch.zeros(1, 1, embed_dim), requires_grad=True |
| ) |
| self.channel_agg = nn.MultiheadAttention( |
| embed_dim, num_heads, batch_first=True |
| ) |
|
|
| |
| self.pos_embed = nn.Parameter( |
| torch.zeros(1, self.num_patches, embed_dim), requires_grad=True |
| ) |
|
|
| self.initialize_weights() |
|
|
| def _create_var_embedding(self, dim): |
| var_embed = nn.Parameter( |
| torch.zeros(1, len(self.variables), dim), requires_grad=True |
| ) |
| var_map = {var: idx for idx, var in enumerate(self.variables)} |
| return var_embed, var_map |
|
|
| @lru_cache(maxsize=None) |
| def get_var_ids(self, vars, device): |
| ids = np.array([self.channel_map[var] for var in vars]) |
| return torch.from_numpy(ids).to(device) |
|
|
| def get_var_emb(self, var_emb, vars): |
| ids = self.get_var_ids(tuple(vars), var_emb.device) |
| return var_emb[:, ids, :] |
|
|
| def initialize_weights(self): |
| |
| pos_embed = get_2d_sincos_pos_embed( |
| self.pos_embed.shape[-1], |
| int(self.img_size[0] / self.patch_size), |
| int(self.img_size[1] / self.patch_size), |
| cls_token=False, |
| ) |
| self.pos_embed.data.copy_(torch.from_numpy(pos_embed).float().unsqueeze(0)) |
|
|
| channel_embed = get_1d_sincos_pos_embed_from_grid( |
| self.channel_embed.shape[-1], np.arange(len(self.variables)) |
| ) |
| self.channel_embed.data.copy_( |
| torch.from_numpy(channel_embed).float().unsqueeze(0) |
| ) |
|
|
| |
| for i in range(len(self.token_embeds)): |
| w = self.token_embeds[i].proj.weight.data |
| _trunc_normal_(w.view([w.shape[0], -1]), std=0.02) |
|
|
| |
| self.apply(self._init_weights) |
|
|
| def _init_weights(self, m): |
| if isinstance(m, nn.Linear): |
| trunc_normal_(m.weight, std=0.02) |
| if m.bias is not None: |
| nn.init.constant_(m.bias, 0) |
| elif isinstance(m, nn.LayerNorm): |
| nn.init.constant_(m.bias, 0) |
| nn.init.constant_(m.weight, 1.0) |
|
|
| def aggregate_variables(self, x: torch.Tensor): |
| """Aggregate variable tokens via cross-attention. |
| |
| Args: |
| x: (B, V, L, D) |
| Returns: |
| (B, L, D) |
| """ |
| b, _, l, _ = x.shape |
| x = torch.einsum("bvld->blvd", x) |
| x = x.flatten(0, 1) |
|
|
| var_query = self.channel_query.repeat_interleave(x.shape[0], dim=0) |
| x, _ = self.channel_agg(var_query, x, x) |
| x = x.squeeze() |
|
|
| x = x.unflatten(dim=0, sizes=(b, l)) |
| return x |
|
|
| def forward(self, x: torch.Tensor, variables): |
| """Forward pass of weather embedding. |
| |
| Args: |
| x: (B, V, H, W) input weather state |
| variables: list of variable names |
| Returns: |
| (B, L, D) aggregated token embeddings |
| """ |
| if isinstance(variables, list): |
| variables = tuple(variables) |
|
|
| |
| embeds = [] |
| var_ids = self.get_var_ids(variables, x.device) |
|
|
| for i in range(len(var_ids)): |
| idx = var_ids[i] |
| embed_variable = self.token_embeds[idx](x[:, i: i + 1]) |
| embeds.append(embed_variable) |
|
|
| x = torch.stack(embeds, dim=1) |
|
|
| |
| var_embed = self.get_var_emb(self.channel_embed, list(variables)) |
| x = x + var_embed.unsqueeze(2) |
| x = x + self.pos_embed.unsqueeze(1) |
|
|
| |
| x = self.aggregate_variables(x) |
|
|
| return x |
|
|
|
|
| |
| |
| |
|
|
| class Stormer(nn.Module): |
| """Stormer: A Transformer-based Global Weather Forecasting Model. |
| |
| This model predicts weather state differences (deltas) over a given |
| time interval, conditioned on that interval via adaLN-Zero. |
| |
| Args: |
| in_img_size (tuple): Input spatial dimensions (H, W). |
| variables (list): List of variable name strings. |
| patch_size (int): Patch size for tokenization. Default: 2. |
| hidden_size (int): Hidden dimension throughout the model. Default: 1024. |
| depth (int): Number of transformer blocks. Default: 24. |
| num_heads (int): Number of attention heads. Default: 16. |
| mlp_ratio (float): MLP hidden dim ratio. Default: 4.0. |
| """ |
|
|
| def __init__( |
| self, |
| in_img_size, |
| variables, |
| patch_size=2, |
| hidden_size=1024, |
| depth=24, |
| num_heads=16, |
| mlp_ratio=4.0, |
| ): |
| super().__init__() |
|
|
| |
| self.pad_size = 0 |
| if in_img_size[0] % patch_size != 0: |
| self.pad_size = patch_size - in_img_size[0] % patch_size |
| in_img_size = (in_img_size[0] + self.pad_size, in_img_size[1]) |
|
|
| self.in_img_size = in_img_size |
| self.variables = variables |
| self.patch_size = patch_size |
|
|
| |
| self.embedding = WeatherEmbedding( |
| variables=variables, |
| img_size=in_img_size, |
| patch_size=patch_size, |
| embed_dim=hidden_size, |
| num_heads=num_heads, |
| ) |
| self.embed_norm_layer = nn.LayerNorm(hidden_size) |
|
|
| |
| self.t_embedder = TimestepEmbedder(hidden_size) |
|
|
| |
| self.blocks = nn.ModuleList([ |
| Block(hidden_size, num_heads, mlp_ratio=mlp_ratio) |
| for _ in range(depth) |
| ]) |
|
|
| |
| self.head = FinalLayer(hidden_size, patch_size, len(variables)) |
|
|
| self.initialize_weights() |
|
|
| def initialize_weights(self): |
| """Initialize model weights following official implementation.""" |
|
|
| def _basic_init(module): |
| if isinstance(module, nn.Linear): |
| trunc_normal_(module.weight, std=0.02) |
| if module.bias is not None: |
| nn.init.constant_(module.bias, 0) |
|
|
| self.apply(_basic_init) |
|
|
| |
| trunc_normal_(self.t_embedder.mlp.weight, std=0.02) |
|
|
| |
| for block in self.blocks: |
| nn.init.constant_(block.adaLN_modulation[-1].weight, 0) |
| nn.init.constant_(block.adaLN_modulation[-1].bias, 0) |
|
|
| |
| nn.init.constant_(self.head.adaLN_modulation[-1].weight, 0) |
| nn.init.constant_(self.head.adaLN_modulation[-1].bias, 0) |
| nn.init.constant_(self.head.linear.weight, 0) |
| nn.init.constant_(self.head.linear.bias, 0) |
|
|
| def replace_constant(self, yhat, out_variables): |
| """Zero out predicted diffs for constant/invariant variables. |
| |
| Following the official Stormer implementation, constant fields |
| (like land_sea_mask, orography, etc.) should have zero prediction |
| since they don't change over time. |
| |
| Args: |
| yhat: (B, V, H, W) predicted diffs |
| out_variables: list of variable names |
| Returns: |
| yhat with constant channels set to zero |
| """ |
| for i in range(yhat.shape[1]): |
| if out_variables[i] in CONSTANTS: |
| yhat[:, i] = 0.0 |
| return yhat |
|
|
| def unpatchify(self, x: torch.Tensor, h=None, w=None): |
| """Convert patch tokens back to image space. |
| |
| Args: |
| x: (B, L, V * patch_size**2) |
| h, w: optional height/width override |
| Returns: |
| imgs: (B, V, H, W) |
| """ |
| p = self.patch_size |
| v = len(self.variables) |
| h = self.in_img_size[0] // p if h is None else h // p |
| w = self.in_img_size[1] // p if w is None else w // p |
| assert h * w == x.shape[1], f"Token count mismatch: {h}*{w} != {x.shape[1]}" |
|
|
| x = x.reshape(shape=(x.shape[0], h, w, p, p, v)) |
| x = torch.einsum("nhwpqv->nvhpwq", x) |
| imgs = x.reshape(shape=(x.shape[0], v, h * p, w * p)) |
| return imgs |
|
|
| def pad(self, x: torch.Tensor): |
| """Pad input height to be divisible by patch_size.""" |
| h = x.shape[-2] |
| if h % self.patch_size != 0: |
| pad_size = self.patch_size - h % self.patch_size |
| padded_x = F.pad(x, (0, 0, pad_size, 0), 'constant', 0) |
| else: |
| padded_x = x |
| pad_size = 0 |
| return padded_x, pad_size |
|
|
| def forward(self, x, variables, time_interval, use_checkpoint=False): |
| """Forward pass of Stormer. |
| |
| Args: |
| x: (B, V, H, W) input weather state (normalized) |
| variables: list of variable name strings |
| time_interval: (B,) or scalar, time interval in hours, will be divided by 10 |
| use_checkpoint: if True, apply gradient checkpointing to each block |
| (saves memory during training, trades compute for memory) |
| |
| Returns: |
| (B, V, H_original, W) predicted difference (delta) in normalized space |
| """ |
| |
| if not isinstance(time_interval, torch.Tensor): |
| time_interval = torch.tensor([time_interval], device=x.device, dtype=x.dtype) |
| time_interval = time_interval / 10.0 |
|
|
| |
| if self.pad_size > 0: |
| x = F.pad(x, (0, 0, self.pad_size, 0), 'constant', 0) |
|
|
| |
| if use_checkpoint and self.training: |
| x = torch.utils.checkpoint.checkpoint( |
| self._do_embed, x, variables, |
| use_reentrant=False, |
| ) |
| else: |
| x = self._do_embed(x, variables) |
|
|
| |
| time_interval_emb = self.t_embedder(time_interval) |
|
|
| |
| for block in self.blocks: |
| if use_checkpoint and self.training: |
| x = torch.utils.checkpoint.checkpoint( |
| block, x, time_interval_emb, |
| use_reentrant=False, |
| ) |
| else: |
| x = block(x, time_interval_emb) |
|
|
| |
| x = self.head(x, time_interval_emb) |
| x = self.unpatchify(x) |
|
|
| |
| if self.pad_size > 0: |
| x = x[:, :, self.pad_size:] |
|
|
| return x |
|
|
| def _do_embed(self, x, variables): |
| """Embedding step (extracted for checkpointing).""" |
| x = self.embedding(x, variables) |
| x = self.embed_norm_layer(x) |
| return x |
|
|