Skip to content

CRPS decoder integrated in latest develop - #2578

Closed
saibr wants to merge 10 commits into
ecmwf:developfrom
saibr:crps_decoder_v2
Closed

CRPS decoder integrated in latest develop#2578
saibr wants to merge 10 commits into
ecmwf:developfrom
saibr:crps_decoder_v2

Conversation

@saibr

@saibr saibr commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Description

This is the cleaned CRPS decoder code integrated in the latest develop on 1/7. It is based on the code from https://github.com/saibr/WeatherGenerator/tree/crps-decoder. This version should be easy to merge without conflicts.

In the config, the following lines should be added to use the CRPS decoder:

decoder_ens_latent_perturbation : 
  num_members: 2    # 0 / 1 = disabled; >1 = number of ensemble members
  sigma_init: 0.2  # initial noise standard deviation
  sigma_learnable: True  # true = nn.Parameter; false = fixed buffer

Besides the changes on the decoder, I made a modification on the loss function, to make sure the model can run with the kernel_crps loss without including the mse loss, by using the following setup:

losses : {
    "physical": {
        type: LossPhysical,
        loss_fcts: {"mse": null, "kernel_crps": {}, },
        },
    }

In case there is an alternative for this that I missed, these modifications can be discarded.

@github-actions github-actions Bot added the model Related to model training or definition (not generic infra) label Jul 1, 2026

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

Thanks for the implementation. Left some comments: I think the code can be a bit tidier overall. Three questions:

  1. Should we generate a warning if training is with multiple ens members and CRPS perturbations but without CRPS loss (or another ens loss)?
  2. Do we know how the memory requirements change with the CRPS decoder?
  3. Do we have any training results?

For the config, can we make it:

decoder_ens_latent_perturbation : 
  num_members: 2    # 0 / 1 = disabled; >1 = number of ensemble members
  sigma_init: 0.2  # initial noise standard deviation
  sigma_learnable: True  # true = nn.Parameter; false = fixed buffer

This is clearer and more composable (we still need to refactor the model config into dicts).

(s[0] * s[1] + 1,), fill_value=9, dtype=torch.int32, device=tokens_nbors.device
)
tokens_nbors_lens[0] = 0
# remove register and class tokens

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.

Can we encapsulate this into a separate function

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.

Which lines are you referring to for the separate function? This comment refers only to the single line below

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 whole block that is green (which I think is a rewrite of some code that was there before and some new code).

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.

I created _build_ensemble_latent_tokens for this

Comment thread src/weathergen/model/model.py Outdated
# remove register and class tokens
tokens = tokens[:, self.num_aux_tokens:]

B = len(batch)

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 single letter vars make the code more difficult to read

Comment thread src/weathergen/model/model.py Outdated
tokens_tiled = tokens
assert tokens_tiled.shape == (M * B, H * Q, D)

MB = M * B

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.

Is this variable really useful? Again, short var name is an issue

Comment thread src/weathergen/model/model.py Outdated
tokens_nbors = tokens_flat[idxs.flatten()].flatten(0, 1) # [MB*H*9*Q, D]

tokens_nbors_lens = tokens_flat.new_zeros(MB * H + 1, dtype=torch.int32)
tokens_nbors_lens[1:] = 9 * Q

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.

We should not hard-code 9 but have a class-level variable with a useful name, e.g. hp_num_nbors

Comment thread src/weathergen/model/model.py Outdated
]
t_coords_lens = [len(t) for t in t_coords]
t_coords = torch.cat(t_coords)
P = t_coords.shape[0]

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.

Again, single letter var name.

Comment thread src/weathergen/model/model.py Outdated
# Latent-perturbation noise scale (learnable or fixed)
# torch.zeros() is used (not torch.tensor) so this respects the meta-device context used by FSDP. The actual value is filled in reset_parameters().
num_mem = cf.get("latent_perturbation_num_members", 0)
if num_mem > 1:

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.

why not if cf.get("latent_perturbation_num_members", 0) > 1 :

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.

With the changed config, we should use

if cf.get("decoder_ens_latent_perturbation") is not None

or we check in the constructor and have a class variable for this.

Args:
model_params : Query and embedding parameters
fstep : Number of forecast steps
step : Forecast step

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.

Why not keep fstep?

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.

predict_decoders takes step as input, not fstep. This is not something I changed, it is used in the latest develop. I believe the comment wasn't updated before.

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.

Ok, thanks

Comment thread src/weathergen/model/model.py Outdated
eps = torch.randn(M, B, H * Q, D, device=tokens.device, dtype=tokens.dtype)
tokens_tiled = (tokens.unsqueeze(0) + sigma * eps).reshape(M * B, H * Q, D)
else:
M = 1

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.

