Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
56f4982
private methods
nerdai Jun 9, 2025
5799a34
implement subclass init method for validation
nerdai Jun 9, 2025
e39be07
pin jupyter-core due to pip audit vulnerability
nerdai Jun 9, 2025
b901844
pin jupyter-core due to pip audit vulnerability
nerdai Jun 9, 2025
cec0e81
update perfcl
nerdai Jun 9, 2025
7cf1713
bring in updated nnunet client
nerdai Jun 9, 2025
4d839f3
update basic client protocol
nerdai Jun 9, 2025
bb38df5
add tests for warnings being raised
nerdai Jun 9, 2025
25bd640
add flexible client
nerdai Jun 16, 2025
8667c45
revert ditto and perfcl clients
nerdai Jun 16, 2025
432a84a
wip
nerdai Jun 16, 2025
9702b13
repeated
nerdai Jun 16, 2025
338c5f6
rm datasets
nerdai Jun 16, 2025
102e6e6
poetry lock from main
nerdai Jun 16, 2025
3f3bf8b
_predict_with_model to public interface
nerdai Jun 16, 2025
22c7c93
predict_with_model to predict
nerdai Jun 16, 2025
0f701aa
Revert "predict_with_model to predict"
nerdai Jun 16, 2025
767dd3b
add smoke tests for mnist client dynamic
nerdai Jun 17, 2025
4d8d197
test train step adaptive
nerdai Jun 17, 2025
2a78759
more coverage
nerdai Jun 17, 2025
85eb62d
more coverage
nerdai Jun 17, 2025
5dea6d7
no cover to test
nerdai Jun 17, 2025
fad0d39
no cover to test
nerdai Jun 17, 2025
6bf65e7
cover again
nerdai Jun 17, 2025
36ac8ce
dont cover but tests do
nerdai Jun 17, 2025
2bcc803
add epochs smoke test:
nerdai Jun 19, 2025
2317ba2
wip
nerdai Jun 20, 2025
5f67019
revert back
nerdai Jun 20, 2025
c6e827d
fmt
nerdai Jun 20, 2025
29167c5
fmt
nerdai Jun 20, 2025
b9124f4
cr
nerdai Jun 20, 2025
f0fca19
cr
nerdai Jun 20, 2025
5969f4f
dry
nerdai Jun 20, 2025
bf3f2a9
bring back transform_gradients_with_model
nerdai Jun 20, 2025
440b491
more dry
nerdai Jun 20, 2025
351dd67
cr
nerdai Jun 21, 2025
b2f6ac4
cr
nerdai Jun 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions examples/ditto_example/client_dynamic.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from torch.utils.data import DataLoader

from examples.models.cnn_model import MnistNet
from fl4health.clients.basic_client import BasicClient
from fl4health.clients.flexible_client import FlexibleClient
from fl4health.mixins.personalized import PersonalizedMode, make_it_personal
from fl4health.reporting import JsonReporter
from fl4health.utils.config import narrow_dict_type
Expand All @@ -22,8 +22,8 @@
from fl4health.utils.sampler import DirichletLabelBasedSampler


class MnistClient(BasicClient):
"""A simple `BasicClient` type that we dynamically personalize via Ditto."""
class MnistClient(FlexibleClient):
"""A simple `FlexibleClient` type that we dynamically personalize via Ditto."""

def get_data_loaders(self, config: Config) -> tuple[DataLoader, DataLoader]:
sample_percentage = narrow_dict_type(config, "downsampling_ratio", float)
Expand Down
326 changes: 326 additions & 0 deletions fl4health/clients/flexible_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
import warnings
from collections.abc import Sequence
from logging import WARN
from pathlib import Path
from typing import Any

import torch
from flwr.common.logger import log
from torch import nn
from torch.optim import Optimizer

from fl4health.checkpointing.client_module import ClientCheckpointAndStateModule
from fl4health.clients.basic_client import BasicClient
from fl4health.metrics.base_metrics import Metric
from fl4health.reporting.base_reporter import BaseReporter
from fl4health.utils.losses import EvaluationLosses, LossMeterType, TrainingLosses
from fl4health.utils.typing import TorchFeatureType, TorchInputType, TorchPredType, TorchTargetType


