MEDfl 2.0.5.dev8__tar.gz → 2.0.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/LearningManager/client.py +40 -20
  2. medfl-2.0.6/MEDfl/LearningManager/model.py +345 -0
  3. medfl-2.0.6/MEDfl/LearningManager/server.py +211 -0
  4. medfl-2.0.6/MEDfl/LearningManager/strategy.py +83 -0
  5. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/NetManager/flsetup.py +14 -1
  6. {medfl-2.0.5.dev8 → medfl-2.0.6}/PKG-INFO +2 -33
  7. {medfl-2.0.5.dev8 → medfl-2.0.6}/medfl.egg-info/PKG-INFO +2 -33
  8. {medfl-2.0.5.dev8 → medfl-2.0.6}/medfl.egg-info/requires.txt +1 -1
  9. {medfl-2.0.5.dev8 → medfl-2.0.6}/setup.py +1 -1
  10. medfl-2.0.5.dev8/MEDfl/LearningManager/model.py +0 -224
  11. medfl-2.0.5.dev8/MEDfl/LearningManager/server.py +0 -181
  12. medfl-2.0.5.dev8/MEDfl/LearningManager/strategy.py +0 -82
  13. {medfl-2.0.5.dev8 → medfl-2.0.6}/LICENSE +0 -0
  14. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/LearningManager/__init__.py +0 -0
  15. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/LearningManager/dynamicModal.py +0 -0
  16. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/LearningManager/federated_dataset.py +0 -0
  17. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/LearningManager/flpipeline.py +0 -0
  18. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/LearningManager/params.yaml +0 -0
  19. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/LearningManager/params_optimiser.py +0 -0
  20. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/LearningManager/plot.py +0 -0
  21. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/LearningManager/utils.py +0 -0
  22. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/NetManager/__init__.py +0 -0
  23. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/NetManager/database_connector.py +0 -0
  24. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/NetManager/dataset.py +0 -0
  25. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/NetManager/net_helper.py +0 -0
  26. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/NetManager/net_manager_queries.py +0 -0
  27. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/NetManager/network.py +0 -0
  28. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/NetManager/node.py +0 -0
  29. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/__init__.py +0 -0
  30. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/rw/__init__.py +0 -0
  31. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/rw/client.py +0 -0
  32. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/rw/model.py +0 -0
  33. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/rw/server.py +0 -0
  34. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/rw/strategy.py +0 -0
  35. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/scripts/__init__.py +0 -0
  36. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/scripts/base.py +0 -0
  37. {medfl-2.0.5.dev8 → medfl-2.0.6}/MEDfl/scripts/create_db.py +0 -0
  38. {medfl-2.0.5.dev8 → medfl-2.0.6}/README.md +0 -0
  39. {medfl-2.0.5.dev8 → medfl-2.0.6}/alembic/__init__.py +0 -0
  40. {medfl-2.0.5.dev8 → medfl-2.0.6}/alembic/env.py +0 -0
  41. {medfl-2.0.5.dev8 → medfl-2.0.6}/medfl.egg-info/SOURCES.txt +0 -0
  42. {medfl-2.0.5.dev8 → medfl-2.0.6}/medfl.egg-info/dependency_links.txt +0 -0
  43. {medfl-2.0.5.dev8 → medfl-2.0.6}/medfl.egg-info/top_level.txt +0 -0
  44. {medfl-2.0.5.dev8 → medfl-2.0.6}/setup.cfg +0 -0
  45. {medfl-2.0.5.dev8 → medfl-2.0.6}/tests/test_dataset.py +0 -0
  46. {medfl-2.0.5.dev8 → medfl-2.0.6}/tests/test_federated_dataset.py +0 -0
  47. {medfl-2.0.5.dev8 → medfl-2.0.6}/tests/test_flpipeline.py +0 -0
  48. {medfl-2.0.5.dev8 → medfl-2.0.6}/tests/test_flsetup.py +0 -0
  49. {medfl-2.0.5.dev8 → medfl-2.0.6}/tests/test_network.py +0 -0
  50. {medfl-2.0.5.dev8 → medfl-2.0.6}/tests/test_node.py +0 -0
