Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1713911
feat: move training loss onto Forecaster, add probabilistic interface
Sir-Sloth-The-Lazy Jul 5, 2026
3f5402d
Address PR review: clarify scoring-rule wording, rename score_fn to s…
Sir-Sloth-The-Lazy Jul 6, 2026
987fecd
Address PR review: move loss and per_var_std onto the Forecaster
Sir-Sloth-The-Lazy Jul 8, 2026
511a6d5
Fail fast when a forecaster is missing per_var_std it needs
Sir-Sloth-The-Lazy Jul 8, 2026
56b3d6b
Rename ensemble_std to per_member_std, document mixture semantics
Sir-Sloth-The-Lazy Jul 8, 2026
d64878c
Update neural_lam/models/forecasters/probabilistic.py
Sir-Sloth-The-Lazy Jul 8, 2026
6fd050a
Leave ProbabilisticARForecaster.compute_training_loss abstract
Sir-Sloth-The-Lazy Jul 8, 2026
a1350ff
Require an explicit member count instead of a stored ensemble_size
Sir-Sloth-The-Lazy Jul 8, 2026
1cbded6
Implement ProbabilisticForecasterModule.test_step
Sir-Sloth-The-Lazy Jul 8, 2026
703e53d
Address PR review: separate ensemble RMSE from validation loss naming
Sir-Sloth-The-Lazy Jul 10, 2026
45ffdeb
Address PR review: drop redundant inline comments in probabilistic tests
Sir-Sloth-The-Lazy Jul 10, 2026
d020691
Address PR review: split ForecasterModule into an abstract base + con…
Sir-Sloth-The-Lazy Jul 10, 2026
98ab692
Ignore .idea directory in .gitignore
Sir-Sloth-The-Lazy Jul 18, 2026
f0e01b1
Address PR review: delegate validation/test loss computation to Forec…
Sir-Sloth-The-Lazy Jul 18, 2026
8b3ab33
Address PR review: move per_var_std/config validation into the Foreca…
Sir-Sloth-The-Lazy Jul 18, 2026
7dc0b90
Address PR review: move ForecasterModules out of forecasters/ into mo…
Sir-Sloth-The-Lazy Jul 18, 2026
8a998e8
Merge branch 'main' into feat/probabilistic-forecasting-interface
Sir-Sloth-The-Lazy Jul 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ tags
# Coc configuration directory
.vim
.vscode
.idea

