Skip to content

RFC: a general probabilistic forecasting interface #685

Description

@Sir-Sloth-The-Lazy

Status: Draft for review
Audience: @joeloskarsson , @leifdenby , @sadamov , @observingClouds and anyone interested ! 🤩
Scope: how to support any probabilistic forecasting model in neural-lam


1. Summary

The deterministic stack has a property worth preserving: adding a new model (GraphLAM, HiLAM, …) means implementing one abstractionStepPredictor and registering it. The core loop (StepPredictorARForecasterForecasterModule) is never touched. It is closed for modification, open for
extension
.

This proposal extends that same property to probabilistic models. The goal:

A new probabilistic model is added by implementing a single predictor
abstraction and registering it. ProbabilisticARForecaster and
ProbabilisticForecasterModule are generic and never change per model, and
contain no mention of ELBO, KL, CRPS, priors, posteriors, or β-weights.

ELBO becomes one example of a probabilistic objective, expressed entirely
inside the Graph-EFM model class — not baked into the core.


2. Why the deterministic core stays fixed today

Every deterministic model conforms to one contract:

StepPredictor.forward(prev_state, prev_prev_state, forcing) -> (pred, std | None)

and they all share one objective shape, assembled in
ForecasterModule.training_step:

loss = mean( self.loss(prediction, target, pred_std, mask) )