@@ -6,6 +6,7 @@ from torch.utils.data import DataLoader
6
6
  from .model import Model
7
7
  from .utils import params
8
8
  import torch
9
+ import numpy as np
9
10
 
10
11
  class FlowerClient(fl.client.NumPyClient):
11
12
  """
@@ -52,6 +53,9 @@ class FlowerClient(fl.client.NumPyClient):
52
53
  self.epsilons = []
53
54
  self.accuracies = []
54
55
  self.losses = []
56
+ self.train_round_losses = []
57
+ self.train_round_metrics = []
58
+
55
59
  if self.diff_priv:
56
60
  model, optimizer, self.trainloader = self.privacy_engine.make_private_with_epsilon(
57
61
  module=self.local_model.model.train(),
@@ -112,8 +116,11 @@ class FlowerClient(fl.client.NumPyClient):
112
116
  # print(config['epochs'])
113
117
  print('\n -------------------------------- \n ')
114
118
  self.local_model.set_parameters(parameters)
119
+
120
+ epoch_losses, epoch_metrics = [], []
121
+
115
122
  for _ in range(params["train_epochs"]):
116
- epsilon = self.local_model.train(
123
+ epsilon, tr_loss, tr_metric = self.local_model.train(
117
124
  self.trainloader,
118
125
  epoch=_,
119
126
  device=self.device,
@@ -121,30 +128,43 @@ class FlowerClient(fl.client.NumPyClient):
121
128
  diff_priv=self.diff_priv,
122
129
  )
123
130
  self.epsilons.append(epsilon)
131
+ epoch_losses.append(tr_loss)
132
+ epoch_metrics.append(tr_metric)
133
+
124
134
  print(f"epsilon of client {self.cid} : eps = {epsilon}")
135
+ round_train_loss = float(np.nanmean(epoch_losses)) if len(epoch_losses) else float("nan")
136
+
137
+ round_train_metric = float(np.nanmean(epoch_metrics)) if len(epoch_metrics) else float("nan")
138
+
139
+ self.train_round_losses.append(round_train_loss)
140
+ self.train_round_metrics.append(round_train_metric)
141
+
142
+ metric_key = "train_accuracy" if self.local_model.task_type == "binary" else "train_rmse"
143
+
125
144
  return (
126
145
  self.local_model.get_parameters(),
127
- len(self.trainloader),
128
- {"epsilon": epsilon},
146
+ len(self.trainloader.dataset), # better than len(dataloader)
147
+ {
148
+ "epsilon": float(epsilon),
149
+ "train_loss": float(round_train_loss),
150
+ metric_key: float(round_train_metric),
151
+ },
129
152
  )
130
153
 
154
+
131
155
  def evaluate(self, parameters, config):
132
- """
133
- Evaluates the local model on the validation data and returns the loss and accuracy.
156
+ print(f"[Client {self.cid}] evaluate, config: {config}")
157
+ self.local_model.set_parameters(parameters)
134
158
 
135
- Args:
136
- parameters: Parameters received from the server.
137
- config: Configuration information.
159
+ # NEW: evaluate returns (loss, metrics_dict)
160
+ loss, metrics = self.local_model.evaluate(self.valloader, device=self.device)
138
161
 