class FlexibleClient(BasicClient):
def __init__(
self,
data_path: Path,
metrics: Sequence[Metric],
device: torch.device,
loss_meter_type: LossMeterType = LossMeterType.AVERAGE,
checkpoint_and_state_module: ClientCheckpointAndStateModule | None = None,
reporters: Sequence[BaseReporter] | None = None,
progress_bar: bool = False,
client_name: str | None = None,
) -> None:
"""
Flexible FL Client with functionality to train, evaluate, log, report and checkpoint.

`FlexibleClient` is similar to `BasicClient` but provides added flexibility through the
ability to inject models and optimizers in the methods responsible for making predictions
and performing both train and validation steps.

This added flexibility allows for `FlexibleClient` to be automatically adapted with our
personalized methods: ~fl4health.mixins.personalized.

As with `BasicClient`, users are responsible for implementing methods:

- ``get_model``
Comment thread
nerdai marked this conversation as resolved.
- ``get_optimizer``
- ``get_data_loaders``,
- ``get_criterion``

However, unlike `BasicClient`, users looking to specialize logic for making predictions,
and performing train and validation steps, should instead override:

- ``predict_with_model``
- ``_train_step_with_model_and_optimizer`` (and its delegated helpers)
- ``_val_step_with_model``

Other methods can be overridden to achieve custom functionality.

Args:
data_path (Path): path to the data to be used to load the data for client-side training
metrics (Sequence[Metric]): Metrics to be computed based on the labels and predictions of the client model
device (torch.device): Device indicator for where to send the model, batches, labels etc. Often "cpu" or
"cuda"
loss_meter_type (LossMeterType, optional): Type of meter used to track and compute the losses over
each batch. Defaults to ``LossMeterType.AVERAGE``.
checkpoint_and_state_module (ClientCheckpointAndStateModule | None, optional): A module meant to handle
both checkpointing and state saving. The module, and its underlying model and state checkpointing
components will determine when and how to do checkpointing during client-side training.
No checkpointing (state or model) is done if not provided. Defaults to None.
reporters (Sequence[BaseReporter] | None, optional): A sequence of FL4Health reporters which the client
should send data to. Defaults to None.
progress_bar (bool, optional): Whether or not to display a progress bar during client training and
validation. Uses ``tqdm``. Defaults to False
client_name (str | None, optional): An optional client name that uniquely identifies a client.
If not passed, a hash is randomly generated. Client state will use this as part of its state file
name. Defaults to None.
"""
super().__init__(
data_path,
metrics,
device,
loss_meter_type,
checkpoint_and_state_module,
reporters,
progress_bar,
client_name,
)

def __init_subclass__(cls, **kwargs: Any) -> None:
"""Perform some validations on subclasses of FlexibleClient."""
super().__init_subclass__(**kwargs)

# check that specific methods are not overridden, otherwise throw warning
methods_should_not_be_overridden = [
(
"predict",
(
f"`{cls.__name__}` overrides `predict()`, but this method should no longer be overridden. "
"Please use `predict_with_model()` instead."
),
),
(
"val_step",
(
f"`{cls.__name__}` overrides `val_step()`, but this method should no longer be overridden. "
"Please use `_val_step_with_model()` instead."
),
),
(
"train_step",
(
f"`{cls.__name__}` overrides `train_step()`, but this method should no longer be overridden. "
"Please use `_train_step_with_model_and_optimizer()` and its helper methods instead "
"for proper customization."
),
),
]

for method_name, msg in methods_should_not_be_overridden:
if method_name in cls.__dict__: # method was overridden by subclass
log(WARN, msg)
warnings.warn(msg, RuntimeWarning, stacklevel=2)

def _compute_preds_and_losses(
self, model: nn.Module, optimizer: Optimizer, input: TorchInputType, target: TorchTargetType
) -> tuple[TrainingLosses, TorchPredType]:
"""
Helper method within the train step for computing preds and losses.

NOTE: Subclasses should implement this helper method if there is a need
to specialize this part of the overall train step.

Args:
model (nn.Module): the model used to make predictions
optimizer (Optimizer): the associated optimizer
input (TorchInputType): The input to be fed into the model.
target (TorchTargetType): The target corresponding to the input.

Returns:
tuple[TrainingLosses, TorchPredType]: The losses object from the train step along with
a dictionary of any predictions produced by the model prior to the
application of the backwards phase
"""
# Clear gradients from optimizer if they exist
optimizer.zero_grad()

