Skip to content

feat: add support for output-only diagnostic variables - #698

Open
palakbhati wants to merge 2 commits into
mllam:mainfrom
palakbhati:feature/diagnostic-vars-support
Open

feat: add support for output-only diagnostic variables#698
palakbhati wants to merge 2 commits into
mllam:mainfrom
palakbhati:feature/diagnostic-vars-support

Conversation

@palakbhati

@palakbhati palakbhati commented Jul 2, 2026

Copy link
Copy Markdown

I have implemented support for the diagnostic data category as discussed. This allows users to designate certain variables as "output-only," preventing them from being fed back into the autoregressive loop as input for subsequent steps.

Summary of changes:

Configuration Schema: Updated NeuralLAMConfig in config.py to include a diagnostic_vars field, allowing users to specify output-only variables directly in the model-side config.yaml.

Autoregressive Logic: Modified ARForecaster.forward in autoregressive.py to filter these variables during the unrolling process. By setting the diagnostic indices to the ground truth (boundary_state) at each step, we ensure the model treats them as output-only and does not propagate them autoregressively.

Dynamic Mapping: Updated train_model.py to dynamically map variable names provided in the config to their corresponding indices in the datastore, with validation to ensure the requested variables exist.

I have verified the implementation using the current training pipeline, and it successfully handles the configuration loading and variable filtering.

Issue Link

Closes #684

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation (Addition or improvements to documentation)

Checklist before requesting a review

  • My branch is up-to-date with the target branch - if not update your fork with the changes from the target branch (use pull with --rebase option if possible).
  • I have performed a self-review of my code
  • For any new/modified functions/classes I have added docstrings that clearly describe its purpose, expected inputs and returned values
  • I have placed in-line comments to clarify the intent of any hard-to-understand passages of my code
  • I have updated the README to cover introduced code changes
  • I have added tests that prove my fix is effective or that my feature works
  • I have given the PR a name that clearly describes the change, written in imperative form (context).
  • I have requested a reviewer and an assignee (assignee is responsible for merging). This applies only if you have write access to the repo, otherwise feel free to tag a maintainer to add a reviewer and assignee.

Checklist for reviewers

Each PR comes with its own improvements and flaws. The reviewer should check the following:

  • the code is readable
  • the code is well tested
  • the code is documented (including return types and parameters)
  • the code is easy to maintain

Author checklist after completed review

  • I have added a line to the CHANGELOG describing this change, in a section
    reflecting type of change (add section where missing):
  • added: when you have added new functionality
  • changed: when default behaviour of the code has been changed
  • fixes: when your contribution fixes a bug
  • maintenance: when your contribution is relates to repo maintenance, e.g. CI/CD or documentation

Checklist for assignee

  • PR is up to date with the base branch
  • the tests pass
  • (if the PR is not just maintenance/bugfix) the PR is assigned to the next milestone. If it is not, propose it for a future milestone.
  • author has added an entry to the changelog (and designated the change as added, changed, fixed or maintenance)
    Once the PR is ready to be merged, squash commits and merge the PR.

@Sir-Sloth-The-Lazy Sir-Sloth-The-Lazy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking this on! The formatting/mypy points are quick fixes, but I think the core mechanism needs a rethink before we go further. Because boundary_states is the ground truth in every phase, overwriting the predicted diagnostic channels with it means the model never learns those variables (zero loss) and eval metrics leak the truth. Issue #684's intent is output-only variables, predicted and trained on, but not fed back as input which usually means the predictor's output dimension exceeds its input state dimension and the AR feedback slices the diagnostics off. Could you align on that design (probably with @joeloskarsson or @sadamov ) before iterating?

Comment thread neural_lam/config.py
output_clamping: OutputClamping = dataclasses.field(
default_factory=OutputClamping
)
# Adding the new Field:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the comment does not describe the code, could you please write a comment which explain a future reader, what this line means ?

Comment thread neural_lam/config.py
default_factory=OutputClamping
)
# Adding the new Field:
diagnostic_vars: list[str] = dataclasses.field(default_factory=list)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this live on TrainingConfig? Issue #684 frames diagnostic as a datastore data category (parallel to state/forcing/static), not a training hyperparameter. Worth confirming the design with @sadamov

def __init__(
self, predictor: StepPredictor, datastore: BaseDatastore
self, predictor: StepPredictor, datastore: BaseDatastore,
diagnostic_indices: list[int] = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(1)This will fail the mypy pre-commit hook,the annotation needs to be list[int] | None = None. (2) This new parameter isn't added to the init numpydoc Parameters section; It would be appreciated, if you do that.

self, predictor: StepPredictor, datastore: BaseDatastore,
diagnostic_indices: list[int] = None
) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a trailing whitespace, please remove it

super().__init__()
self.predictor = predictor

self.diagnostic_indices = diagnostic_indices

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the indices are stored without validation, no bounds check against num_state_vars. This can cause a problem

