"""PPNN NN-aux-emb model and proper scoring rules.""" from __future__ import annotations import math import torch from torch import nn FORMAT_VERSION = "ppnn_nn_aux_emb_v1" MEMBER_COUNT = 50 VARIABLE_COUNT = 18 STATION_COUNT = 537 LEAD_HOURS = 48 AUX_FEATURE_COUNT = 4 CONTINUOUS_FEATURE_COUNT = VARIABLE_COUNT * 2 + AUX_FEATURE_COUNT EMBEDDING_DIM = 2 INPUT_FEATURE_COUNT = CONTINUOUS_FEATURE_COUNT + EMBEDDING_DIM class PPNN(nn.Module): """NN-aux-emb: ensemble moments, auxiliary predictors, and station embedding.""" def __init__(self, hidden_size: int = 32, station_count: int = STATION_COUNT, eps: float = 1e-4): super().__init__() if station_count != STATION_COUNT: raise ValueError(f"station_count must be the paper value {STATION_COUNT}") self.station_count = station_count self.eps = float(eps) self.station_embedding = nn.Embedding(station_count, EMBEDDING_DIM) self.hidden = nn.Linear(INPUT_FEATURE_COUNT, hidden_size) self.output = nn.Linear(hidden_size, 2) def forward(self, continuous: torch.Tensor, station_index: torch.Tensor): if continuous.ndim != 2 or continuous.shape[-1] != CONTINUOUS_FEATURE_COUNT: raise ValueError(f"continuous features must have shape [batch,{CONTINUOUS_FEATURE_COUNT}]") if station_index.ndim != 1 or station_index.shape[0] != continuous.shape[0]: raise ValueError("station_index must have shape [batch]") if station_index.numel() and (station_index.min() < 0 or station_index.max() >= self.station_count): raise ValueError("station index outside [0, 536]") features = torch.cat((continuous, self.station_embedding(station_index)), dim=-1) mu, raw_sigma = self.output(torch.relu(self.hidden(features))).unbind(dim=-1) return mu, raw_sigma.abs() + self.eps def ensemble_features(ensemble: torch.Tensor, auxiliary: torch.Tensor) -> torch.Tensor: """Reduce [batch, 50, 18] to 36 moments and append four auxiliary features.""" if ensemble.ndim != 3 or ensemble.shape[1:] != (MEMBER_COUNT, VARIABLE_COUNT): raise ValueError(f"ensemble must have shape [batch,{MEMBER_COUNT},{VARIABLE_COUNT}]") if auxiliary.ndim != 2 or auxiliary.shape != (ensemble.shape[0], AUX_FEATURE_COUNT): raise ValueError(f"auxiliary must have shape [batch,{AUX_FEATURE_COUNT}]") moments = torch.cat((ensemble.mean(dim=1), ensemble.std(dim=1, correction=1)), dim=-1) return torch.cat((moments, auxiliary), dim=-1) def gaussian_crps(mu: torch.Tensor, sigma: torch.Tensor, target: torch.Tensor) -> torch.Tensor: """Closed-form CRPS for a Gaussian predictive distribution.""" if torch.any(sigma <= 0): raise ValueError("sigma must be strictly positive") z = (target - mu) / sigma pdf = torch.exp(-0.5 * z.square()) / math.sqrt(2.0 * math.pi) cdf = 0.5 * (1.0 + torch.erf(z / math.sqrt(2.0))) return sigma * (z * (2.0 * cdf - 1.0) + 2.0 * pdf - 1.0 / math.sqrt(math.pi)) def ensemble_crps(ensemble: torch.Tensor, target: torch.Tensor) -> torch.Tensor: """Empirical ensemble CRPS without constructing a member-pair matrix.""" if ensemble.shape[-1] != MEMBER_COUNT: raise ValueError(f"last ensemble dimension must be {MEMBER_COUNT}") sorted_members = ensemble.sort(dim=-1).values weights = (2 * torch.arange(1, MEMBER_COUNT + 1, device=ensemble.device) - MEMBER_COUNT - 1).to(ensemble.dtype) pair_term = (sorted_members * weights).sum(dim=-1) / (MEMBER_COUNT * MEMBER_COUNT) return (ensemble - target.unsqueeze(-1)).abs().mean(dim=-1) - pair_term