# Call user defined methods to get predictions and compute loss
preds, features = self.predict_with_model(model, input)
target = self.transform_target(target)
losses = self.compute_training_loss(preds, features, target)

return losses, preds

def _apply_backwards_on_losses_and_take_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.

Is it necessary for this to be it's own function. It's only 3 lines of code and as far as I can tell the only place it will be called is in _train_step_with_model_and_optimizer. Do you feel there is a need here for this functionality to be separate? If not we can reduce the number of methods in this class by 1, and the number of lines by a decent amount

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Without trying to get this all to work with NNunetClient I would have to agree with you. But when trying to ditto-ify it, I learned that this client specializes this logic and I need to be able to pass the respective model, optimizers and losses.

So, this should be interpreted as us introducing flexibility that will be used in the subsequent PR when we move over NNunetClient to inherit FlexibleClient

self, model: nn.Module, optimizer: Optimizer, losses: TrainingLosses
) -> TrainingLosses:
"""
Helper method within the train step for applying backwards on losses and taking step with optimizer.

NOTE: Subclasses should implement this helper method if there is a need
to specialize this part of the overall train step.

Args:
model (nn.Module): the model used for making predictions. Passed here in case subclasses need it.
optimizer (Optimizer): the optimizer with which we take the step
losses (TrainingLosses): the losses to apply backwards on

Returns:
TrainingLosses: The losses object post backwards application
"""
# Compute backward pass and update parameters with optimizer
losses.backward["backward"].backward()
self.transform_gradients(losses)
optimizer.step()

return losses

def _train_step_with_model_and_optimizer(
self, model: torch.nn.Module, optimizer: Optimizer, input: TorchInputType, target: TorchTargetType
) -> tuple[TrainingLosses, TorchPredType]:
"""
Helper train step method that allows for injection of model and optimizer.

NOTE: Subclasses should implement this method if there is a need to specialize
the train_step logic.

Args:

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 model and optmizer arguments are undefined in this docstring

model (nn.Module): the model used for making predictions. Passed here in case subclasses need it.
optimizer (Optimizer): the optimizer with which we take the step
input (TorchInputType): The input to be fed into the model.
target (TorchTargetType): The target corresponding to the input.

Returns:

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.

nit: Not sure if there is a standard here, but when the return signature for a function is a fixed length tuple containing different return values, I usually seperate each value of the tuple as separate return values with the tuple type being implicit. Ie

"""
Returns:
    losses (TrainingLosses) : The losses object ...
    preds (TorchPredType): dictionary of any predictions ...
"""

Is there a preferred way here?

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 occurs in several other methods in this class, so I'll just let this comment represent all instances of this. I recognize it was probably just copied from BasicClient's docstrings but if my suggested method is preferable then maybe a good idea to start implementing this practice here?

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 like this suggestion in principle, but I don't think it adheres to the "Google docstring" style. It's hard to tell how they "want" you to document returns, but by default the docstrings that are generated follow the tuple typing. The style you're describing is part of the numpy standard I think though. So I'm not 100% sure.

In any case, the rest of the library doesn't break tuples out in the way you're describing at the moment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

In this case, we should keep as is to be standard with rest of library.

tuple[TrainingLosses, TorchPredType]: The losses object from the train step along with
a dictionary of any predictions produced by the model.
"""
losses, preds = self._compute_preds_and_losses(model, optimizer, input, target)
losses = self._apply_backwards_on_losses_and_take_step(model, optimizer, losses)

return losses, preds

def train_step(self, input: TorchInputType, target: TorchTargetType) -> tuple[TrainingLosses, TorchPredType]:
"""
Given a single batch of input and target data, generate predictions, compute loss, update parameters and
optionally update metrics if they exist. (i.e. backprop on a single batch of data).
Assumes ``self.model`` is in train mode already.

Args:
input (TorchInputType): The input to be fed into the model.
target (TorchTargetType): The target corresponding to the input.

Returns:
tuple[TrainingLosses, TorchPredType]: The losses object from the train step along with
a dictionary of any predictions produced by the model.
"""
return self._train_step_with_model_and_optimizer(self.model, self.optimizers["global"], input, target)