Avoid by M = self.cf.get("latent_perturbation_num_members", 1) above. But I think it should be valid to have self.cf.get("latent_perturbation_num_members", 1) and then we also add noise for decoding.

Comment thread src/weathergen/model/model.py Outdated
# fully vectorised. Layout convention: M is the OUTER axis, B is INNER.
# tokens_tiled[m*B + b] belongs to member m, batch item b
M = self.cf.get("latent_perturbation_num_members", 0)
if M > 1 and self.latent_perturbation_log_sigma 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.

Maybe really good to have a class variable if CRPS-perturbs are used or not

)
tokens_nbors_lens[0] = 0
# remove register and class tokens
tokens = tokens[:, self.num_aux_tokens:]

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.

If we call this tokens_tiled (or a bit later) then we don't need the else branch in 803

@clessig

clessig commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

@saibr : can you also lint (this might also complain about single letter variables ;)) and open a PR.

@saibr

saibr commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for all the comments @clessig , I incorporated them. I am not sure how to deal with the scenario of num_members: 1. I added noise to it, but the current implementation of the kernel crps loss does not allow num_members to be 1. Let me know if you have a specific use case for this in mind and I can adapt it accordingly.

Comment thread src/weathergen/model/model.py Outdated
# Latent-perturbation noise scale (learnable or fixed)
self.use_latent_perturbation = (
self.decoder_ens_latent_perturbation is not None
and self.decoder_ens_latent_perturbation.get("num_members", 0) >= 1

@shmh40 shmh40 Jul 2, 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.

Should this be > 1? Do we only want this to work when the user has specified multiple members? What happens if they set it as 1? Do you get a perturbed single member?

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.

Otherwise the implementation looks ok to me, thanks @saibr !

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.

@shmh40 Christian mentioned in a comment that perturbation on 1 member would be a valid case, so I included it, but I'm not sure about the use case. The current implementation of the crps loss does not accept one member, so in my first implementation I discarded the scenario with 0 and 1 members.

@github-actions github-actions Bot added the eval anything related to the model evaluation pipeline label Jul 9, 2026

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

Thanks for the changes. Some more comments.

Comment thread src/weathergen/model/model.py Outdated
f"expected {num_members * t_coords.shape[0]} pts in pred, "
f"got {total_target_points}"
)
pred = (

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 statement cuts across 12 lines and is extremely difficult to read for me. Can we precompute the shapes to get this statement in 1 or 2 lines

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.

Changed in latest commit

if self.cf.decoder_type == "Linear":
# repeat target-coord tokens once per ensemble member,
# matching tokens_tiled's ordering
tc_tokens_in = tc_tokens.repeat(num_members, 1)

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.

Can't we repeat the output of line 983 instead of repeating the input? There is no ensembling happening here. Otherwise, the if self.cf.decoder_type == "Linear": branch should be in line 1002, I think.

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.

I believe we cannot remove the repeat output instead if the input, the ensemble component is in tokens_tiled which is create by _build_ensemble_latent_tokens. If we repeat the output num_members times, we get the same prediction for each member

tc_tokens_outs = []
for m in range(num_members):
member_tokens = tokens_tiled[m * batch_size: (m + 1) * batch_size]
tokens_nbors, tokens_nbors_lens = self._gather_neighbor_tokens(

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.

thank you

Comment thread src/weathergen/model/model.py Outdated
(s[0] * s[1] + 1,), fill_value=9, dtype=torch.int32, device=tokens_nbors.device
)
tokens_nbors_lens[0] = 0
ens = self._build_ensemble_latent_tokens(model_params, tokens, batch)

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.

It looks like EnsembleLatentTokens is only used as return type for the function. Then we should not introduce it and just return a tuple/list with the values.

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.

I agree, changed

Comment thread src/weathergen/model/model.py Outdated
)

if self.use_latent_perturbation:
if self.decoder_ens_latent_perturbation.get("sigma_learnable", True):

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 branches differ just by requires_grad. We can save a few lines and make the code more readable by having one statement and just having the flag conditional (either using a temp variable or by directly having self.decoder_ens_latent_perturbation.get("sigma_learnable", True) there. (self.decoder_ens_latent_perturbation is very long and leads to a lot of line breaks that make the code difficult to read; maybe use a shorter name, e.g. self.ens_latent_perturb

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 thanks, changed it

Comment thread src/weathergen/model/model.py Outdated
)

tokens_tiled = tokens
if self.use_latent_perturbation:

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.

Could one return from this function immediately at the beginning if self.use_latent_perturbation == False

@clessig clessig mentioned this pull request Jul 13, 2026
4 tasks
@clessig

clessig commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Closes via #2623

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

Labels

eval anything related to the model evaluation pipeline model Related to model training or definition (not generic infra)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants