Source code for neural_lam.models.step_predictors.graph.base

"""Base class for graph-based step predictors."""

# Standard library

# Third-party
import torch

# Local
from .... import utils
from ....datastore import BaseDatastore
from ....gnn_layers import get_gnn_class
from ..base import StepPredictor


[docs] class BaseGraphModel(StepPredictor): """ Base (abstract) class for graph-based models building on the encode-process-decode idea. """ diff_mean: torch.Tensor diff_std: torch.Tensor g2m_features: torch.Tensor m2g_features: torch.Tensor g2m_edge_index: torch.Tensor m2g_edge_index: torch.Tensor mesh_static_features: torch.Tensor | list[torch.Tensor] m2m_features: torch.Tensor | list[torch.Tensor] m2m_edge_index: torch.Tensor | list[torch.Tensor] def __init__( self, datastore: BaseDatastore, graph_name: str = "multiscale", hidden_dim: int = 64, hidden_layers: int = 1, processor_layers: int = 4, mesh_aggr: str = "sum", num_past_forcing_steps: int = 1, num_future_forcing_steps: int = 1, output_std: bool = False, output_clamping_lower: dict[str, float] | None = None, output_clamping_upper: dict[str, float] | None = None, g2m_gnn_type: str = "InteractionNet", m2g_gnn_type: str = "InteractionNet", ) -> None: """ Initialize the BaseGraphModel. Parameters ---------- datastore : BaseDatastore Datastore supplying data and information about dataset and forecast region. graph_name : str, default "multiscale" The name of the graph to load. hidden_dim : int, default 64 The dimension of the hidden representations. hidden_layers : int, default 1 The number of hidden layers in the MLPs. processor_layers : int, default 4 The number of processor layers in the GNN. mesh_aggr : str, default "sum" The aggregation method for mesh nodes. num_past_forcing_steps : int, default 1 The number of past forcing steps to include. num_future_forcing_steps : int, default 1 The number of future forcing steps to include. 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__( datastore=datastore, output_std=output_std, output_clamping_lower=output_clamping_lower, output_clamping_upper=output_clamping_upper, ) self.g2m_gnn_type = g2m_gnn_type self.m2g_gnn_type = m2g_gnn_type # Retrieve difference statistics for rescaling in forward pass da_state_stats = datastore.get_standardization_dataarray("state") self.register_buffer( "diff_mean", torch.tensor( da_state_stats.state_diff_mean_standardized.values, dtype=torch.float32, ), persistent=False, ) self.register_buffer( "diff_std", torch.tensor( da_state_stats.state_diff_std_standardized.values, dtype=torch.float32, ), persistent=False, ) # Store architecture hyperparameters for subclass use self.hidden_dim = hidden_dim self.hidden_layers = hidden_layers self.processor_layers = processor_layers self.mesh_aggr = mesh_aggr # Load graph with static features # NOTE: (IMPORTANT!) mesh nodes MUST have the first # num_mesh_nodes indices, grid_xy_extent = datastore.get_xy_extent(category="state") grid_xy_max_span = max( grid_xy_extent[1] - grid_xy_extent[0], grid_xy_extent[3] - grid_xy_extent[2], ) self.hierarchical = utils.load_and_register_graph( self, datastore, graph_name, mesh_node_features_scaling=grid_xy_max_span, ) # Specify dimensions of data self.num_mesh_nodes, _ = self.get_num_mesh() utils.log_on_rank_zero( f"Loaded graph with {self.num_grid_nodes + self.num_mesh_nodes} " f"nodes ({self.num_grid_nodes} grid, {self.num_mesh_nodes} mesh)" ) # Compute grid_input_dim: total input dimensionality on the grid self.grid_input_dim = utils.compute_grid_input_dim( datastore, num_past_forcing_steps, num_future_forcing_steps, ) self.g2m_edges, g2m_dim = self.g2m_features.shape self.m2g_edges, m2g_dim = self.m2g_features.shape # Define sub-models # Feature embedders for grid self.mlp_blueprint_end = [hidden_dim] * (hidden_layers + 1) self.grid_embedder = utils.make_mlp( [self.grid_input_dim] + self.mlp_blueprint_end ) self.g2m_embedder = utils.make_mlp([g2m_dim] + self.mlp_blueprint_end) self.m2g_embedder = utils.make_mlp([m2g_dim] + self.mlp_blueprint_end) # GNNs # encoder self.g2m_gnn = get_gnn_class(g2m_gnn_type)( self.g2m_edge_index, hidden_dim, hidden_layers=hidden_layers, update_edges=False, ) self.encoding_grid_mlp = utils.make_mlp( [hidden_dim] + self.mlp_blueprint_end ) # decoder self.m2g_gnn = get_gnn_class(m2g_gnn_type)( self.m2g_edge_index, hidden_dim, hidden_layers=hidden_layers, update_edges=False, ) # Output mapping (hidden_dim -> output_dim) self.output_map = utils.make_mlp( [hidden_dim] * (hidden_layers + 1) + [self.grid_output_dim], layer_norm=False, ) # No layer norm on this one # Compute indices and define clamping functions self.prepare_clamping_params(datastore)
[docs] def get_num_mesh(self) -> tuple[int, int]: """ Compute number of mesh nodes from loaded features, and number of mesh nodes that should be ignored in encoding/decoding. Returns ------- num_mesh_nodes : int The number of mesh nodes. num_ignore_mesh_nodes : int The number of mesh nodes to ignore. """ raise NotImplementedError("get_num_mesh not implemented")
[docs] def embedd_mesh_nodes(self) -> torch.Tensor: """ Embed static mesh node features. Returns ------- torch.Tensor Shape ``(num_mesh_nodes, hidden_dim)``. Embedded mesh node representations. Dims: ``num_mesh_nodes`` is the number of mesh nodes and ``hidden_dim`` is the hidden dimension. """ raise NotImplementedError("embedd_mesh_nodes not implemented")
[docs] def process_step(self, mesh_rep: torch.Tensor) -> torch.Tensor: """ Process the mesh representation in the encode-process-decode framework, running one or more message-passing steps. Parameters ---------- mesh_rep : torch.Tensor Shape ``(B, num_mesh_nodes, hidden_dim)``. Current mesh node representations. Dims: ``B`` is batch size, ``num_mesh_nodes`` is the number of mesh nodes, and ``hidden_dim`` is the hidden dimension. Returns ------- torch.Tensor Shape ``(B, num_mesh_nodes, hidden_dim)``. Updated mesh node representations. Dims: same as ``mesh_rep``. """ raise NotImplementedError("process_step not implemented")
[docs] 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 using the encode-process-decode graph pipeline: embed grid + edge features, map grid -> mesh via the g2m GNN, run the (subclass-defined) processor on the mesh, decode mesh -> grid via the m2g GNN, project to a state delta, rescale with difference statistics, and add to ``prev_state`` (with optional clamping). Returns ``(X_{t+1}, optional std)``. 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 grid 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 grid nodes, and ``num_forcing_vars`` is the forcing feature dimension. Returns ------- new_state : torch.Tensor Shape ``(B, num_grid_nodes, num_state_vars)``. The predicted next state ``X_{t+1}`` after delta-add and clamping. 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 (raw softplus output, not rescaled by diff statistics). Dims: same as ``prev_state``. """ batch_size = prev_state.shape[0] # Create full grid node features of shape (B, num_grid_nodes, grid_dim) grid_features = torch.cat( ( prev_state, prev_prev_state, forcing, self.expand_to_batch(self.grid_static_features, batch_size), ), dim=-1, ) # Embed all features grid_emb = self.grid_embedder( grid_features ) # (B, num_grid_nodes, hidden_dim) g2m_emb = self.g2m_embedder( self.g2m_features ) # (num_edges, hidden_dim) m2g_emb = self.m2g_embedder( self.m2g_features ) # (num_edges, hidden_dim) mesh_emb = self.embedd_mesh_nodes() # Map from grid to mesh mesh_emb_expanded = self.expand_to_batch( mesh_emb, batch_size ) # (B, num_mesh_nodes, hidden_dim) g2m_emb_expanded = self.expand_to_batch(g2m_emb, batch_size) # This also splits representation into grid and mesh mesh_rep = self.g2m_gnn( grid_emb, mesh_emb_expanded, g2m_emb_expanded ) # (B, num_mesh_nodes, hidden_dim) # Also MLP with residual for grid representation grid_rep = grid_emb + self.encoding_grid_mlp( grid_emb ) # (B, num_grid_nodes, hidden_dim) # Run processor step mesh_rep = self.process_step(mesh_rep) # Map back from mesh to grid m2g_emb_expanded = self.expand_to_batch(m2g_emb, batch_size) grid_rep = self.m2g_gnn( mesh_rep, grid_rep, m2g_emb_expanded ) # (B, num_grid_nodes, hidden_dim) # Map to output dimension, only for grid net_output = self.output_map( grid_rep ) # (B, num_grid_nodes, d_grid_out) if self.output_std: pred_delta_mean, pred_std_raw = net_output.chunk( 2, dim=-1 ) # both (B, num_grid_nodes, num_state_vars) # NOTE: The predicted std. is not scaled in any way here # linter for some reason does not think softplus is callable # pylint: disable-next=not-callable pred_std = torch.nn.functional.softplus(pred_std_raw) else: pred_delta_mean = net_output pred_std = None # Rescale with one-step difference statistics rescaled_delta_mean = pred_delta_mean * self.diff_std + self.diff_mean # Clamp values to valid range (also add the delta to the previous state) new_state = self.get_clamped_new_state(rescaled_delta_mean, prev_state) return new_state, pred_std