def _val_step_with_model(
self, model: nn.Module, input: TorchInputType, target: TorchTargetType
) -> tuple[EvaluationLosses, TorchPredType]:
"""
Helper method for val_step that allows for injection of model.

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.

Docstring missing definition for model argument


NOTE: Subclasses should implement this method if there is a need to
specialize the val_step logic.

Args:
model (nn.Module): the model used for making predictions. Passed here in case subclasses need it.
input (TorchInputType): The input to be fed into the model.
target (TorchTargetType): The target corresponding to the input.

Returns:
tuple[EvaluationLosses, TorchPredType]: The losses object from the val step along with a dictionary of the
predictions produced by the model.
"""
# Get preds and compute loss
with torch.no_grad():
preds, features = self.predict_with_model(model, input)
target = self.transform_target(target)
losses = self.compute_evaluation_loss(preds, features, target)

return losses, preds

def val_step(self, input: TorchInputType, target: TorchTargetType) -> tuple[EvaluationLosses, TorchPredType]:
"""
Given input and target, compute loss, update loss and metrics. Assumes ``self.model`` is in eval mode already.

Args:
input (TorchInputType): The input to be fed into the model.
target (TorchTargetType): The target corresponding to the input.

Returns:
tuple[EvaluationLosses, TorchPredType]: The losses object from the val step along with a dictionary of the
predictions produced by the model.
"""
return self._val_step_with_model(self.model, input, target)

def predict_with_model(

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.

For all the other new "with model" methods, you have redefined the original method to call the new one with the default model. Why didn't you redefine the predict method? Ie.

def predict(self, input):
    return self.predict_with_model(input, self.model)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think we might have discussed this a bit, or something related in the last PR. Mostly this was to not break the public interface that had priorly been established with BasicClient. Now that we have FlexibleClient that inherits BasicClient I'm still inclined not to overload the interface.

We couldn't add model as param to BasicClient because we can't pass a default value, and thus we would have to unfortunately introduce a breaking change.

self, model: torch.nn.Module, input: TorchInputType
) -> tuple[TorchPredType, TorchFeatureType]:
"""
Helper predict method that allows for injection of model.

NOTE: Subclasses should implement this method if there is need to specialize
the predict logic of the client.

Args:
model (torch.nn.Module): the model with which to make predictions
input (TorchInputType): Inputs to be fed into the model. If input is of type ``dict[str, torch.Tensor]``,
it is assumed that the keys of input match the names of the keyword arguments of
``self.model.forward().`

Returns:
tuple[TorchPredType, TorchFeatureType]: A tuple in which the first element contains a dictionary of
predictions indexed by name and the second element contains intermediate activations indexed by name. By
passing features, we can compute losses such as the contrastive loss in MOON. All predictions included in
dictionary will by default be used to compute metrics separately.

Raises:
TypeError: Occurs when something other than a tensor or dict of tensors is passed in to the model's
forward method.
ValueError: Occurs when something other than a tensor or dict of tensors is returned by the model
forward.
"""
if isinstance(input, torch.Tensor):
output = model(input)
elif isinstance(input, dict):
# If input is a dictionary, then we unpack it before computing the forward pass.
# Note that this assumes the keys of the input match (exactly) the keyword args
# of self.model.forward().
output = model(**input)
else:
raise TypeError("'input' must be of type torch.Tensor or dict[str, torch.Tensor].")

if isinstance(output, dict):
return output, {}
if isinstance(output, torch.Tensor):
return {"prediction": output}, {}
if isinstance(output, tuple):
if len(output) != 2:
raise ValueError(f"Output tuple should have length 2 but has length {len(output)}")
preds, features = output
return preds, features
raise ValueError("Model forward did not return a tensor, dictionary of tensors, or tuple of tensors")

def _transform_gradients_with_model(self, model: torch.nn.Module, losses: TrainingLosses) -> None:
"""
Helper transform gradients method that allows for injection of model.

NOTE: Subclasses should implement this helper should there be a need to specialize the logic
for transforming gradients.

Args:
model (torch.nn.Module): the model used to generate predictions to compute losses
losses (TrainingLosses): The losses object from the train step
"""
pass

def transform_gradients(self, losses: TrainingLosses) -> None:
"""
Hook function for model training only called after backwards pass but before optimizer step. Useful for
transforming the gradients (such as with gradient clipping) before they are applied to the model weights.

Args:
losses (TrainingLosses): The losses object from the train step
"""
return self._transform_gradients_with_model(self.model, losses)
Loading
Loading