The only thing that varies between models is forward; the only thing that varies between runs is the configured scoring rule (--loss wmse|mae|nll|…). Because both the contract and the objective shape are fixed, the core never changes. That is exactly why [ARForecaster] and [`ForecasterModule'] are model-agnostic.


3. What probabilistic models break

Probabilistic models keep the forward contract (they can still produce a predictive step) but they break the shared objective shape. In prob_model_lam, GraphEFM.training_step assembles:

loss = -mean_likelihood + kl_beta * mean_kl   (+ crps_weight * crps_ens)

— reconstruction likelihood + KL(posterior‖prior) + an optional ensemble-CRPS term over sampled trajectories. A pure-ensemble model would instead train on CRPS alone; a diffusion model would train on a denoising loss. There is no single objective formula that covers these.

So if we want the core to stay fixed, the objective, the part that varies, must be owned by the model and reached through a uniform contract, exactly the way forward already is.


4. The generalization

The deterministic objective is

loss = reduce( scoring_rule(prediction, target, std) )

Two ingredients generalize:

  1. Predictive object — deterministic: a point (+ std). General: a Gaussian (mean, std), an ensemble of S trajectories, a per-step reconstruction sampled from a prior/posterior, a diffusion denoiser. Produced by the model.

  2. Objective = a proper scoring rule on the predictive object, plus model-internal penalties, then combined and reduced:

    Model Named terms
    deterministic / Gaussian {score: wMSE or NLL}
    ensemble (CRPS-trained) {score: crps_ens}
    Graph-EFM (ELBO) {nll: NLL(reconstruction), kl: KL(post‖prior)} (+ optional {crps})
    diffusion {score: denoising-MSE}

The universal shape is

loss = reduce( Σ_k  w_k · term_k )

The core knows only this shape: collect named per-sample terms, multiply each by a configured weight, sum, average over the batch, and log each. It never sees the strings "kl"/"crps". KL/likelihood/CRPS are named only inside the concrete model.

The contract that keeps the core truly generic: each term arrives already
reduced to shape (B,)
(the model alone knows whether a term sums over
grid+vars or over latent dims). The core does only
loss = Σ_k w_k · mean_B(term_k) and per-term logging. That is, literally, "the
module reduces and combines."


5. The uniform contract (the entire extension surface)

A new probabilistic model implements only ProbabilisticStepPredictor:

class ProbabilisticStepPredictor(StepPredictor):           # core, abstract

    # 1. eval / sampling step — fresh randomness each call.
    #    (Already what GraphEFM.forward does: sample prior, decode.)
    def forward(prev, prev_prev, forcing) -> (pred, std | None): ...

    # 2. per-step training contributions, each ALREADY reduced to (B,),
    #    plus the state to AR-condition on.
    def training_step_terms(
        prev, prev_prev, forcing, target, score_fn, interior_mask
    ) -> (terms: dict[str, Tensor[B]], next_state: Tensor): ...

    # 3. (optional) terms that need full sampled trajectories, not a step.
    def trajectory_terms(trajectories, target, mask) -> dict[str, Tensor[B]]:
        return {}                                          # default: none

    # 4. model-owned weights + how many trajectories training needs.
    loss_term_weights: dict[str, float]   # keys match the dicts above
    train_ensemble_size: int = 0          # 0 = no trajectory sampling

What makes this satisfy the Open/Closed requirement:

  • The model names its own terms. ELBO → {"nll", "kl"}; CRPS-trained → {"crps"}; deterministic → {"score"}. The core never sees the names.
  • The model owns its weights (kl_beta, crps_weight, …), read from its own config in its own constructor never plumbed into the core module.
  • The scoring rule and mask are injected (score_fn, interior_mask) by the module, consistent with the already-settled boundary that loss_fn/mask are parameters rather than predictor-owned state. The pointwise scoring rule stays config-driven and shared (--loss); an ensemble scoring rule (CRPS) is chosen internally by a model that trains on ensembles.

6. The core, now fixed

# ProbabilisticARForecaster.training_rollout — generic, never edited per model
def training_rollout(init, forcing, target, score_fn, mask):
    terms = defaultdict(lambda: 0)
    prev_prev, prev = init[:, 0], init[:, 1]
    for i in range(pred_steps):
        step_terms, next_state = predictor.training_step_terms(
            prev, prev_prev, forcing[:, i], target[:, i], score_fn, mask
        )
        for k, v in step_terms.items():
            terms[k] = terms[k] + v                 # sum over the rollout
        new_state = boundary_mask * target[:, i] + interior_mask * next_state
        prev_prev, prev = prev, new_state

    if predictor.train_ensemble_size > 0:
        traj = self.sample_trajectories(init, forcing, target,
                                        predictor.train_ensemble_size)
        terms.update(predictor.trajectory_terms(traj, target, mask))
    return terms                                    # dict[str, (B,)]


# ProbabilisticForecasterModule.training_step — generic, never edited per model
def training_step(self, batch):
    terms = self.forecaster.training_rollout(*batch, self.loss, self.interior_mask_bool)
    weights = self.forecaster.predictor.loss_term_weights
    loss = sum(weights[k] * terms[k].mean() for k in terms)
    self.log_dict({f"train_{k}": terms[k].mean() for k in terms} | {"train_loss": loss})
    return loss

No ELBO, KL, CRPS, prior, posterior, or β anywhere in these classes. train_ensemble_size = 0 with one-member evaluation recovers exactly today's deterministic behavior evidence the generalization is real rather than a
special case bolted on.


7. Worked examples (core untouched in every case)

Graph-EFM (ELBO). In its training_step_terms: embed including the target →
encoder posterior → sample z → decode → return
{"nll": -score_fn(recon, target, std, mask).sum(grid, vars), "kl": KL(posterior‖prior).sum(latent)}, next_state = recon_mean.
trajectory_terms{"crps": crps_ens(traj, target, mask)}.
loss_term_weights = {"nll": 1, "kl": kl_beta, "crps": crps_weight},
train_ensemble_size = 2. Every ELBO/prior/posterior detail lives here.

A pure ensemble / CRPS model. training_step_terms returns ({}, sample);
train_ensemble_size = K; trajectory_terms → {"crps": …};
loss_term_weights = {"crps": 1}.

Deterministic, re-expressed. training_step_terms → ({"score": score_fn(pred, target, std)}, pred), loss_term_weights = {"score": 1},
train_ensemble_size = 0.

In each case the only thing written is a predictor subclass + a MODELS
registration.


8. Evaluation generalizes the same way

Deterministic eval scores a point. Probabilistic eval scores a distribution,
so the generic eval step:

  1. Samples an ensemble via forecaster.sample_trajectories(...)
    (sample_trajectories is a generic forecaster capability: loop forward).
  2. Runs the configured ensemble metrics (CRPS, spread/skill, ens-MSE/MAE) —
    the module runs "the configured metrics," it does not name them.
  3. Plots ensemble examples.

With ensemble_size = 1 this collapses to the current deterministic eval, so the
same code path serves both.

Note: crps_ens and spread_squared are not yet in metrics.py (it has
wmse/mse/wmae/mae/nll/crps_gauss). They are the planned ensemble-metrics PR and
are a prerequisite.


9. What changes vs. the current WIP branch draft PR #678

File Change
probabilistic_module.py Remove ELBO/kl_beta/elbo_*. Becomes generic: weighted sum of named terms + per-term logging + ensemble eval.
forecasters/probabilistic.py training_rollout stops computing KL; it accumulates named terms the predictor returns. Keeps sample_trajectories (generic).
step_predictors/probabilistic.py Reframed as the concrete latent-variable family (may keep prior/posterior naming), or split so the generic base only declares the contract in §5.
graph/graph_efm.py Implement training_step_terms / trajectory_terms / loss_term_weights here — this is where ELBO moves to.

10. Open questions for review

  1. Where does the objective live? This proposal puts the term-assembly on the predictor (training_step_terms), which is the closest analogue to the deterministic pattern (one class per model). The alternative is a tiny model-owned Objective object the predictor declares, which keeps the predictor a pure distribution producer. The latter directly addresses your earlier comment that a step predictor shouldn't compute likelihood/KL, at the cost of a second small class per model. Which do you prefer?

  2. This reverses the boundary we earlier agreed (predictor = distribution producer, module = ELBO assembler). That agreement assumed the module was allowed to be ELBO-specific. The new requirement (generic core, untouched per model) forces ELBO assembly off the generic module and onto the model side. Confirming you're happy with that reversal is the crux of this review.

  3. Generality ceiling. The contract is AR-structured (per-step terms + optional trajectory terms), which covers ELBO, ensembles, and CRPS. A genuinely non-AR objective (e.g. diffusion denoising over sampled noise levels) would need an escape hatch where the model overrides the training rollout entirely. Acceptable to design for AR now and leave that hatch, or do you want the more general "model owns its whole objective given tools" contract from the start?

  4. Config. Model-specific hyperparameters (kl_beta, crps_weight,ensemble_size, …) would live in a probabilistic: config section and be read by the model into its own loss_term_weights, keeping them out of the core module. Agreed?

  5. Ensemble batch-folding. Eval/training trajectory sampling can either loop S times or fold S into the batch dim (one B·S rollout). The fold trades memory for speed; OK as the default?


11. Proposed PR sequencing

  1. Ensemble metricscrps_ens, spread_squared in metrics.py. No deps.
  2. Ensemble batch-fold utility — expand/fold S↔B helpers.
  3. Generic probabilistic core — de-ELBO ProbabilisticForecasterModule (named-terms weighted sum + per-term logging), generic ProbabilisticARForecaster.training_rollout (term accumulation) + sample_trajectories, and the abstract ProbabilisticStepPredictor contract from §5. Depends on 1–2.
  4. Graph-EFM as the first model — implement training_step_terms / trajectory_terms / loss_term_weights on the EFM predictor; register in MODELS; config-aware assembly in train_model.py. Depends on 3.
  5. Ensemble eval + plotting wired into the generic module. Depends on 3–4.
  6. Eval scripts. Optional / defer.

Each step keeps the core closed: only step 4 adds a model, and it touches no core
class.

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions