Skip to content

Commit 3f50d8f

Browse files
authored
Merge pull request #398 from VectorInstitute/nerdai/refactor-basic-client
2 parents 9d6788d + b2f6ac4 commit 3f50d8f

14 files changed

Lines changed: 983 additions & 404 deletions

File tree

examples/ditto_example/client_dynamic.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from torch.utils.data import DataLoader
1313

1414
from examples.models.cnn_model import MnistNet
15-
from fl4health.clients.basic_client import BasicClient
15+
from fl4health.clients.flexible_client import FlexibleClient
1616
from fl4health.mixins.personalized import PersonalizedMode, make_it_personal
1717
from fl4health.reporting import JsonReporter
1818
from fl4health.utils.config import narrow_dict_type
@@ -22,8 +22,8 @@
2222
from fl4health.utils.sampler import DirichletLabelBasedSampler
2323

2424

25-
class MnistClient(BasicClient):
26-
"""A simple `BasicClient` type that we dynamically personalize via Ditto."""
25+
class MnistClient(FlexibleClient):
26+
"""A simple `FlexibleClient` type that we dynamically personalize via Ditto."""
2727

2828
def get_data_loaders(self, config: Config) -> tuple[DataLoader, DataLoader]:
2929
sample_percentage = narrow_dict_type(config, "downsampling_ratio", float)
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import warnings
2+
from collections.abc import Sequence
3+
from logging import WARN
4+
from pathlib import Path
5+
from typing import Any
6+
7+
import torch
8+
from flwr.common.logger import log
9+
from torch import nn
10+
from torch.optim import Optimizer
11+
12+
from fl4health.checkpointing.client_module import ClientCheckpointAndStateModule
13+
from fl4health.clients.basic_client import BasicClient
14+
from fl4health.metrics.base_metrics import Metric
15+
from fl4health.reporting.base_reporter import BaseReporter
16+
from fl4health.utils.losses import EvaluationLosses, LossMeterType, TrainingLosses
17+
from fl4health.utils.typing import TorchFeatureType, TorchInputType, TorchPredType, TorchTargetType
18+
19+
20+
class FlexibleClient(BasicClient):
21+
def __init__(
22+
self,
23+
data_path: Path,
24+
metrics: Sequence[Metric],
25+
device: torch.device,
26+
loss_meter_type: LossMeterType = LossMeterType.AVERAGE,
27+
checkpoint_and_state_module: ClientCheckpointAndStateModule | None = None,
28+
reporters: Sequence[BaseReporter] | None = None,
29+
progress_bar: bool = False,
30+
client_name: str | None = None,
31+
) -> None:
32+
"""
33+
Flexible FL Client with functionality to train, evaluate, log, report and checkpoint.
34+
35+
`FlexibleClient` is similar to `BasicClient` but provides added flexibility through the
36+
ability to inject models and optimizers in the methods responsible for making predictions
37+
and performing both train and validation steps.
38+
39+
This added flexibility allows for `FlexibleClient` to be automatically adapted with our
40+
personalized methods: ~fl4health.mixins.personalized.
41+
42+
As with `BasicClient`, users are responsible for implementing methods:
43+
44+
- ``get_model``
45+
- ``get_optimizer``
46+
- ``get_data_loaders``,
47+
- ``get_criterion``
48+
49+
However, unlike `BasicClient`, users looking to specialize logic for making predictions,
50+
and performing train and validation steps, should instead override:
51+
52+
- ``predict_with_model``
53+
- ``_train_step_with_model_and_optimizer`` (and its delegated helpers)
54+
- ``_val_step_with_model``
55+
56+
Other methods can be overridden to achieve custom functionality.
57+
58+
Args:
59+
data_path (Path): path to the data to be used to load the data for client-side training
60+
metrics (Sequence[Metric]): Metrics to be computed based on the labels and predictions of the client model
61+
device (torch.device): Device indicator for where to send the model, batches, labels etc. Often "cpu" or
62+
"cuda"
63+
loss_meter_type (LossMeterType, optional): Type of meter used to track and compute the losses over
64+
each batch. Defaults to ``LossMeterType.AVERAGE``.
65+
checkpoint_and_state_module (ClientCheckpointAndStateModule | None, optional): A module meant to handle
66+
both checkpointing and state saving. The module, and its underlying model and state checkpointing
67+
components will determine when and how to do checkpointing during client-side training.
68+
No checkpointing (state or model) is done if not provided. Defaults to None.
69+
reporters (Sequence[BaseReporter] | None, optional): A sequence of FL4Health reporters which the client
70+
should send data to. Defaults to None.
71+
progress_bar (bool, optional): Whether or not to display a progress bar during client training and
72+
validation. Uses ``tqdm``. Defaults to False
73+
client_name (str | None, optional): An optional client name that uniquely identifies a client.
74+
If not passed, a hash is randomly generated. Client state will use this as part of its state file
75+
name. Defaults to None.
76+
"""
77+
super().__init__(
78+
data_path,
79+
metrics,
80+
device,
81+
loss_meter_type,
82+
checkpoint_and_state_module,
83+
reporters,
84+
progress_bar,
85+
client_name,
86+
)
87+
88+
def __init_subclass__(cls, **kwargs: Any) -> None:
89+
"""Perform some validations on subclasses of FlexibleClient."""
90+
super().__init_subclass__(**kwargs)
91+
92+
# check that specific methods are not overridden, otherwise throw warning
93+
methods_should_not_be_overridden = [
94+
(
95+
"predict",
96+
(
97+
f"`{cls.__name__}` overrides `predict()`, but this method should no longer be overridden. "
98+
"Please use `predict_with_model()` instead."
99+
),
100+
),
101+
(
102+
"val_step",
103+
(
104+
f"`{cls.__name__}` overrides `val_step()`, but this method should no longer be overridden. "
105+
"Please use `_val_step_with_model()` instead."
106+
),
107+
),
108+
(
109+
"train_step",
110+
(
111+
f"`{cls.__name__}` overrides `train_step()`, but this method should no longer be overridden. "
112+
"Please use `_train_step_with_model_and_optimizer()` and its helper methods instead "
113+
"for proper customization."
114+
),
115+
),
116+
]
117+
118+
for method_name, msg in methods_should_not_be_overridden:
119+
if method_name in cls.__dict__: # method was overridden by subclass
120+
log(WARN, msg)
121+
warnings.warn(msg, RuntimeWarning, stacklevel=2)
122+
123+
def _compute_preds_and_losses(
124+
self, model: nn.Module, optimizer: Optimizer, input: TorchInputType, target: TorchTargetType
125+
) -> tuple[TrainingLosses, TorchPredType]:
126+
"""
127+
Helper method within the train step for computing preds and losses.
128+
129+
NOTE: Subclasses should implement this helper method if there is a need
130+
to specialize this part of the overall train step.
131+
132+
Args:
133+
model (nn.Module): the model used to make predictions
134+
optimizer (Optimizer): the associated optimizer
135+
input (TorchInputType): The input to be fed into the model.
136+
target (TorchTargetType): The target corresponding to the input.
137+
138+
Returns:
139+
tuple[TrainingLosses, TorchPredType]: The losses object from the train step along with
140+
a dictionary of any predictions produced by the model prior to the
141+
application of the backwards phase
142+
"""
143+
# Clear gradients from optimizer if they exist
144+
optimizer.zero_grad()
145+
146+
# Call user defined methods to get predictions and compute loss
147+
preds, features = self.predict_with_model(model, input)
148+
target = self.transform_target(target)
149+
losses = self.compute_training_loss(preds, features, target)
150+
151+
return losses, preds
152+
153+
def _apply_backwards_on_losses_and_take_step(
154+
self, model: nn.Module, optimizer: Optimizer, losses: TrainingLosses
155+
) -> TrainingLosses:
156+
"""
157+
Helper method within the train step for applying backwards on losses and taking step with optimizer.
158+
159+
NOTE: Subclasses should implement this helper method if there is a need
160+
to specialize this part of the overall train step.
161+
162+
Args:
163+
model (nn.Module): the model used for making predictions. Passed here in case subclasses need it.
164+
optimizer (Optimizer): the optimizer with which we take the step
165+
losses (TrainingLosses): the losses to apply backwards on
166+
167+
Returns:
168+
TrainingLosses: The losses object post backwards application
169+
"""
170+
# Compute backward pass and update parameters with optimizer
171+
losses.backward["backward"].backward()
172+
self.transform_gradients(losses)
173+
optimizer.step()
174+
175+
return losses
176+
177+
def _train_step_with_model_and_optimizer(
178+
self, model: torch.nn.Module, optimizer: Optimizer, input: TorchInputType, target: TorchTargetType
179+
) -> tuple[TrainingLosses, TorchPredType]:
180+
"""
181+
Helper train step method that allows for injection of model and optimizer.
182+
183+
NOTE: Subclasses should implement this method if there is a need to specialize
184+
the train_step logic.
185+
186+
Args:
187+
model (nn.Module): the model used for making predictions. Passed here in case subclasses need it.
188+
optimizer (Optimizer): the optimizer with which we take the step
189+
input (TorchInputType): The input to be fed into the model.
190+
target (TorchTargetType): The target corresponding to the input.
191+
192+
Returns:
193+
tuple[TrainingLosses, TorchPredType]: The losses object from the train step along with
194+
a dictionary of any predictions produced by the model.
195+
"""
196+
losses, preds = self._compute_preds_and_losses(model, optimizer, input, target)
197+
losses = self._apply_backwards_on_losses_and_take_step(model, optimizer, losses)
198+
199+
return losses, preds
200+
201+
def train_step(self, input: TorchInputType, target: TorchTargetType) -> tuple[TrainingLosses, TorchPredType]:
202+
"""
203+
Given a single batch of input and target data, generate predictions, compute loss, update parameters and
204+
optionally update metrics if they exist. (i.e. backprop on a single batch of data).
205+
Assumes ``self.model`` is in train mode already.
206+
207+
Args:
208+
input (TorchInputType): The input to be fed into the model.
209+
target (TorchTargetType): The target corresponding to the input.
210+
211+
Returns:
212+
tuple[TrainingLosses, TorchPredType]: The losses object from the train step along with
213+
a dictionary of any predictions produced by the model.
214+
"""
215+
return self._train_step_with_model_and_optimizer(self.model, self.optimizers["global"], input, target)
216+
217+
def _val_step_with_model(
218+
self, model: nn.Module, input: TorchInputType, target: TorchTargetType
219+
) -> tuple[EvaluationLosses, TorchPredType]:
220+
"""
221+
Helper method for val_step that allows for injection of model.
222+
223+
NOTE: Subclasses should implement this method if there is a need to
224+
specialize the val_step logic.
225+
226+
Args:
227+
model (nn.Module): the model used for making predictions. Passed here in case subclasses need it.
228+
input (TorchInputType): The input to be fed into the model.
229+
target (TorchTargetType): The target corresponding to the input.
230+
231+
Returns:
232+
tuple[EvaluationLosses, TorchPredType]: The losses object from the val step along with a dictionary of the
233+
predictions produced by the model.
234+
"""
235+
# Get preds and compute loss
236+
with torch.no_grad():
237+
preds, features = self.predict_with_model(model, input)
238+
target = self.transform_target(target)
239+
losses = self.compute_evaluation_loss(preds, features, target)
240+
241+
return losses, preds
242+
243+
def val_step(self, input: TorchInputType, target: TorchTargetType) -> tuple[EvaluationLosses, TorchPredType]:
244+
"""
245+
Given input and target, compute loss, update loss and metrics. Assumes ``self.model`` is in eval mode already.
246+
247+
Args:
248+
input (TorchInputType): The input to be fed into the model.
249+
target (TorchTargetType): The target corresponding to the input.
250+
251+
Returns:
252+
tuple[EvaluationLosses, TorchPredType]: The losses object from the val step along with a dictionary of the
253+
predictions produced by the model.
254+
"""
255+
return self._val_step_with_model(self.model, input, target)
256+
257+
def predict_with_model(
258+
self, model: torch.nn.Module, input: TorchInputType
259+
) -> tuple[TorchPredType, TorchFeatureType]:
260+
"""
261+
Helper predict method that allows for injection of model.
262+
263+
NOTE: Subclasses should implement this method if there is need to specialize
264+
the predict logic of the client.
265+
266+
Args:
267+
model (torch.nn.Module): the model with which to make predictions
268+
input (TorchInputType): Inputs to be fed into the model. If input is of type ``dict[str, torch.Tensor]``,
269+
it is assumed that the keys of input match the names of the keyword arguments of
270+
``self.model.forward().`
271+
272+
Returns:
273+
tuple[TorchPredType, TorchFeatureType]: A tuple in which the first element contains a dictionary of
274+
predictions indexed by name and the second element contains intermediate activations indexed by name. By
275+
passing features, we can compute losses such as the contrastive loss in MOON. All predictions included in
276+
dictionary will by default be used to compute metrics separately.
277+
278+
Raises:
279+
TypeError: Occurs when something other than a tensor or dict of tensors is passed in to the model's
280+
forward method.
281+
ValueError: Occurs when something other than a tensor or dict of tensors is returned by the model
282+
forward.
283+
"""
284+
if isinstance(input, torch.Tensor):
285+
output = model(input)
286+
elif isinstance(input, dict):
287+
# If input is a dictionary, then we unpack it before computing the forward pass.
288+
# Note that this assumes the keys of the input match (exactly) the keyword args
289+
# of self.model.forward().
290+
output = model(**input)
291+
else:
292+
raise TypeError("'input' must be of type torch.Tensor or dict[str, torch.Tensor].")
293+
294+
if isinstance(output, dict):
295+
return output, {}
296+
if isinstance(output, torch.Tensor):
297+
return {"prediction": output}, {}
298+
if isinstance(output, tuple):
299+
if len(output) != 2:
300+
raise ValueError(f"Output tuple should have length 2 but has length {len(output)}")
301+
preds, features = output
302+
return preds, features
303+
raise ValueError("Model forward did not return a tensor, dictionary of tensors, or tuple of tensors")
304+
305+
def _transform_gradients_with_model(self, model: torch.nn.Module, losses: TrainingLosses) -> None:
306+
"""
307+
Helper transform gradients method that allows for injection of model.
308+
309+
NOTE: Subclasses should implement this helper should there be a need to specialize the logic
310+
for transforming gradients.
311+
312+
Args:
313+
model (torch.nn.Module): the model used to generate predictions to compute losses
314+
losses (TrainingLosses): The losses object from the train step
315+
"""
316+
pass
317+
318+
def transform_gradients(self, losses: TrainingLosses) -> None:
319+
"""
320+
Hook function for model training only called after backwards pass but before optimizer step. Useful for
321+
transforming the gradients (such as with gradient clipping) before they are applied to the model weights.
322+
323+
Args:
324+
losses (TrainingLosses): The losses object from the train step
325+
"""
326+
return self._transform_gradients_with_model(self.model, losses)

0 commit comments

Comments
 (0)