| """Paper-faithful PyTorch CNN-LSTM baseline for ClimateBench.""" |
|
|
| from __future__ import annotations |
|
|
| import torch |
| from torch import nn |
| from torch.nn import functional as F |
|
|
|
|
| TARGETS = ("tas", "dtr", "pr", "pr90") |
|
|
|
|
| class ReLULSTM(nn.Module): |
| """Keras-compatible LSTM using sigmoid gates and ReLU activation.""" |
|
|
| def __init__(self, input_size: int = 20, hidden_size: int = 25): |
| super().__init__() |
| self.input_size = int(input_size) |
| self.hidden_size = int(hidden_size) |
| self.kernel = nn.Parameter(torch.empty(input_size, 4 * hidden_size)) |
| self.recurrent_kernel = nn.Parameter(torch.empty(hidden_size, 4 * hidden_size)) |
| self.bias = nn.Parameter(torch.zeros(4 * hidden_size)) |
| self.reset_parameters() |
|
|
| def reset_parameters(self) -> None: |
| nn.init.xavier_uniform_(self.kernel) |
| nn.init.orthogonal_(self.recurrent_kernel) |
| nn.init.zeros_(self.bias) |
| with torch.no_grad(): |
| self.bias[self.hidden_size:2 * self.hidden_size].fill_(1.0) |
|
|
| def forward(self, sequence: torch.Tensor) -> torch.Tensor: |
| if sequence.ndim != 3 or sequence.shape[-1] != self.input_size: |
| raise ValueError(f"expected [B,T,{self.input_size}], got {tuple(sequence.shape)}") |
| batch = sequence.shape[0] |
| hidden = sequence.new_zeros(batch, self.hidden_size) |
| cell = sequence.new_zeros(batch, self.hidden_size) |
| for step in range(sequence.shape[1]): |
| gates = sequence[:, step] @ self.kernel + hidden @ self.recurrent_kernel + self.bias |
| input_gate, forget_gate, candidate, output_gate = gates.chunk(4, dim=-1) |
| input_gate = torch.sigmoid(input_gate) |
| forget_gate = torch.sigmoid(forget_gate) |
| candidate = F.relu(candidate) |
| output_gate = torch.sigmoid(output_gate) |
| cell = forget_gate * cell + input_gate * candidate |
| hidden = output_gate * F.relu(cell) |
| return hidden |
|
|
|
|
| class ClimateBenchBranch(nn.Module): |
| """One 364,764-parameter paper CNN-LSTM target emulator.""" |
|
|
| PAPER_PARAMETER_COUNT = 364_764 |
|
|
| def __init__(self, height: int = 96, width: int = 144): |
| super().__init__() |
| self.height = int(height) |
| self.width = int(width) |
| self.conv = nn.Conv2d(4, 20, kernel_size=3, padding="same") |
| self.pool = nn.AvgPool2d(kernel_size=2) |
| self.lstm = ReLULSTM(20, 25) |
| self.dense = nn.Linear(25, height * width) |
| count = sum(parameter.numel() for parameter in self.parameters()) |
| if count != self.PAPER_PARAMETER_COUNT: |
| raise RuntimeError(f"paper branch must have 364764 parameters, got {count}") |
|
|
| def forward(self, inputs: torch.Tensor) -> torch.Tensor: |
| if inputs.ndim != 5 or inputs.shape[2:] != (4, self.height, self.width): |
| raise ValueError(f"expected [B,T,4,{self.height},{self.width}], got {tuple(inputs.shape)}") |
| batch, time = inputs.shape[:2] |
| features = F.relu(self.conv(inputs.reshape(batch * time, 4, self.height, self.width))) |
| features = self.pool(features).mean(dim=(-2, -1)).reshape(batch, time, 20) |
| return self.dense(self.lstm(features)).reshape(batch, 1, self.height, self.width) |
|
|
|
|
| class ClimateBench(nn.Module): |
| """Four independent paper branches ordered as tas, dtr, pr and pr90.""" |
|
|
| def __init__(self, height: int = 96, width: int = 144): |
| super().__init__() |
| self.height = int(height) |
| self.width = int(width) |
| self.branches = nn.ModuleDict({name: ClimateBenchBranch(height, width) for name in TARGETS}) |
|
|
| def forward(self, inputs: torch.Tensor) -> torch.Tensor: |
| return torch.cat([self.branches[name](inputs) for name in TARGETS], dim=1) |
|
|
| def parameter_counts(self) -> dict[str, int]: |
| return {name: sum(parameter.numel() for parameter in branch.parameters()) |
| for name, branch in self.branches.items()} |
|
|