139
- Returns:
140
- Tuple: Loss, number of validation examples, and accuracy information.
141
- """
142
- print(f"[Client {self.cid}] evaluate, config: {config}")
143
- self.local_model.set_parameters(parameters)
144
- loss, accuracy , auc = self.local_model.evaluate(
145
- self.valloader, device=self.device
146
- )
147
- self.losses.append(loss)
148
- self.accuracies.append(accuracy)
149
-
150
- return float(loss), len(self.valloader), {"accuracy": float(accuracy)}
162
+ self.losses.append(loss)
163
+
164
+ # Keep old behavior if accuracy exists
165
+ if isinstance(metrics, dict) and "accuracy" in metrics:
166
+ self.accuracies.append(metrics["accuracy"])
167
+
168
+ # Return metrics as-is (works for binary + regression)
169
+ # Flower expects: (loss, num_examples, metrics_dict)
170
+ return float(loss), len(self.valloader.dataset), {k: float(v) for k, v in (metrics or {}).items()}
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env python3
2
+ # froked from https://github.com/pythonlessons/mltu/blob/main/mltu/torch/model.py
3
+
4
+ import typing
5
+ from collections import OrderedDict
6
+ from typing import Dict, List, Optional, Tuple
7
+
8
+ import numpy as np
9
+ import torch
10
+ import torch.nn as nn
11
+ from sklearn.metrics import accuracy_score, roc_auc_score, mean_squared_error, mean_absolute_error, r2_score
12
+
13
+
14
+ from .utils import params
15
+
16
+
17
+ class Model:
18
+ """
19
+ Model class for training and testing PyTorch neural networks.
20
+
21
+ Attributes:
22
+ model (torch.nn.Module): PyTorch neural network.
23
+ optimizer (torch.optim.Optimizer): PyTorch optimizer.
24
+ criterion (typing.Callable): Loss function.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ model: torch.nn.Module,
30
+ optimizer: torch.optim.Optimizer,
31
+ criterion: typing.Callable,
32
+ task_type: str = "binary"
33
+ ) -> None:
34
+ """
35
+ Initialize Model class with the specified model, optimizer, and criterion.
36
+
37
+ Args:
38
+ model (torch.nn.Module): PyTorch neural network.
39
+ optimizer (torch.optim.Optimizer): PyTorch optimizer.
40
+ criterion (typing.Callable): Loss function.
41
+ """
42
+ self.model = model
43
+ self.optimizer = optimizer
44
+ self.criterion = criterion
45
+ self.task_type = task_type
46
+ # Get device on which model is running
47
+ self.validate()
48
+
49
+ def validate(self) -> None:
50
+ """
51
+ Validate model and optimizer.
52
+ """
53
+ if not isinstance(self.model, torch.nn.Module):
54
+ raise TypeError("model argument must be a torch.nn.Module")
55
+
56
+ if not isinstance(self.optimizer, torch.optim.Optimizer):
57
+ raise TypeError(
58
+ "optimizer argument must be a torch.optim.Optimizer"
59
+ )
60
+
61
+ def get_parameters(self) -> List[np.ndarray]:
62
+ """
63
+ Get the parameters of the model as a list of NumPy arrays.
64
+
65
+ Returns:
66
+ List[np.ndarray]: The parameters of the model as a list of NumPy arrays.
67
+ """
68
+ return [val.detach().cpu().numpy() for val in self.model.state_dict().values()]
69
+
70
+ def set_parameters(self, parameters: List[np.ndarray]) -> None:
71
+ state_dict = self.model.state_dict()
72
+ keys = list(state_dict.keys())
73
+
74
+ device = next(self.model.parameters()).device # model device
75
+ new_state_dict = OrderedDict()
76
+
77
+ for k, v in zip(keys, parameters):
78
+ ref = state_dict[k] # reference tensor for dtype/shape
79
+ # v is numpy array -> tensor on correct device/dtype
80
+ new_state_dict[k] = torch.as_tensor(v, device=device, dtype=ref.dtype)
81
+
82
+ self.model.load_state_dict(new_state_dict, strict=True)
83
+
84
+ def train(self, train_loader, epoch, device, privacy_engine, diff_priv=False) -> float:
85
+ self.model.train()
86
+ epsilon = 0.0
87
+ losses = []
88
+ metrics = []
89
+
90
+ for i, (X_train, y_train) in enumerate(train_loader):
91
+ X_train = X_train.to(device)
92
+ y_train = y_train.to(device).view(-1) # ensure (batch,)
93
+
94
+ # -------------------------
95
+ # HARD INPUT SANITY CHECKS
96
+ # -------------------------
97
+ if torch.isnan(X_train).any() or torch.isinf(X_train).any():
98
+ print(f"❌ Train batch {i}: X_train has NaN/Inf -> skipping batch")
99
+ continue
100
+ if torch.isnan(y_train).any() or torch.isinf(y_train).any():
101
+ print(f"❌ Train batch {i}: y_train has NaN/Inf -> skipping batch")
102
+ continue
103
+
104
+ # Optional: print scales once (helps diagnose exploding grads)
105
+ if i == 0 and epoch == 0:
106
+ try:
107
+ print("Train scale check | X min/max:", X_train.min().item(), X_train.max().item(),
108
+ "| y min/max:", y_train.min().item(), y_train.max().item())
109
+ except Exception:
110
+ pass
111
+
112
+ self.optimizer.zero_grad(set_to_none=True)
113
+
114
+ # -------------------------
115
+ # FORWARD
116
+ # -------------------------
117
+ y_hat = self.model(X_train).view(-1) # ensure (batch,)
118
+
119
+ if torch.isnan(y_hat).any() or torch.isinf(y_hat).any():
120
+ print(f"❌ Train batch {i}: y_hat has NaN/Inf -> skipping batch")
121
+ continue
122
+
123
+ # -------------------------
124
+ # LOSS
125
+ # -------------------------
126
+ loss = self.criterion(y_hat, y_train)
127
+
128
+ if torch.isnan(loss) or torch.isinf(loss):
129
+ print(f"❌ Train batch {i}: loss is NaN/Inf -> skipping batch")
130
+ continue
131
+
132
+ losses.append(float(loss.item()))
133
+
134
+ # -------------------------
135
+ # BACKWARD
136
+ # -------------------------
137
+ loss.backward()
138
+
139
+ # -------------------------
140
+ # GRADIENT SANITY CHECK
141
+ # If grads are NaN/Inf, DO NOT UPDATE WEIGHTS
142
+ # -------------------------
143
+ bad_grad = False
144
+ for name, p in self.model.named_parameters():
145
+ if p.grad is None:
146
+ continue
147
+ if torch.isnan(p.grad).any() or torch.isinf(p.grad).any():
148
+ print(
149
+ f"❌ Train batch {i}: grad NaN/Inf in {name} "
150
+ f"(NaN={torch.isnan(p.grad).sum().item()}, Inf={torch.isinf(p.grad).sum().item()}) "
151
+ f"-> skipping update"
152
+ )
153
+ bad_grad = True
154
+ break
155
+
156
+ if bad_grad:
157
+ self.optimizer.zero_grad(set_to_none=True)
158
+ continue
159
+
160
+ # -------------------------
161
+ # CLIP + STEP
162
+ # -------------------------
163
+ torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
164
+ self.optimizer.step()
165
+
166
+ # -------------------------
167
+ # METRICS
168
+ # -------------------------
169
+ if self.task_type == "binary":
170
+ # Assumes y_hat is probability (Sigmoid model + BCELoss)
171
+ y_prob = y_hat.detach().cpu().numpy()
172
+ y_true = y_train.detach().cpu().numpy()
173
+ acc = accuracy_score(y_true, (y_prob >= 0.5).astype(int))
174
+ metrics.append(float(acc))
175
+
176
+ elif self.task_type == "regression":
177
+ rmse = torch.sqrt(torch.mean((y_hat - y_train) ** 2)).item()
178
+ metrics.append(float(rmse))
179
+
180
+ # -------------------------
181
+ # DP EPSILON
182
+ # -------------------------
183
+ if diff_priv:
184
+ epsilon = float(privacy_engine.get_epsilon(float(params["DELTA"])))
185
+
186
+ # -------------------------
187
+ # LOGGING
188
+ # -------------------------
189
+ if (i + 1) % 10 == 0 and len(losses) > 0:
190
+ if self.task_type == "binary" and len(metrics) > 0:
191
+ print(
192
+ f"\tTrain Epoch {epoch} | "
193
+ f"Loss {np.mean(losses):.6f} | "
194
+ f"Acc {np.mean(metrics) * 100:.2f}%"
195
+ + (f" | ε={epsilon:.2f}" if diff_priv else "")
196
+ )
197
+ elif self.task_type == "regression" and len(metrics) > 0:
198
+ print(
199
+ f"\tTrain Epoch {epoch} | "
200
+ f"Loss {np.mean(losses):.6f} | "
201
+ f"RMSE {np.mean(metrics):.6f}"
202
+ + (f" | ε={epsilon:.2f}" if diff_priv else "")
203
+ )
204
+
205
+ mean_train_loss = float(np.mean(losses)) if len(losses) else float("nan")
206
+ mean_train_metric = float(np.mean(metrics)) if len(metrics) else float("nan")
207
+
208
+ return epsilon, mean_train_loss, mean_train_metric
209
+
210
+
211
+
212
+ def evaluate(self, val_loader, device=torch.device("cpu")):
213
+ self.model.eval()
214
+ losses = []
215
+ y_true_all, y_pred_all = [], []
216
+
217
+ with torch.no_grad():
218
+ for i, (X_test, y_test) in enumerate(val_loader):
219
+ X_test = X_test.to(device)
220
+ y_test_t = y_test.to(device).view(-1) # force 1D targets
221
+ y_hat_t = self.model(X_test).view(-1) # force 1D preds
222
+
223
+ # Skip bad batches
224
+ if torch.isnan(y_hat_t).any() or torch.isinf(y_hat_t).any():
225
+ print(f"❌ Eval batch {i}: y_hat NaN/Inf -> skipping")
226
+ continue
227
+ if torch.isnan(y_test_t).any() or torch.isinf(y_test_t).any():
228
+ print(f"❌ Eval batch {i}: y_test NaN/Inf -> skipping")
229
+ continue
230
+
231
+ crit = self.criterion.to(y_hat_t.device) if hasattr(self.criterion, "to") else self.criterion
232
+ loss_t = crit(y_hat_t, y_test_t)
233
+
234
+ if torch.isnan(loss_t) or torch.isinf(loss_t):
235
+ print(f"❌ Eval batch {i}: loss NaN/Inf -> skipping")
236
+ continue
237
+
238
+ losses.append(float(loss_t.item()))
239
+ # IMPORTANT: append 1D numpy arrays (never scalars)
240
+ y_true_all.append(np.atleast_1d(y_test_t.detach().cpu().numpy()))
241
+ y_pred_all.append(np.atleast_1d(y_hat_t.detach().cpu().numpy()))
242
+
243
+ if len(losses) == 0 or len(y_true_all) == 0:
244
+ return float("nan"), {}
245
+
246
+ y_true = np.concatenate(y_true_all, axis=0)
247
+ y_pred = np.concatenate(y_pred_all, axis=0)
248
+
249
+ mean_loss = float(np.mean(losses))
250
+
251
+ if self.task_type == "binary":
252
+ acc = accuracy_score(y_true, (y_pred >= 0.5).astype(int))
253
+ auc = roc_auc_score(y_true, y_pred) if len(np.unique(y_true)) > 1 else float("nan")
254
+ return mean_loss, {"accuracy": float(acc), "auc": float(auc)}
255
+
256
+ if self.task_type == "regression":
257
+ mask = np.isfinite(y_true) & np.isfinite(y_pred)
258
+ if mask.sum() == 0:
259
+ return mean_loss, {
260
+ "rmse": float("nan"),
261
+ "mae": float("nan"),
262
+ "r2": float("nan"),
263
+ "adj_r2": float("nan"),
264
+ }
265
+
266
+ y_true_c = y_true[mask]
267
+ y_pred_c = y_pred[mask]
268
+
269
+ mse = mean_squared_error(y_true_c, y_pred_c)
270
+ mae = mean_absolute_error(y_true_c, y_pred_c)
271
+ r2 = r2_score(y_true_c, y_pred_c)
272
+ rmse = float(np.sqrt(mse))
273
+
274
+ # ---- Adjusted R² ----
275
+ n = int(len(y_true_c))
276
+
277
+ # Determine p (number of predictors)
278
+ p = None
279
+ try:
280
+ # Works for Linear Regressor and most MLPs (first Linear layer)
281
+ for m in self.model.modules():
282
+ if isinstance(m, torch.nn.Linear):
283
+ p = int(m.in_features)
284
+ break
285
+ except Exception:
286
+ p = None
287
+
288
+ if p is None or n <= p + 1:
289
+ adj_r2 = float("nan")
290
+ else:
291
+ adj_r2 = 1.0 - (1.0 - float(r2)) * (n - 1) / (n - p - 1)
292
+
293
+ print("adj_r2 check | n =", n, "| p =", p, "| condition n>p+1 =", n > p + 1)
294
+
295
+
296
+ return mean_loss, {
297
+ "rmse": float(rmse),
298
+ "mae": float(mae),
299
+ "r2": float(r2),
300
+ "adj_r2": float(adj_r2),
301
+ }
302
+
303
+ return mean_loss, {}
304
+
305
+
306
+ @staticmethod
307
+ def save_model(model , model_name:str):
308
+ """
309
+ Saves a PyTorch model to a file.
310
+
311
+ Args:
312
+ model (torch.nn.Module): PyTorch model to be saved.
313
+ model_name (str): Name of the model file.
314
+
315
+ Raises:
316
+ Exception: If there is an issue during the saving process.
317
+
318
+ Returns:
319
+ None
320
+ """
321
+ try:
322
+ torch.save(model, '../../notebooks/.ipynb_checkpoints/trainedModels/' + model_name + ".pth")
323
+ except Exception as e:
324
+ raise Exception(f"Error saving the model: {str(e)}")
325
+
326
+ @staticmethod
327
+ def load_model(model_path: str):
328
+ """
329
+ Loads a PyTorch model from a file.
330
+
331
+ Args:
332
+ model_path (str): Path to the model file to be loaded.
333
+
334
+ Returns:
335
+ torch.nn.Module: Loaded PyTorch model.
336
+ """
337
+ # Ensure models are loaded onto the CPU when CUDA is not available
338
+ torch_kwargs = {"weights_only": False}
339
+ if torch.cuda.is_available():
340
+ loaded_model = torch.load(model_path , **torch_kwargs)
341
+ else:
342
+ loaded_model = torch.load(model_path, map_location=torch.device('cpu') , **torch_kwargs)
343
+ return loaded_model
344
+
345
+
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import copy
4
+ from typing import Dict, Optional, Tuple
5
+
6
+ import flwr as fl
7
+ import torch
8
+
9
+ from torch.utils.data import ConcatDataset, DataLoader # ✅ NEW
10
+
11
+ from .client import FlowerClient
12
+ from .federated_dataset import FederatedDataset
13
+ from .model import Model
14
+ from .strategy import Strategy
15
+
16
+
17
+ class FlowerServer:
18
+ """
19
+ A class representing the central server for Federated Learning using Flower.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ global_model: Model,
25
+ strategy: Strategy,
26
+ num_rounds: int,
27
+ num_clients: int,
28
+ fed_dataset: FederatedDataset,
29
+ diff_privacy: bool = False,
30
+ client_resources: Optional[Dict[str, float]] = {'num_cpus': 1, 'num_gpus': 0.0}
31
+ ) -> None:
32
+
33
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
34
+ self.global_model = global_model
35
+ self.params = global_model.get_parameters()
36
+ self.global_model.model = global_model.model.to(self.device)
37
+ self.num_rounds = num_rounds
38
+ self.num_clients = num_clients
39
+ self.fed_dataset = fed_dataset
40
+ self.strategy = strategy
41
+ self.client_resources = client_resources
42
+
43
+ setattr(self.strategy.strategy_object, "min_available_clients", self.num_clients)
44
+ setattr(self.strategy.strategy_object, "initial_parameters", fl.common.ndarrays_to_parameters(self.params))
45
+ setattr(self.strategy.strategy_object, "evaluate_fn", self.evaluate)
46
+
47
+ self.diff_priv = diff_privacy
48
+
49
+ # ✅ tracking
50
+ self.accuracies = []
51
+ self.losses = []
52
+ self.auc = []
53
+ self.rmse = []
54
+ self.mae = []
55
+ self.r2 = []
56
+ self.adj_r2 = []
57
+
58
+ self.train_losses = []
59
+ self.train_accuracies = []
60
+ self.train_auc = []
61
+ self.train_rmse = []
62
+ self.train_mae = []
63
+ self.train_r2 = []
64
+ self.train_adj_r2 = []
65
+
66
+ self.flower_clients = []
67
+ self.device = torch.device("cpu")
68
+ self.global_model = global_model
69
+
70
+ # move model to CPU
71
+ self.global_model.model = self.global_model.model.to(self.device)
72
+
73
+ # also move optimizer state to CPU (important if optimizer had any tensors)
74
+ for state in self.global_model.optimizer.state.values():
75
+ for k, v in state.items():
76
+ if torch.is_tensor(v):
77
+ state[k] = v.detach().cpu()
78
+
79
+ # if criterion has tensor buffers (e.g., pos_weight), ensure CPU too
80
+ if hasattr(self.global_model.criterion, "pos_weight") and self.global_model.criterion.pos_weight is not None:
81
+ self.global_model.criterion.pos_weight = self.global_model.criterion.pos_weight.detach().cpu()
82
+ self.validate()
83
+
84
+ def validate(self) -> None:
85
+ if not isinstance(self.global_model, Model):
86
+ raise TypeError("global_model argument must be a Model instance")
87
+ if not isinstance(self.num_clients, int):
88
+ raise TypeError("num_clients argument must be an int")
89
+ if not isinstance(self.num_rounds, int):
90
+ raise TypeError("num_rounds argument must be an int")
91
+ if not isinstance(self.diff_priv, bool):
92
+ raise TypeError("diff_priv argument must be a bool")
93
+
94
+ def client_fn(self, cid) -> FlowerClient:
95
+ # NOTE: you might want to replace %4 with device_count() later
96
+ device = torch.device(f"cuda:{int(cid) % 4}" if torch.cuda.is_available() else "cpu")
97
+
98
+ client_model = copy.deepcopy(self.global_model)
99
+
100
+ trainloader = self.fed_dataset.trainloaders[int(cid)]
101
+ valloader = self.fed_dataset.valloaders[int(cid)]
102
+
103
+ client_model = copy.deepcopy(self.global_model)
104
+ client = FlowerClient(cid, client_model, trainloader, valloader, self.diff_priv)
105
+ self.flower_clients.append(client)
106
+ return client
107
+
108
+ # ✅ NEW helper: build one global loader from all client loaders (simulation)
109
+ def _concat_loader(self, loaders, shuffle: bool = False) -> DataLoader:
110
+ # Use each loader's dataset and build one union dataset
111
+ ds = ConcatDataset([ldr.dataset for ldr in loaders])
112
+
113
+ # Try to reuse an existing batch_size if possible; fallback to 64
114
+ batch_size = getattr(loaders[0], "batch_size", None) or 64
115
+ num_workers = getattr(loaders[0], "num_workers", 0)
116
+ pin_memory = getattr(loaders[0], "pin_memory", False)
117
+
118
+ return DataLoader(
119
+ ds,
120
+ batch_size=batch_size,
121
+ shuffle=shuffle,
122
+ num_workers=num_workers,
123
+ pin_memory=pin_memory,
124
+ )
125
+
126
+ def evaluate(
127
+ self,
128
+ server_round: int,
129
+ parameters: fl.common.NDArrays,
130
+ config: Dict[str, fl.common.Scalar],
131
+ ) -> Optional[Tuple[float, Dict[str, fl.common.Scalar]]]:
132
+
133
+ # Update model with latest weights
134
+ self.global_model.set_parameters(parameters)
135
+ self.last_parameters = parameters
136
+
137
+
138
+ # ✅ GLOBAL loaders across ALL clients (simulation)
139
+ global_valloader = self._concat_loader(self.fed_dataset.valloaders, shuffle=False)
140
+ global_trainloader = self._concat_loader(self.fed_dataset.trainloaders, shuffle=False)
141
+
142
+ # ---- Validation (global) ----
143
+ val_loss, val_metrics = self.global_model.evaluate(global_valloader, self.device)
144
+ self.losses.append(val_loss)
145
+
146
+ if isinstance(val_metrics, dict):
147
+ if "accuracy" in val_metrics: self.accuracies.append(val_metrics["accuracy"])
148
+ if "auc" in val_metrics: self.auc.append(val_metrics["auc"])
149
+ if "rmse" in val_metrics: self.rmse.append(val_metrics["rmse"])
150
+ if "mae" in val_metrics: self.mae.append(val_metrics["mae"])
151
+ if "r2" in val_metrics: self.r2.append(val_metrics["r2"])
152
+ if "adj_r2" in val_metrics: self.adj_r2.append(val_metrics["adj_r2"])
153
+
154
+ # ---- Train (global) ----
155
+ train_loss, train_metrics = self.global_model.evaluate(global_trainloader, self.device)
156
+ self.train_losses.append(train_loss)
157
+
158
+ if isinstance(train_metrics, dict):
159
+ if "accuracy" in train_metrics: self.train_accuracies.append(train_metrics["accuracy"])
160
+ if "auc" in train_metrics: self.train_auc.append(train_metrics["auc"])
161
+ if "rmse" in train_metrics: self.train_rmse.append(train_metrics["rmse"])
162
+ if "mae" in train_metrics: self.train_mae.append(train_metrics["mae"])
163
+ if "r2" in train_metrics: self.train_r2.append(train_metrics["r2"])
164
+ if "adj_r2" in train_metrics: self.train_adj_r2.append(train_metrics["adj_r2"])
165
+
166
+ # Return validation loss + a metrics dict that also includes train_*
167
+ out_metrics = dict(val_metrics or {})
168
+ out_metrics["train_loss"] = float(train_loss) if train_loss is not None else float("nan")
169
+
170
+ if isinstance(train_metrics, dict):
171
+ for k, v in train_metrics.items():
172
+ out_metrics[f"train_{k}"] = float(v) if v is not None else float("nan")
173
+
174
+ return val_loss, out_metrics
175
+
176
+ def score_each_client(self, parameters=None, split: str = "val"):
177
+ """
178
+ Evaluate the (final) global model on each client's data.
179
+ split: "val" or "train"
180
+ """
181
+ if parameters is not None:
182
+ self.global_model.set_parameters(parameters)
183
+
184
+ loaders = self.fed_dataset.valloaders if split == "val" else self.fed_dataset.trainloaders
185
+
186
+ results = {}
187
+ for cid, loader in enumerate(loaders):
188
+ loss, metrics = self.global_model.evaluate(loader, self.device)
189
+ results[str(cid)] = {
190
+ "num_examples": len(loader.dataset),
191
+ "loss": float(loss),
192
+ **{k: float(v) for k, v in (metrics or {}).items()},
193
+ }
194
+ return results
195
+
196
+
197
+
198
+ def run(self) -> None:
199
+ ray_init_args = {"include_dashboard": False, "object_store_memory": 78643200 ,
200
+ }
201
+ self.fed_dataset.eng = None
202
+
203
+ history = fl.simulation.start_simulation(
204
+ client_fn=self.client_fn,
205
+ num_clients=self.num_clients,
206
+ config=fl.server.ServerConfig(self.num_rounds),
207
+ strategy=self.strategy.strategy_object,
208
+ ray_init_args=ray_init_args,
209
+ client_resources=self.client_resources
210
+ )
211
+ return history