Download model/samudra.py from OneScience-Group/Samudra: direct link, hf CLI and curl.
- Browser
- Download file 7.99 kB
-
https://huggingface.co/OneScience-Group/Samudra/resolve/main/model/samudra.py
- Command line
-
hf download hf://OneScience-Group/Samudra/model/samudra.py
-
curl -L -o samudra.py https://huggingface.co/OneScience-Group/Samudra/resolve/main/model/samudra.py
7.99 kB
| """Paper-version Samudra ConvNeXt U-Net implementation. | |
| The layer layout follows the official ``samudra_om4_v1/model.yaml`` and the | |
| official v1 ``blocks.py`` implementation. Inputs are channel-stacked tensors | |
| with shape ``[batch, 158, lat, lon]`` for the full thermo-dynamic variant. | |
| """ | |
| from __future__ import annotations | |
| from pathlib import Path | |
| from typing import Iterable | |
| import torch | |
| from torch import nn | |
| from torch.nn import functional as F | |
| def circular_pad_width(x: torch.Tensor, padding: int) -> torch.Tensor: | |
| """Apply periodic longitude padding, including widths smaller than padding.""" | |
| if padding == 0: | |
| return x | |
| width = x.shape[-1] | |
| repeats = (padding + width - 1) // width | |
| tiled = x.repeat(1, 1, 1, repeats) | |
| left = tiled[..., -padding:] | |
| right = tiled[..., :padding] | |
| return torch.cat((left, x, right), dim=-1) | |
| def globe_pad(x: torch.Tensor, padding: int) -> torch.Tensor: | |
| x = circular_pad_width(x, padding) | |
| return F.pad(x, (0, 0, padding, padding), mode="constant") | |
| class CappedGELU(nn.Module): | |
| def __init__(self, cap_value: float = 10.0): | |
| super().__init__() | |
| self.gelu = nn.GELU() | |
| # Keep this constant out of DDP buffers. DDP synchronizes buffers at | |
| # every forward, which would mutate the autograd version counter during | |
| # a recurrent multi-step rollout. | |
| self.cap = float(cap_value) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| return torch.clamp(self.gelu(x), max=self.cap) | |
| class ConvNeXtBlock(nn.Module): | |
| """Official v1 residual block with dilated 3x3 convolutions.""" | |
| def __init__(self, in_channels: int, out_channels: int, dilation: int): | |
| super().__init__() | |
| self.in_channels = in_channels | |
| self.out_channels = out_channels | |
| self.dilation = dilation | |
| self.padding = dilation | |
| self.skip = nn.Identity() if in_channels == out_channels else nn.Conv2d(in_channels, out_channels, 1) | |
| hidden = in_channels * 4 | |
| self.layers = nn.ModuleList( | |
| [ | |
| nn.Conv2d(in_channels, hidden, 3, dilation=dilation), | |
| nn.BatchNorm2d(hidden), | |
| CappedGELU(), | |
| nn.Conv2d(hidden, hidden, 3, dilation=dilation), | |
| nn.BatchNorm2d(hidden), | |
| CappedGELU(), | |
| nn.Conv2d(hidden, out_channels, 1), | |
| ] | |
| ) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| skip = self.skip(x) | |
| for layer in self.layers: | |
| if isinstance(layer, nn.Conv2d) and layer.kernel_size == (3, 3): | |
| x = globe_pad(x, self.padding) | |
| x = layer(x) | |
| return skip + x | |
| class PeriodicBilinearUpsample(nn.Module): | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| width = x.shape[-1] | |
| padded = F.pad(x, (1, 1, 0, 0), mode="circular") | |
| result = F.interpolate(padded, scale_factor=2, mode="bilinear", align_corners=False) | |
| return result[..., 2 : 2 + width * 2] | |
| class SamudraUNet(nn.Module): | |
| def __init__(self, in_channels: int, widths: Iterable[int], dilations: Iterable[int]): | |
| super().__init__() | |
| widths = list(widths) | |
| dilations = list(dilations) | |
| if len(widths) != 4 or len(dilations) != 4: | |
| raise ValueError("paper v1 requires four widths and four dilation rates") | |
| channels = [in_channels, *widths] | |
| self.down_blocks = nn.ModuleList( | |
| ConvNeXtBlock(channels[i], channels[i + 1], dilations[i]) for i in range(4) | |
| ) | |
| self.pools = nn.ModuleList(nn.AvgPool2d(2) for _ in range(4)) | |
| self.middle = ConvNeXtBlock(widths[-1], widths[-1], dilations[-1]) | |
| self.first_up = PeriodicBilinearUpsample() | |
| reversed_widths = list(reversed(widths)) | |
| reversed_dilations = list(reversed(dilations)) | |
| self.up_blocks = nn.ModuleList() | |
| self.upsamples = nn.ModuleList() | |
| current = widths[-1] | |
| for index in range(3): | |
| target = reversed_widths[index + 1] | |
| self.up_blocks.append(ConvNeXtBlock(current, target, reversed_dilations[index])) | |
| self.upsamples.append(PeriodicBilinearUpsample()) | |
| current = target | |
| self.final = ConvNeXtBlock(current, widths[0], reversed_dilations[-1]) | |
| self.out_channels = widths[0] | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| skips = [] | |
| for block, pool in zip(self.down_blocks, self.pools): | |
| x = block(x) | |
| skips.append(x) | |
| x = pool(x) | |
| x = self.middle(x) | |
| x = self.first_up(x) | |
| x = self._merge(x, skips[-1]) | |
| for block, up, skip in zip(self.up_blocks, self.upsamples, reversed(skips[:-1])): | |
| x = block(x) | |
| x = up(x) | |
| x = self._merge(x, skip) | |
| return self.final(x) | |
| def _merge(x: torch.Tensor, skip: torch.Tensor) -> torch.Tensor: | |
| height = min(x.shape[-2], skip.shape[-2]) | |
| width = min(x.shape[-1], skip.shape[-1]) | |
| x = x[..., :height, :width] | |
| skip = skip[..., :height, :width] | |
| if x.shape[1] != skip.shape[1]: | |
| raise RuntimeError(f"skip channel mismatch: {x.shape[1]} != {skip.shape[1]}") | |
| return x + skip | |
| class Samudra(nn.Module): | |
| """Samudra v1 single-scale emulator. | |
| ``variant='thermo_dynamic'`` uses the paper's 158-to-154 interface. | |
| ``variant='thermo'`` uses 78 input channels and 154 output channels. | |
| """ | |
| def __init__( | |
| self, | |
| variant: str = "thermo_dynamic", | |
| input_channels: int | None = None, | |
| output_channels: int | None = None, | |
| widths: Iterable[int] = (200, 250, 300, 400), | |
| dilations: Iterable[int] = (1, 2, 4, 8), | |
| ): | |
| super().__init__() | |
| if variant not in {"thermo_dynamic", "thermo"}: | |
| raise ValueError("variant must be thermo_dynamic or thermo") | |
| default_input = 158 if variant == "thermo_dynamic" else 78 | |
| default_output = 154 | |
| self.variant = variant | |
| self.input_channels = default_input if input_channels is None else input_channels | |
| self.output_channels = default_output if output_channels is None else output_channels | |
| self.unet = SamudraUNet(self.input_channels, widths, dilations) | |
| self.decoder = nn.Conv2d(self.unet.out_channels, self.output_channels, 3) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| if x.ndim != 4 or x.shape[1] != self.input_channels: | |
| raise ValueError(f"expected [batch, {self.input_channels}, lat, lon], got {tuple(x.shape)}") | |
| features = self.unet(x) | |
| features = globe_pad(features, 1) | |
| return self.decoder(features) | |
| def load_official_checkpoint(self, path: str | Path, strict: bool = True) -> None: | |
| checkpoint = torch.load(path, map_location="cpu") | |
| if isinstance(checkpoint, dict) and "state_dict" in checkpoint: | |
| checkpoint = checkpoint["state_dict"] | |
| if not isinstance(checkpoint, dict): | |
| raise TypeError("official checkpoint must contain a state dictionary") | |
| # Older local checkpoints stored the fixed GELU cap as a buffer. | |
| checkpoint = {key: value for key, value in checkpoint.items() if not key.endswith(".cap")} | |
| self.load_state_dict(checkpoint, strict=strict) | |
| def build_model(config: dict) -> Samudra: | |
| """Build a model from the project's YAML model section.""" | |
| model_config = config.get("model", config) | |
| model = Samudra( | |
| variant=model_config.get("variant", "thermo_dynamic"), | |
| input_channels=model_config.get("input_channels"), | |
| output_channels=model_config.get("output_channels"), | |
| widths=model_config.get("widths", (200, 250, 300, 400)), | |
| dilations=model_config.get("dilations", (1, 2, 4, 8)), | |
| ) | |
| checkpoint = model_config.get("checkpoint") | |
| if checkpoint: | |
| model.load_official_checkpoint(checkpoint) | |
| return model | |