Enable personalized (ditto) methods on NNunetClient#390
Conversation
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #390 +/- ##
==========================================
+ Coverage 76.84% 77.05% +0.20%
==========================================
Files 155 155
Lines 9464 9470 +6
==========================================
+ Hits 7273 7297 +24
+ Misses 2191 2173 -18 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
2c756e9 to
34f35c7
Compare
NNunetClient
scarere
left a comment
There was a problem hiding this comment.
I don't think I got to review the Ditto Mixin stuff so I'm missing a bit of context, but overall looks good. My main comments are just with the aim of making all BasicClients personalizable by default without any special consideration needed.
| target (TorchTargetType): the targets generated by the dataloader to evaluate the preds with | ||
| metric_manager (MetricManager): the metric manager to update | ||
| """ | ||
| preds = {k: v for k, v in preds.items() if "local" in k} |
There was a problem hiding this comment.
Maybe add comments explaining what is going on here. Without the context that this is to support pFL its kind of confusing.
There was a problem hiding this comment.
Yea good point. I'm going to wait and see how to resolve the "global" vs "local" optimizer key stuff.
Ditto refers to the "global" as for the global model and "local" is for the clients local model...
There was a problem hiding this comment.
Again maybe rather than just having a assumed key in all of our clients, the key should be an argument?
0f60535 to
2ba4f7d
Compare
a9147f4 to
d5e139d
Compare
emersodb
left a comment
There was a problem hiding this comment.
I think you're still in the midst of some refactors, but left some comments as food for thought as you go through 🙂
| compile: bool = True, | ||
| intermediate_client_state_dir: str | None = None, | ||
| client_name: str | None = None, | ||
| personalized_strategy: Literal["ditto"] | None = None, |
There was a problem hiding this comment.
We probably want an enum with the set of supported personalization strategies rather than a string literal?
There was a problem hiding this comment.
Yeah, I haven't used pure enum with argparser before. So this was a quick and easy way to do this, which I felt was okay given this is just an example anyway versus being in the library code itself. But can look to see how to do this for enum if we really want to do it here.
There was a problem hiding this comment.
It's really minor, but you could just do the conversion after parsing the args but before passing it along to the main function. That way we get good typing here and you don't need anything in the argparse.
| n_server_rounds: 1 | ||
|
|
||
| # Parameters that describe the clients | ||
| n_clients: 1 |
There was a problem hiding this comment.
Perhaps we can change this one to have 2 clients to make sure it runs that way?
There was a problem hiding this comment.
Yeah, this just a mirror copy of our existing nnunet example, but we can switch to 2 clients for both.
There was a problem hiding this comment.
I'm good with leaving 1 of them as is and just switching this guy to 2
emersodb
left a comment
There was a problem hiding this comment.
@nerdai: I've only gotten part of the way through the PR, but I think we might want to talk a bit about the design. I'm a bit worried that the functionality with all the private functions is getting a bit fractious and knotted. I just want to make sure I understand the ideas a bit better before we commit to this.
| # NnUNetClient With Personalization Example | ||
|
|
||
| Building on the [nnunet_example](../nnunet_example/README.md), here we demonstrate how personalized | ||
| methods can be applied to `NnUNetClient` class. The config requirements remain the same as in the original |
There was a problem hiding this comment.
"to the NnUNetClient class."
| from fl4health.utils.msd_dataset_sources import get_msd_dataset_enum, msd_num_labels | ||
| from fl4health.utils.nnunet_utils import get_segs_from_probs, set_nnunet_env | ||
|
|
||
| personalized_client_classes = {"ditto": make_it_personal(NnunetClient, PersonalizedMode.DITTO)} |
There was a problem hiding this comment.
Super minor, but should this just be class rather than classes?
| compile: bool = True, | ||
| intermediate_client_state_dir: str | None = None, | ||
| client_name: str | None = None, | ||
| personalized_strategy: Literal["ditto"] | None = None, |
There was a problem hiding this comment.
It's really minor, but you could just do the conversion after parsing the args but before passing it along to the main function. That way we get good typing here and you don't need anything in the argparse.
| client_name: str | None = None, | ||
| personalized_strategy: Literal["ditto"] | None = None, | ||
| ) -> None: | ||
| with torch.autograd.set_detect_anomaly(True): |
There was a problem hiding this comment.
I've never seen this before. Do you mind explaining why we need it here? It might also be worth a comment as well.
| checkpoint_and_state_module = None | ||
|
|
||
| # Create client | ||
| client_kwargs: dict[str, Any] = {} |
There was a problem hiding this comment.
Any reason to create an empty dict and then update rather than just outright defining the dictionary?
| checkpoint_and_state_module=checkpoint_and_state_module, | ||
| client_name=client_name, | ||
| ) | ||
| if personalized_strategy: |
There was a problem hiding this comment.
Calling this a strategy may be slightly confusing, as Flower defines strategies as purely "aggregation-based" and we have adopted this nomenclature for consistency. However, Ditto isn't an aggregation-based modification. Maybe just method, technique, or algorithm?
| n_server_rounds: 1 | ||
|
|
||
| # Parameters that describe the clients | ||
| n_clients: 1 |
There was a problem hiding this comment.
I'm good with leaving 1 of them as is and just switching this guy to 2
| self.grad_scaler.unscale_(self.optimizers["global"]) | ||
| self.transform_gradients(losses) | ||
| self.grad_scaler.unscale_(optimizer) | ||
| self._transform_gradients_with_model(model, losses) |
There was a problem hiding this comment.
Shouldn't this be calling transform_gradients?
|
|
||
| # Forward pass on both the global and local models | ||
| preds, features = self.predict(input) | ||
| log(DEBUG, f"PRED KEYS from train_step: {preds.keys()}") |
There was a problem hiding this comment.
Perhaps we can drop this?
|
|
||
| # Call user defined methods to get predictions and compute loss | ||
| preds, features = self.predict(input) | ||
| preds, features = self._predict_with_model(model, input) |
There was a problem hiding this comment.
It looks like we're essentially circumventing the prediction function here, meaning that this predict function isn't called anywhere anymore, which it a bit odd.
I'm also a bit concerned that the flow of these private functions has become a bit hard to follow because there are public and private functions weaving in and out. We're also not quite being consistent with when and how these are being called, especially when inheriting (see transform_gradients comment in nnunet). If I want to change how gradients are transformed, should I override the private or public functionality?
I don't really remember what required us to move in this direction but I'm worried we'll under up with these kinds of private functions for all of our core functions and it will become a bit spaghetti.
There was a problem hiding this comment.
Yeah this is for added flexibility, which I think is actually a nice (standard) way to handle what we needed to do.
-
The private methods are internal and more flexible now with the injection of model dependency. This is flexible in the sense should we need to change any of these private methods, then we can and can do so in a way that doesn't break the public API, namely
predict(). Its not so much circumventing from the user perspective, but giving us library developers more flexibility. -
We needed to go this route to be able to essentially re-use the already established logic contained in our existing clients when apply ditto and future mixins. With this internal method, we (as library devs) can make use of our private methods and supply local/global models. Without this, we can't do this and we have to resort to the alternative which is DittoClient and then subclasses of DittoClient and the other client, which I think leads to a lot of repeat code and class explosion.
-
What should eventually happen is that we move all existing clients to implement their private methods. There is no rush to do this as this isn't a breaking change, but we should move it at some point so its clear to devs in the future on how to create clients—namely, if the private method exists, implement those helpers. Actually, methods that don't have these private implementations cannot be adapted with Ditto or future mixins that would depend on them-an AtributeError would be raised.
Happy to discuss more on it, but I think this is okay and from my experience a pretty typical thing to do.
There was a problem hiding this comment.
I think from my perspective the main issue here is that we're not doing it cleanly. For example, the predict function is here is being orphaned. It isn't called anywhere anymore. It's also unclear whether these private functions should directly call the other private functions like _transform_gradients_with_model or they should call transform_gradients. We're doing it one way in BasicClient and another in the NnUNetClient.
On point three. I think leaving it in a halfway state isn't very good. It makes knowing how to do things in the library quite odd.
Let's talk about this a together tomorrow, but I'm starting to feel inclined that supporting mixins is becoming more trouble than it's worth. Adapting the current NNUnet client to a Ditto version, while duplicating some code, would have been a much more straightforward process.
PR Type
Feature
Short Description
The mixin implementation of ditto was introduced in previous PRs. In this PR, we adjust
NNunetClient, in non-breaking fashion, in order for it to be personalizable via Ditto.Also added a new example
examples/nnunet_pfl_examplewhich takes the same model and dataset ofexamples/nnunetbut is meant for the application of pfl methods — for this PR, ditto.UPDATE (05-30-2025)
In this latest iteration, I've added some new private helper methods into the interface of
BasicClientin order to permit personalization via mixins more easily and clearly.Added helper methods in
BasicClientSubclasses of
BasicClientshould implement these if there is need to specialize their logic. The associated public methods simply just call these with default values for model (self.model) and optimizer (self.optimizers["global"]) — as a result this is non-breaking._predict_with_model()_train_step_with_model_and_optimizer()_apply_backwards_on_losses_and_take_step()— called within private train step helper_compute_preds_and_losses()— called within private train step helper_val_step_with_model()_transform_gradients_with_model()— allowing for model injection for transforming gradientsMixin Development
For mixins, which are not a subclasses of
BasicClient, we should not be overriding the private methods of the previous section, but we can call them. This is appropriate since with our mixins we really want to make use of the core logic for train, predict, and validation, and only want to make additions on top of the artifacts produced by these.Mixins should instead override the public api of
train_step()andval_step()to implement their additive logic.For example, with the Ditto mixin, we want to call these private methods for both global and local models/optimizers and then only combine their artifacts within the public apis of
train_step()andval_step().NOTE: I've adapted both our Ditto mixin and the Adaptive Constraint mixin to conform to this new standard for developing mixins.
Tests Added