Skip to content

[1849][model] Add optional latent Zarr writer - #1860

Open
evenmn wants to merge 52 commits into
ecmwf:developfrom
metno:feature/latent-zarr-writer
Open

[1849][model] Add optional latent Zarr writer#1860
evenmn wants to merge 52 commits into
ecmwf:developfrom
metno:feature/latent-zarr-writer

Conversation

@evenmn

@evenmn evenmn commented Feb 17, 2026

Copy link
Copy Markdown
Contributor

Description

This PR introduces a writer for the latent vector. To avoid additional config options, I decided to add "latent" as a special case of a stream output, e.g:

uv run inference ... --options test_config.output.streams=[ERA5,latent]

The latent output contains the latent vector itself, metadata to map healpix cells to coordinates, as well as extra features which may be useful for explainable AI applications. The combined ERA5 and latent output file takes the form (here for healpix level 5 and 2048 latent channels):

├── 0                                                                                                                                                                                                              
│   ├── ERA5                                                                                                                                                                                                       
│   │   ├── 0                                                                                                                                                                                                      
│   │   │   └── source                                                                                                                                                                                             
│   │   │       ├── coords (40320, 2) float32
│   │   │       ├── data (40320, 2) float32
│   │   │       ├── geoinfo (40320, 0) float32
│   │   │       └── times (40320,) datetime64
│   │   ├── 1
│   │   │   ├── prediction
│   │   │   │   ├── coords (40320, 2) float32
│   │   │   │   ├── data (40320, 1, 1) float32
│   │   │   │   ├── geoinfo (40320, 0) float32
│   │   │   │   └── times (40320,) datetime64
│   │   │   └── target
│   │   │       ├── coords (40320, 2) float32
│   │   │       ├── data (40320, 1) float32
│   │   │       ├── geoinfo (40320, 0) float32
│   │   │       └── times (40320,) datetime64
...
│   └── latent
│          ├── 0                                                                                                                                                                                                      
│          │   ├── coords (12288, 2) float32                                                                                                                                                                          
│          │   ├── geoinfo (12288, 0) float32                                                                                                                                                                         
│          │   ├── tokens (12288, 2048) float32                                                                                                                                                                 
│          │   ├── class_token (0, 2048) float32                                                                                                                                                         
│          │   ├── register_tokens (0, 2048) float32                                                                                                                                                     
│          │   └── times (12288,) datetime64 
...

Going further, we can visualize the latent vector:

unzip validation_chkpt00000_rank0000.zip -d test.zarr

import zarr
import cartopy.crs as ccrs
import matplotlib.pyplot as plt

z = zarr.open("test.zarr", mode="r", zarr_version=3)

coords = z["0/latent/0/coords"][:]
latent = z["0/latent/0/latent_state"][:]

lat = coords[:,0]
lon = coords[:,1]
values = latent[:,0]

fig = plt.figure(figsize=(12,6))
ax = plt.axes(projection=ccrs.PlateCarree())

sc = ax.scatter(lon, lat, c=values, s=10, transform=ccrs.PlateCarree())

ax.coastlines()
plt.colorbar(sc, label="latent feature 0")

plt.savefig("latent_feature_0.png", dpi=300)

Healpix level 4:
latent_feature_0

Healpix level 5:
latent_time

Issue Number

Closes #1849

Is this PR a draft? Mark it as draft.

Checklist before asking for review

  • I have performed a self-review of my code
  • My changes comply with basic sanity checks:
    • I have fixed formatting issues with ./scripts/actions.sh lint
    • I have run unit tests with ./scripts/actions.sh unit-test
    • I have documented my code and I have updated the docstrings.
    • I have added unit tests, if relevant
  • I have tried my changes with data and code:
    • I have run the integration tests with ./scripts/actions.sh integration-test
    • (bigger changes) I have run a full training and I have written in the comment the run_id(s): launch-slurm.py --time 60
    • (bigger changes and experiments) I have shared a hegdedoc in the github issue with all the configurations and runs for this experiments
  • I have informed and aligned with people impacted by my change:
    • for config changes: the MatterMost channels and/or a design doc
    • for changes of dependencies: the MatterMost software development channel

@clessig

clessig commented Feb 18, 2026

Copy link
Copy Markdown
Collaborator

@evenmn : can you please make sure the linter and unit tests pass.

@clessig

clessig commented Feb 18, 2026

Copy link
Copy Markdown
Collaborator

@grassesi : can you have a look at the changes in io.py. How far are you with refactoring the output writing?

@grassesi grassesi 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.

Overall I like the idea of treating latent output just as another stream. One thing that needs to be addressed is that there is some masking logic that prevents the latent stream from trying to be processed during evaluation (esp. the to_xarray call will not work). Otherwise just some stylistic remarks.
I saw in the Issue that the latents should be eventually exposed via an JSON API? Would it make sense to already implement this and not try to piggyback on ZarrIO?

Comment on lines +618 to +625
# additionally yield latent output items if a latent stream name was provided
if self.latent_stream_name is not None and self.latents:
for s, fo_s in itertools.product(self.samples, self.forecast_steps):
key = ItemKey(int(s), int(fo_s), self.latent_stream_name)
latent_item = self._make_latent_item(key)
if latent_item is not None:
yield latent_item

@grassesi grassesi Feb 19, 2026

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.

Please try to wrap this logic into the above for iteration: having two yielding for loops works but is confusing at best. I see two alternatives:

  1. Mix the writing of latent items into the normal loop. Something like
for s, fo_s, fi_s in itertools.product(
        self.samples, self.forecast_steps, self.streams.keys()
):
    key = ItemKey(int(s), int(fo_s), fi_s)
    if fi_s == LATENT_STREAM:
        latent_item = self._make_latent_item(key)
        if latent_item is not None:
            yield latent_item
    else:
        yield self.extract(ItemKey(int(s), int(fo_s), fi_s))
  1. have the writing of latent items in a separate method:
def latent_items(self):
    if self.latents:
        for s, fo_s in itertools.product(self.samples, self.forecast_steps):
            key = ItemKey(int(s), int(fo_s), LATENT_STREAM)
            latent_item = self._make_latent_item(key)
            if latent_item is not None:
                yield latent_item

...
with zarrio_writer(config.get_path_results(cf, mini_epoch)) as zio:
    for subset in data.items():
        zio.write_zarr(subset)
    for latent in data.latent_items():
        zio.write_zarr(latent)

Option 2. is maybe a bit more clearer and more equivalent to the current solution and also provides more flexibility for the future. But Option 1 would be also fine with me.

Comment thread src/weathergen/utils/validation_io.py Outdated
Comment thread src/weathergen/utils/validation_io.py
Comment on lines +590 to +591
# optional name to use for latent pseudo-stream when yielding latent items
latent_stream_name: str | 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.

Please use a named constant for this.

@github-project-automation github-project-automation Bot moved this to In Progress in WeatherGen-dev Feb 19, 2026
@github-actions github-actions Bot added data Anything related to the datasets used in the project infra Issues related to infrastructure model Related to model training or definition (not generic infra) labels Feb 19, 2026
@evenmn

evenmn commented Feb 19, 2026

Copy link
Copy Markdown
Contributor Author

Overall I like the idea of treating latent output just as another stream. One thing that needs to be addressed is that there is some masking logic that prevents the latent stream from trying to be processed during evaluation (esp. the to_xarray call will not work). Otherwise just some stylistic remarks. I saw in the Issue that the latents should be eventually exposed via an JSON API? Would it make sense to already implement this and not try to piggyback on ZarrIO?

Thanks for your feedback. Exposing the latent space via an JSON API is useful when running the model operationally. However, I still think we should export the latent state as a Zarr file, since this is useful for other applications, for instance explanable AI.

@clessig

clessig commented Feb 19, 2026

Copy link
Copy Markdown
Collaborator

Overall I like the idea of treating latent output just as another stream. One thing that needs to be addressed is that there is some masking logic that prevents the latent stream from trying to be processed during evaluation (esp. the to_xarray call will not work). Otherwise just some stylistic remarks. I saw in the Issue that the latents should be eventually exposed via an JSON API? Would it make sense to already implement this and not try to piggyback on ZarrIO?

Thanks for your feedback. Exposing the latent space via an JSON API is useful when running the model operationally. However, I still think we should export the latent state as a Zarr file, since this is useful for other applications, for instance explanable AI.

Yes, json API is a separate issue and this PR should address writing the latent space to disc as zarr.

@clessig clessig left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Overall looks good. But validation_io will be refactored and it should be discussed how to best do the latent output going forward.

Comment thread src/weathergen/utils/validation_io.py Outdated
output_streams = {}
for name in output_stream_names:
if name == "latent":
latent_stream_name = name

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't understand the logic here. Wouldn't it be enough to have

if "latent" in output_stream_names

in l 158? Do we expect to have have multiple latent states?

Comment thread src/weathergen/utils/validation_io.py Outdated
per_sample = {}
for lname, lval in latent_pred.items():
if isinstance(lval, LatentState):
for field in ("z_pre_norm", "patch_tokens", "register_tokens", "class_token"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The latent state that should be relevant for the output are the patch_tokens. These are used for the decoder. To be fully future proof we could have an argument which part of LatentState is written although it might be over-engineering.

Comment thread src/weathergen/utils/validation_io.py Outdated

# collect latent outputs per forecast step and per sample (optional)
latents_all = []
if latent_stream_name is not None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This should go to a separate function.

Comment on lines +591 to +594
latent_stream_name: str | None = None
latent_stream_name: str | None = LATENT_STREAM

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.

Please remove: since you are always using the default here anyway it makes no difference if self.latent_stream_name or LATENT_STREAM is used. But using using latent_stream_name clutters up the namespace/interface of OutputBatchData

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.

self.latent_stream_name is currently an alias to LATENT_STREAM which is never None. So please just use if self.latents (This should not disregard my previous comment on these lines.)

Comment thread src/weathergen/utils/validation_io.py Outdated
stream_names = [stream.name for stream in cf.streams]
# include known pseudo-stream names (e.g. latent) so they are treated as known
if io.LATENT_STREAM not in stream_names:
stream_names.append(io.LATENT_STREAM)

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.

Please remove and just use:

if io.LATENT_STREAM in output_stream_names:
    output_streams[io.LATENT_STREAM] = None

in ll. 136

Comment thread src/weathergen/utils/validation_io.py Outdated
for name in output_stream_names:
if name == "latent":
if name == io.LATENT_STREAM:
latent_stream_name = name

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.

Please remove this if clause, instead implement the suggestion I commented above

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.

Please use io.LATENT_STREAM here.

@grassesi

Copy link
Copy Markdown
Contributor

Overall looks good. But validation_io will be refactored and it should be discussed how to best do the latent output going forward.

The choosen approach should translate relatively well into the refactored version.

@evenmn

evenmn commented Feb 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks to both of you for the feedback, it truly improved this PR. I believe I have incorporated the suggested changes in my latest commit, but I still need to test the implementation before it gets merged in.

@clessig

clessig commented Feb 23, 2026

Copy link
Copy Markdown
Collaborator

Thanks to both of you for the feedback, it truly improved this PR. I believe I have incorporated the suggested changes in my latest commit, but I still need to test the implementation before it gets merged in.

Can you please test it as far as you can, and then I have a final look.

@tjhunter tjhunter added the app label Feb 26, 2026
Signed-off-by: evenmn <[email protected]>
@MatKbauer

MatKbauer commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Do the written latents have a notion of space and time, i.e., are latent healpix tokens attributed with their lat/lon and time coordinates?
EDIT: Found coords and time here. Looks good to me. @evenmn, can you update to develop once more so that we can merge it?

@evenmn

evenmn commented Apr 23, 2026

Copy link
Copy Markdown
Contributor Author

Do the written latents have a notion of space and time, i.e., are latent healpix tokens attributed with their lat/lon and time coordinates?

@MatKbauer It comes with lat/lon, you can see the output dimensions in the first comment above. The time dimension is wrong (it has coordinate dimensions), but so is the time dimension in the ERA5 outputs

…r-writer

# Conflicts:
#	integration_tests/small1.yaml
#	integration_tests/small1_test.py
#	packages/common/src/weathergen/common/config.py
#	src/weathergen/train/trainer.py

@MatKbauer MatKbauer 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.

I successfully wrote some latent states to disk and appreciate they are contained seamlessly in the validation_chkpt00000_rank0000.zip.

Writing the latents with the 6x1h model resulted in some conflicts and I have tested and proposed a solution provided by Claude. There are some other small comments still, please have a look @evenmn.

With the new .zip instead of .zarr output, I'd suggest to update the PR description where the written latents can be plotted as follows:

store = ZipStore("validation_chkpt00000_rank0000.zip", mode="r")
z = zarr.open_group(store=store, zarr_format=3, mode="r")

That is, add the open_group explicitly to be compatible with the .zip format, which now is the default.

Also, please add a note to the PR description telling how latents can be written, i.e., when passing an according stream info to the inference command, such as

uv run inference ... --options test_config.output.streams=[ERA5,latent]

Edit: just realised that it is mentioned already in the description. Maybe make it more explicit with the uv run inference prefix.

@clessig, can you still have a look and merge if you agree?

Comment thread packages/common/src/weathergen/common/io.py Outdated
Comment thread src/weathergen/utils/validation_io.py Outdated
Comment thread src/weathergen/utils/validation_io.py
Comment thread src/weathergen/utils/validation_io.py Outdated
evenmn and others added 6 commits May 8, 2026 14:06
Removed group initialization in latent writer

Co-authored-by: Matthias Karlbauer <[email protected]>
Move and clean up group attributes dict

Co-authored-by: Matthias Karlbauer <[email protected]>
Remove duplicated lines

Co-authored-by: Matthias Karlbauer <[email protected]>
Signed-off-by: evenmn <[email protected]>
@grassesi grassesi mentioned this pull request Jun 8, 2026
7 tasks

@MatKbauer MatKbauer 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 updating this, @evenmn! I have tested it again successfully and found the latents in the validation.zarr. Before we can merge, please address the comments I've left.

datasets.append(ds)

# TODO: missing forecast offset
return OutputItem(key=key, forecast_offset=None, latent=datasets)

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.

Is there a reason for which we don't have forecast_offset included here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The idea here was that the latent-only items do not need the offset for target/prediction inclusion. If forecast_offset is 1 and forecast_step is 0, the offset_key method will return -1, which can cause strange behavior. I may be wrong

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.

I understand. The core question is: Do we want to write the initial condition t_0 as well when writing forecast latents, or do we start writing at $t_0+\Delta t$. The current setup would not write t_0, which is in line with running a forecast after all.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, I think you're right. For a 40 step forecast the latent states are indexed 0 to 39, which might be confusing since index 0 is actually $t_0+\Delta t$. I think it would be useful to include the initial encoded state ($t_0$) as well, and have indices 0 to 40. What do you think?

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.

Good idea, to include the IC. This might mess up definitions at other places, though, as in forecast mode with offset: 1, we only write predictions and targets starting at $t_0 + \Delta t$. @clessig suggested to only start writing the latent at $t_0 + \Delta t$ for consistency. If we want to write latents for the initial condition, we have to run inference differently, e.g., not in forecast mode.

That is, when running in forecast mode, we want to have everything in step 0 to be empty. No predictions, targets, and latents.

If I'm not mistaken, we could realise this by changing this line in the latent writer from

now

for t_idx, latents_for_step in enumerate(data.latents):

to

for t_idx, latents_for_step in enumerate(data.latents, start=data.forecast_offset):

Comment thread src/weathergen/utils/validation_io.py Outdated
Comment thread src/weathergen/utils/validation_io.py Outdated
Comment thread src/weathergen/utils/validation_io.py Outdated
Comment thread src/weathergen/utils/validation_io.py Outdated
Comment thread src/weathergen/utils/validation_io.py
@github-actions github-actions Bot added the eval anything related to the model evaluation pipeline label Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app data Anything related to the datasets used in the project eval anything related to the model evaluation pipeline infra Issues related to infrastructure model Related to model training or definition (not generic infra)

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

Add support for exporting latent space from WeatherGenerator

7 participants