Comment on lines +131 to +132
if self.diagnostic_indices is not None:
new_state[:, :, self.diagnostic_indices] = boundary_state[:, :, self.diagnostic_indices]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the core concern. boundary_state is boundary_states[:, i], and ForecasterModule.common_step passes target_states as boundary_states in all phases (train/val/test). So this line sets the returned prediction's diagnostic channels equal to the ground truth. Consequences:

  • Training: the loss on those channels is identically zero --> the model gets no gradient and never learns to predict diagnostics.
  • Val/test: the "prediction" for diagnostics is the target --> data leakage / artificially perfect scores.
  • new_state also becomes prev_state, so ground-truth diagnostics are fed back into the predictor input each step, the opposite of "output-only," and it leaks truth into the ordinary state predictions too.
    I think this needs a different design: diagnostics should be predicted (and included in the loss) but excluded from the AR feedback, which generally means the predictor outputs more channels than it takes as input, rather than overwriting predictions with truth.

Comment thread neural_lam/train_model.py
Comment on lines +468 to +469
else:
print(f"Warning: Diagnostic variable {var} not found in state features! Available: {feature_names}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says this validates that requested variables exist, but a missing variable just prints and continues, producing a silently-incomplete diagnostic_indices. I'd raise a config error here instead so misconfiguration fails loudly. Also, the codebase uses warnings.warn, not print.

Comment thread neural_lam/train_model.py
else:
print(f"Warning: Diagnostic variable {var} not found in state features! Available: {feature_names}")

forecaster = ARForecaster(predictor, datastore,diagnostic_indices=diagnostic_indices if diagnostic_indices else None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note this mapping logic only runs here in main(),load_forecaster_module_from_checkpoint constructs ARForecaster too, so a reloaded checkpoint silently loses its diagnostic config (gets None). Might be cleaner to resolve names --> indices inside the forecaster/datastore so both construction sites share it.

Comment on lines +19 to +21
# Adding the line:
diagnostic_vars:
- precipitation_rate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This datastore's state features are u100m, v100m, r2m, t2m there's no precipitation_rate in it. So with this config the lookup misses, prints the warning, and diagnostic_indices ends up empty --> the feature is silently disabled. The example meant to demonstrate the feature actually exercises the not-found path. It also highlights the underlying gap: the datastore has no diagnostic-category data, which is what #684 says needs to exist first.

@palakbhati

Copy link
Copy Markdown
Author

@Sir-Sloth-The-Lazy Thanks for the thorough review agreed on all points, especially the core one.

I implemented diagnostics as a subset of existing state indices, masked with boundary_state (= target_states) at each AR step the same mechanism used for spatial boundary conditions, misapplied to feature indices. Since boundary_state is ground truth in every phase, this zeroes the training loss on those channels and leaks truth into eval metrics, exactly as you described. It also feeds true diagnostic values back into prev_state, which is the opposite of output-only.

Fix, not patch: rather than fix this mechanism in place, I'll rework it so the predictor's output width exceeds its input width: grid_output_dim = num_state_vars + num_diagnostic_vars. The AR loop will only feed the first num_state_vars channels back as prev_state/prev_prev_state; the remaining diagnostic channels are real predictions, included in the loss, never overwritten with truth.

This also means diagnostic needs to be a datastore-level category (parallel to state/forcing/static, per #684), not a TrainingConfig field agreed that's the wrong home for it. And you're right that today there's no diagnostic data in the datastore layer at all, which is why the example config's precipitation_rate just hits the not-found path.

@sadamov @joeloskarsson before I rebuild this, could you confirm the design above (extra output channels beyond state width, sliced off before AR feedback) matches what you had in mind for #684? Want to align once rather than iterate blind again.

If that's confirmed, I'll close this PR and split the rework into smaller, reviewable pieces:

Datastore support for a real diagnostic category (+ a diagnostic variable in tests/dummy_datastore.py so it's actually testable)
StepPredictor/BaseGraphModel output-width split (state-delta vs. diagnostic-absolute channels)
ARForecaster change: only state channels loop back, diagnostics collected separately
train_model.py wiring, with name→index resolution shared between main() and load_forecaster_module_from_checkpoint so checkpoint reloads don't silently lose the config

@sadamov
sadamov self-requested a review July 8, 2026 07:14
@sadamov

sadamov commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Thanks @palakbhati, and thanks @Sir-Sloth-The-Lazy for the review. Agreed the current mechanism can't stand: overwriting the predicted channels with boundary_state at each AR step zeroes their loss and leaks truth. Let's go with your original proposal: a real diagnostic datastore category parallel to state/forcing/static, per #684.

Design choices:

  • diagnostic a datastore category with its own std; output-only, so never in init_states, loaded only as targets.
  • Predictor grid_output_dim = num_state + num_diagnostic, output head split: state slice is a residual delta, diagnostic slice absolute (no prev to add a delta to, since diagnostics aren't fed back).
  • ARForecaster feeds only the state slice back; diagnostics collected separately, no boundary overwrite.
  • Loss/metrics get a diagnostic term with its own weighting.

I'd close this PR and split into: (1) datastore category + a diagnostic var in dummy_datastore, (2) data module targets, (3) predictor output split, (4) forecaster feedback, (5) loss wiring. Start with the datastore piece so the rest is testable.

@joeloskarsson @leifdenby What do you think? Let's wait for their input as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a diagnostic data category (output-only)

3 participants