# macos
.DS_Store
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Add `--train_steps_to_log` CLI option to log training loss for individual unroll steps, and deduplicate common prediction and loss computation steps across loops [\#674](https://github.com/mllam/neural-lam/issues/674) @GiGiKoneti

- Add a general probabilistic forecasting interface (`ProbabilisticForecaster`,
`ProbabilisticARForecaster`, `ProbabilisticForecasterModule`) and move
ownership of the training objective, scoring rule and per-variable std
from `ForecasterModule` onto the `Forecaster`.
[\#685](https://github.com/mllam/neural-lam/issues/685)
@Sir-Sloth-The-Lazy

- Add `PropagationNet` GNN layer that incentivises directional message
propagation from sender to receiver nodes, and expose it alongside
`InteractionNet` through four new CLI arguments (`--g2m_gnn_type`,
Expand Down
8 changes: 7 additions & 1 deletion neural_lam/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
# Local
from .forecasters.autoregressive import ARForecaster
from .forecasters.base import Forecaster
from .module import ForecasterModule
from .forecasters.probabilistic import (
ProbabilisticARForecaster,
ProbabilisticForecaster,
)
from .modules.base import BaseForecasterModule
from .modules.deterministic import DeterministicForecasterModule
from .modules.probabilistic import ProbabilisticForecasterModule
from .step_predictors.base import StepPredictor
from .step_predictors.graph.base import BaseGraphModel
from .step_predictors.graph.graph_lam import GraphLAM
Expand Down
1 change: 1 addition & 0 deletions neural_lam/models/forecasters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
# Local
from .autoregressive import ARForecaster
from .base import Forecaster
from .probabilistic import ProbabilisticARForecaster, ProbabilisticForecaster
219 changes: 216 additions & 3 deletions neural_lam/models/forecasters/autoregressive.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
"""Forecaster that uses an auto-regressive strategy to unroll a forecast."""

# Standard library
from typing import Callable, Optional

# Third-party
import torch

# Local
from ... import metrics
from ...config import NeuralLAMConfig
from ...datastore import BaseDatastore
from ...loss_weighting import get_state_feature_weighting
from ..step_predictors.base import StepPredictor
from .base import Forecaster

Expand All @@ -18,7 +22,11 @@ class ARForecaster(Forecaster):
"""

def __init__(
self, predictor: StepPredictor, datastore: BaseDatastore
self,
predictor: StepPredictor,
datastore: BaseDatastore,
config: NeuralLAMConfig | None = None,
loss: str = "wmse",
) -> None:
"""
Initialize the ARForecaster.
Expand All @@ -29,6 +37,17 @@ def __init__(
The predictor to use for each step.
datastore : BaseDatastore
The datastore providing grid metadata and boundary masks.
config : NeuralLAMConfig or None
Configuration used to compute the constant per-variable std
substituted for ``pred_std`` when ``predictor`` does not output
its own (see ``per_var_std``). Required in that case for
``score``/``compute_training_loss`` to work (they raise
``ValueError`` via ``_resolve_pred_std`` otherwise); forecasters
used purely for inference (``forward``/``sample_ensemble``) can
omit it.
loss : str, default "wmse"
The scoring rule (from ``neural_lam.metrics``) used by
``compute_training_loss`` and stored as ``self.loss``.
"""
super().__init__()
self.predictor = predictor
Expand All @@ -44,6 +63,31 @@ def __init__(
"interior_mask", 1.0 - self.boundary_mask, persistent=False
)

self.loss = metrics.get_metric(loss)

# Store per_var_std here if the predictor does not output its own std
if not self.predicts_std and config is not None:
da_state_stats = datastore.get_standardization_dataarray(
category="state"
)
state_feature_weights = get_state_feature_weighting(
config=config, datastore=datastore
)
diff_std = torch.tensor(
da_state_stats.state_diff_std_standardized.values,
dtype=torch.float32,
)
feature_weights_t = torch.tensor(
state_feature_weights, dtype=torch.float32
)
self.register_buffer(
"per_var_std",
diff_std / torch.sqrt(feature_weights_t),
persistent=False,
)
else:
self.per_var_std = None

@property
def predicts_std(self) -> bool:
"""
Expand Down Expand Up @@ -103,7 +147,7 @@ def forward(
pred_std : torch.Tensor or None
Shape ``(B, pred_steps, num_grid_nodes, num_state_vars)`` when the
wrapped predictor outputs an std, otherwise ``None`` (in which
case ``ForecasterModule`` substitutes the constant
case ``DeterministicForecasterModule`` substitutes the constant
per-variable std). Dims: same as ``prediction``.
"""

Expand Down Expand Up @@ -137,10 +181,179 @@ def forward(

prediction = torch.stack(prediction_list, dim=1)
# If predictor outputs std, stack it; otherwise return None so
# ForecasterModule can substitute the constant per_var_std
# callers can substitute the constant per_var_std
if pred_std_list:
pred_std = torch.stack(pred_std_list, dim=1)
else:
pred_std = None

return prediction, pred_std

def compute_training_loss(
self,
init_states: torch.Tensor,
forcing_features: torch.Tensor,
target_states: torch.Tensor,
interior_mask_bool: torch.Tensor,
) -> tuple[torch.Tensor, dict[str, torch.Tensor]]:
"""
Score the deterministic rollout with ``self.loss``.

Unrolls a single forecast over the full rollout, scores it against
the target states on interior nodes and averages over batch and
time.

Parameters
----------
init_states : torch.Tensor
Shape ``(B, 2, num_grid_nodes, num_state_vars)``. The two initial
states ``[X_{t-1}, X_t]`` used to start the rollout from. Dims:
``B`` is batch size, ``2`` is the time index (``[X_{t-1}, X_t]``),
``num_grid_nodes`` is the number of spatial nodes, and
``num_state_vars`` is the state feature dimension.
forcing_features : torch.Tensor
Shape ``(B, pred_steps, num_grid_nodes, num_forcing_vars)``.
External forcings provided at each predicted step. Dims: ``B``
is batch size, ``pred_steps`` is the rollout length,
``num_grid_nodes`` is the number of spatial nodes, and
``num_forcing_vars`` is the forcing feature dimension (already
concatenated past/current/future windows).
target_states : torch.Tensor
Shape ``(B, pred_steps, num_grid_nodes, num_state_vars)``. True
states at each predicted step, used both as the prediction
targets and to overwrite boundary nodes during the rollout.
Dims: same as the prediction.
interior_mask_bool : torch.Tensor
Shape ``(num_grid_nodes,)``, boolean. ``True`` for interior
nodes; passed as ``mask`` to ``self.loss`` so that only interior
nodes are scored.

Returns
-------
batch_loss : torch.Tensor
Scalar. The scoring rule applied to the rollout, averaged over
batch and time.
loss_components : dict of {str: torch.Tensor}
Empty; the deterministic objective has no separate components.

Raises
------
ValueError
If the predictor does not output its own std and no
``per_var_std`` fallback is available; see
``_resolve_pred_std``.
"""
prediction, pred_std = self(
init_states, forcing_features, target_states
)
pred_std = self._resolve_pred_std(pred_std)

batch_loss = torch.mean(
self.loss(
prediction,
target_states,
pred_std,
mask=interior_mask_bool,
)
)
return batch_loss, {}

def _resolve_pred_std(
self, pred_std: Optional[torch.Tensor]
) -> torch.Tensor:
"""
Return ``pred_std``, or the constant ``per_var_std`` fallback.

Parameters
----------
pred_std : torch.Tensor or None
Predicted standard deviation as returned by ``forward``,
possibly ``None``.

Returns
-------
torch.Tensor
``pred_std`` unchanged when given; otherwise ``self.per_var_std``.

Raises
------
ValueError
If ``pred_std`` is ``None`` and no ``per_var_std`` fallback is
available (``predictor.predicts_std`` is False and this
forecaster was constructed without ``config``).
"""
if pred_std is not None:
return pred_std
if self.per_var_std is None:
raise ValueError(
"No pred_std available for scoring: predictor.predicts_std "
"is False and this forecaster has no per_var_std fallback "
"(it was constructed without config). Pass config to the "
"constructor, or use a predictor that outputs its own std."
)
return self.per_var_std

def score(
self,
prediction: torch.Tensor,
target_states: torch.Tensor,
pred_std: Optional[torch.Tensor],
metric: Optional[Callable[..., torch.Tensor]] = None,
mask: Optional[torch.Tensor] = None,
average_grid: bool = True,
sum_vars: bool = True,
) -> torch.Tensor:
"""
Score an already-produced prediction for reporting (not training).

Resolves ``pred_std`` via ``_resolve_pred_std`` (substituting
``self.per_var_std`` when ``None``), then applies ``metric``
(defaulting to ``self.loss``, the configured scoring rule).

Parameters
----------
prediction : torch.Tensor
Shape ``(..., num_grid_nodes, num_state_vars)``. Forecast to
score.
target_states : torch.Tensor
Shape ``(..., num_grid_nodes, num_state_vars)``. True states to
score against. Dims: same as ``prediction``.
pred_std : torch.Tensor or None
Shape ``(..., num_grid_nodes, num_state_vars)``, or ``None``.
Predicted standard deviation for ``prediction``; ``None`` when
the wrapped predictor does not output one, in which case
``self.per_var_std`` is substituted (see ``_resolve_pred_std``
for when this raises instead).
metric : callable or None, optional
Scoring function with the ``neural_lam.metrics`` signature
``(pred, target, pred_std, mask=None, average_grid=True,
sum_vars=True) -> torch.Tensor``. Defaults to ``self.loss``.
mask : torch.Tensor or None, optional
Shape ``(num_grid_nodes,)``, boolean. Forwarded to ``metric``.
average_grid : bool, optional
Forwarded to ``metric``.
sum_vars : bool, optional
Forwarded to ``metric``.

Returns
-------
torch.Tensor
The metric's output; shape depends on ``average_grid`` and
``sum_vars`` (see ``neural_lam.metrics``).

Raises
------
ValueError
If ``pred_std`` is ``None`` and no ``per_var_std`` fallback is
available; see ``_resolve_pred_std``.
"""
pred_std = self._resolve_pred_std(pred_std)
metric_fn = self.loss if metric is None else metric
return metric_fn(
prediction,
target_states,
pred_std,
mask=mask,
average_grid=average_grid,
sum_vars=sum_vars,
)
Loading
Loading