"""Base class for step predictors."""
# Standard library
from abc import ABC, abstractmethod
# Third-party
import torch
from torch import nn
# Local
from ... import utils
from ...datastore import BaseDatastore
[docs]
class StepPredictor(nn.Module, ABC):
"""
Abstract base class for step predictors mapping from the two previous
time steps plus forcing into a prediction of the next state.
"""
grid_static_features: torch.Tensor
state_mean: torch.Tensor
state_std: torch.Tensor
# Registered buffers for clamping
sigmoid_lower_lims: torch.Tensor
sigmoid_upper_lims: torch.Tensor
softplus_lower_lims: torch.Tensor
softplus_upper_lims: torch.Tensor
clamp_lower_upper_idx: torch.Tensor
clamp_lower_idx: torch.Tensor
clamp_upper_idx: torch.Tensor
@property
def trainable(self) -> bool:
"""
Whether the predictor has learnable parameters and can be trained.
Returns
-------
bool
``True`` if the predictor has trainable parameters,
``False`` otherwise.
"""
return any(p.requires_grad for p in self.parameters())
def __init__(
self,
datastore: BaseDatastore,
output_std: bool = False,
output_clamping_lower: dict[str, float] | None = None,
output_clamping_upper: dict[str, float] | None = None,
) -> None:
"""
Initialize the StepPredictor.
Parameters
----------
datastore : BaseDatastore
The datastore providing grid metadata and data access.
output_std : bool, default False
Whether to output a predicted standard deviation.
output_clamping_lower : dict, optional
Lower clamping limits for state variables.
output_clamping_upper : dict, optional
Upper clamping limits for state variables.
"""
super().__init__()
self._output_clamping_lower: dict[str, float] = (
dict(output_clamping_lower) if output_clamping_lower else {}
)
self._output_clamping_upper: dict[str, float] = (
dict(output_clamping_upper) if output_clamping_upper else {}
)
num_state_vars = datastore.get_num_data_vars(category="state")
# Load static features standardized
da_static_features = datastore.get_dataarray(
category="static", split=None, standardize=True
)
if da_static_features is None:
# Create empty static features of the correct shape
num_grid_nodes = datastore.num_grid_points
grid_static_features = torch.empty(
(num_grid_nodes, 0), dtype=torch.float32
)
else:
grid_static_features = torch.tensor(
da_static_features.values, dtype=torch.float32
)
self.register_buffer(
"grid_static_features",
grid_static_features,
persistent=False,
)
da_state_stats = datastore.get_standardization_dataarray(
category="state"
)
self.register_buffer(
"state_mean",
torch.tensor(da_state_stats.state_mean.values, dtype=torch.float32),
persistent=False,
)
self.register_buffer(
"state_std",
torch.tensor(da_state_stats.state_std.values, dtype=torch.float32),
persistent=False,
)
self.output_std = bool(output_std)
if self.output_std:
self.grid_output_dim = 2 * num_state_vars
else:
self.grid_output_dim = num_state_vars
(self.num_grid_nodes, _) = self.grid_static_features.shape
@property
def predicts_std(self) -> bool:
"""
Whether this predictor outputs a predicted standard deviation.
Returns
-------
bool
``True`` if the predictor predicts standard deviation,
``False`` otherwise.
"""
return self.output_std
[docs]
def expand_to_batch(self, x: torch.Tensor, batch_size: int) -> torch.Tensor:
"""
Expand a shared node-feature tensor into a batch of copies.
Parameters
----------
x : torch.Tensor
Shape ``(N, d)``. Tensor to expand. Dims: ``N`` is the number of
nodes and ``d`` is the feature dimension.
batch_size : int
Target batch size ``B``.
Returns
-------
torch.Tensor
Shape ``(B, N, d)``. Batch-expanded view of ``x``.
"""
return x.unsqueeze(0).expand(batch_size, -1, -1)
[docs]
@abstractmethod
def forward(
self,
prev_state: torch.Tensor,
prev_prev_state: torch.Tensor,
forcing: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor | None]:
"""
Advance the state by one step:
``(X_t, X_{t-1}, forcing_t) -> X_{t+1}``.
Parameters
----------
prev_state : torch.Tensor
Shape ``(B, num_grid_nodes, num_state_vars)``. The current state
``X_t``. Dims: ``B`` is batch size, ``num_grid_nodes`` is the
number of spatial nodes, and ``num_state_vars`` is the number of
state variables.
prev_prev_state : torch.Tensor
Shape ``(B, num_grid_nodes, num_state_vars)``. The previous state
``X_{t-1}``, used as additional conditioning. Dims: same as
``prev_state``.
forcing : torch.Tensor
Shape ``(B, num_grid_nodes, num_forcing_vars)``. External forcings
for this step (already concatenated past/current/future
windows). Dims: ``B`` is batch size, ``num_grid_nodes`` is
the number of spatial nodes, and ``num_forcing_vars`` is the
forcing feature dimension.
Returns
-------
pred_state : torch.Tensor
Shape ``(B, num_grid_nodes, num_state_vars)``. The predicted next
state ``X_{t+1}``. Dims: same as ``prev_state``.
pred_std : torch.Tensor or None
Shape ``(B, num_grid_nodes, num_state_vars)`` when ``output_std``
is True, otherwise ``None``. Per-feature predicted standard
deviation. Dims: same as ``prev_state``.
"""
[docs]
def prepare_clamping_params(self, datastore: BaseDatastore) -> None:
"""
Prepare parameters for clamping predicted values to valid range.
Reads the per-feature lower/upper limits from
``self._output_clamping_lower`` and ``self._output_clamping_upper``
(set in ``__init__``) and registers the buffers and clamping
functions used by ``get_clamped_new_state``.
Parameters
----------
datastore : BaseDatastore
The datastore providing variable names.
"""
# Read clamping limits stored on self
state_feature_names = datastore.get_vars_names(category="state")
lower_lims = self._output_clamping_lower
upper_lims = self._output_clamping_upper
# Check that limits are for valid features
unknown_features_lower = set(lower_lims.keys()) - set(
state_feature_names
)
unknown_features_upper = set(upper_lims.keys()) - set(
state_feature_names
)
if unknown_features_lower or unknown_features_upper:
raise ValueError(
"State feature limits were provided for unknown features: "
f"{unknown_features_lower.union(unknown_features_upper)}"
)
# Constant parameters for clamping
sigmoid_sharpness = 1
softplus_sharpness = 1
sigmoid_center = 0
softplus_center = 0
def normalize_clamping_lim(x: float, feature_idx: int) -> torch.Tensor:
"""Normalize a clamping limit from the original feature space to the
standardized space of the model's output.
Parameters
----------
x : float
The clamping limit in the original feature space.
feature_idx : int
The index of the feature this limit applies to, used to look up
the mean and std for normalization.
"""
return (x - self.state_mean[feature_idx]) / self.state_std[
feature_idx
]
# Check which clamping functions to use for each feature
sigmoid_lower_upper_idx = []
sigmoid_lower_lims = []
sigmoid_upper_lims = []
softplus_lower_idx = []
softplus_lower_lims = []
softplus_upper_idx = []
softplus_upper_lims = []
for feature_idx, feature in enumerate(state_feature_names):
if feature in lower_lims and feature in upper_lims:
if not lower_lims[feature] < upper_lims[feature]:
raise ValueError(
f'Invalid clamping limits for feature "{feature}": '
f"lower ({lower_lims[feature]}) must be smaller than "
f"upper ({upper_lims[feature]})."
)
sigmoid_lower_upper_idx.append(feature_idx)
sigmoid_lower_lims.append(
normalize_clamping_lim(lower_lims[feature], feature_idx)
)
sigmoid_upper_lims.append(
normalize_clamping_lim(upper_lims[feature], feature_idx)
)
elif feature in lower_lims and feature not in upper_lims:
softplus_lower_idx.append(feature_idx)
softplus_lower_lims.append(
normalize_clamping_lim(lower_lims[feature], feature_idx)
)
elif feature not in lower_lims and feature in upper_lims:
softplus_upper_idx.append(feature_idx)
softplus_upper_lims.append(
normalize_clamping_lim(upper_lims[feature], feature_idx)
)
self.register_buffer(
"sigmoid_lower_lims", torch.tensor(sigmoid_lower_lims)
)
self.register_buffer(
"sigmoid_upper_lims", torch.tensor(sigmoid_upper_lims)
)
self.register_buffer(
"softplus_lower_lims", torch.tensor(softplus_lower_lims)
)
self.register_buffer(
"softplus_upper_lims", torch.tensor(softplus_upper_lims)
)
self.register_buffer(
"clamp_lower_upper_idx", torch.tensor(sigmoid_lower_upper_idx)
)
self.register_buffer(
"clamp_lower_idx", torch.tensor(softplus_lower_idx)
)
self.register_buffer(
"clamp_upper_idx", torch.tensor(softplus_upper_idx)
)
# Define clamping functions
self.clamp_lower_upper = lambda x: (
self.sigmoid_lower_lims
+ (self.sigmoid_upper_lims - self.sigmoid_lower_lims)
* torch.sigmoid(sigmoid_sharpness * (x - sigmoid_center))
)
self.clamp_lower = lambda x: (
self.softplus_lower_lims
+ torch.nn.functional.softplus(
x - softplus_center, beta=softplus_sharpness
)
)
self.clamp_upper = lambda x: (
self.softplus_upper_lims
- torch.nn.functional.softplus(
softplus_center - x, beta=softplus_sharpness
)
)
self.inverse_clamp_lower_upper = lambda x: (
sigmoid_center
+ utils.inverse_sigmoid(
(x - self.sigmoid_lower_lims)
/ (self.sigmoid_upper_lims - self.sigmoid_lower_lims)
)
/ sigmoid_sharpness
)
self.inverse_clamp_lower = lambda x: (
utils.inverse_softplus(
x - self.softplus_lower_lims, beta=softplus_sharpness
)
+ softplus_center
)
self.inverse_clamp_upper = lambda x: (
-utils.inverse_softplus(
self.softplus_upper_lims - x, beta=softplus_sharpness
)
+ softplus_center
)
[docs]
def get_clamped_new_state(
self, state_delta: torch.Tensor, prev_state: torch.Tensor
) -> torch.Tensor:
"""
Clamp the next-state prediction to its valid feature range.
Instead of the plain residual update
``X_{t+1} = X_t + delta``, the clamped form is
``X_{t+1} = f(f^{-1}(X_t) + delta)``
so the model learns to output increments in the domain of the
inverse clamping function.
Parameters
----------
state_delta : torch.Tensor
Shape ``(B, num_grid_nodes, num_state_vars)``. Raw predicted state
increment (network output, already rescaled). Dims: ``B`` is
batch size, ``num_grid_nodes`` is the number of spatial nodes,
and ``num_state_vars`` is the number of state variables.
prev_state : torch.Tensor
Shape ``(B, num_grid_nodes, num_state_vars)``. Current state ``X_t``
used as the base for the clamped update. Dims: same as
``state_delta``.
Returns
-------
torch.Tensor
Shape ``(B, num_grid_nodes, num_state_vars)``. Clamped next state.
Dims: same as ``state_delta``.
"""
# Assign new state, but overwrite clamped values of each type later
new_state = prev_state + state_delta
# Sigmoid/logistic clamps between ]a,b[
if self.clamp_lower_upper_idx.numel() > 0:
idx = self.clamp_lower_upper_idx
new_state[:, :, idx] = self.clamp_lower_upper(
self.inverse_clamp_lower_upper(prev_state[:, :, idx])
+ state_delta[:, :, idx]
)
# Softplus clamps between ]a,infty[
if self.clamp_lower_idx.numel() > 0:
idx = self.clamp_lower_idx
new_state[:, :, idx] = self.clamp_lower(
self.inverse_clamp_lower(prev_state[:, :, idx])
+ state_delta[:, :, idx]
)
# Softplus clamps between ]-infty,b[
if self.clamp_upper_idx.numel() > 0:
idx = self.clamp_upper_idx
new_state[:, :, idx] = self.clamp_upper(
self.inverse_clamp_upper(prev_state[:, :, idx])
+ state_delta[:, :, idx]
)
return new_state