MEDfl 2.0.5.dev9__tar.gz → 2.0.7__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.
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/LearningManager/client.py +40 -20
- medfl-2.0.7/MEDfl/LearningManager/model.py +345 -0
- medfl-2.0.7/MEDfl/LearningManager/server.py +211 -0
- medfl-2.0.7/MEDfl/LearningManager/strategy.py +83 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/NetManager/flsetup.py +14 -1
- {medfl-2.0.5.dev9 → medfl-2.0.7}/PKG-INFO +2 -33
- {medfl-2.0.5.dev9 → medfl-2.0.7}/medfl.egg-info/PKG-INFO +2 -33
- medfl-2.0.7/medfl.egg-info/requires.txt +16 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/setup.py +1 -1
- medfl-2.0.5.dev9/MEDfl/LearningManager/model.py +0 -224
- medfl-2.0.5.dev9/MEDfl/LearningManager/server.py +0 -181
- medfl-2.0.5.dev9/MEDfl/LearningManager/strategy.py +0 -82
- medfl-2.0.5.dev9/medfl.egg-info/requires.txt +0 -19
- {medfl-2.0.5.dev9 → medfl-2.0.7}/LICENSE +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/LearningManager/__init__.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/LearningManager/dynamicModal.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/LearningManager/federated_dataset.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/LearningManager/flpipeline.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/LearningManager/params.yaml +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/LearningManager/params_optimiser.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/LearningManager/plot.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/LearningManager/utils.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/NetManager/__init__.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/NetManager/database_connector.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/NetManager/dataset.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/NetManager/net_helper.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/NetManager/net_manager_queries.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/NetManager/network.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/NetManager/node.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/__init__.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/rw/__init__.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/rw/client.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/rw/model.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/rw/server.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/rw/strategy.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/scripts/__init__.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/scripts/base.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/MEDfl/scripts/create_db.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/README.md +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/alembic/__init__.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/alembic/env.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/medfl.egg-info/SOURCES.txt +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/medfl.egg-info/dependency_links.txt +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/medfl.egg-info/top_level.txt +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/setup.cfg +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/tests/test_dataset.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/tests/test_federated_dataset.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/tests/test_flpipeline.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/tests/test_flsetup.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/tests/test_network.py +0 -0
- {medfl-2.0.5.dev9 → medfl-2.0.7}/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
|
-
{
|
|
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
|
-
|
|
156
|
+
print(f"[Client {self.cid}] evaluate, config: {config}")
|
|
157
|
+
self.local_model.set_parameters(parameters)
|
|
134
158
|
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|