MEDfl 2.0.10.dev2__py3-none-any.whl → 2.0.10.dev3__py3-none-any.whl
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/LearningManager/federated_dataset.py +20 -0
- MEDfl/LearningManager/server.py +364 -1
- MEDfl/LearningManager/shap/__init__.py +9 -0
- MEDfl/LearningManager/shap/aggregation.py +249 -0
- MEDfl/LearningManager/shap/config.py +103 -0
- MEDfl/LearningManager/shap/explainer.py +324 -0
- MEDfl/LearningManager/strategy.py +4 -0
- MEDfl/LearningManager/xgboost/strategy.py +1 -1
- {medfl-2.0.10.dev2.dist-info → medfl-2.0.10.dev3.dist-info}/METADATA +2 -1
- {medfl-2.0.10.dev2.dist-info → medfl-2.0.10.dev3.dist-info}/RECORD +13 -9
- {medfl-2.0.10.dev2.dist-info → medfl-2.0.10.dev3.dist-info}/LICENSE +0 -0
- {medfl-2.0.10.dev2.dist-info → medfl-2.0.10.dev3.dist-info}/WHEEL +0 -0
- {medfl-2.0.10.dev2.dist-info → medfl-2.0.10.dev3.dist-info}/top_level.txt +0 -0
|
@@ -11,6 +11,7 @@ class FederatedDataset:
|
|
|
11
11
|
trainloaders: list,
|
|
12
12
|
valloaders: list,
|
|
13
13
|
testloaders: list,
|
|
14
|
+
feature_names: list[str] | None = None,
|
|
14
15
|
):
|
|
15
16
|
"""
|
|
16
17
|
Represents a Federated Dataset.
|
|
@@ -30,6 +31,25 @@ class FederatedDataset:
|
|
|
30
31
|
self.testloaders = testloaders
|
|
31
32
|
self.size = len(self.trainloaders[0].dataset[0][0])
|
|
32
33
|
|
|
34
|
+
if feature_names is None:
|
|
35
|
+
feature_names = [
|
|
36
|
+
f"feature_{index}"
|
|
37
|
+
for index in range(self.size)
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
if len(feature_names) != self.size:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"feature_names length must match the "
|
|
43
|
+
f"federated dataset size. Expected {self.size}, "
|
|
44
|
+
f"received {len(feature_names)}."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
self.feature_names = list(
|
|
48
|
+
feature_names
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
33
53
|
db_manager = DatabaseManager()
|
|
34
54
|
db_manager.connect()
|
|
35
55
|
self.eng = db_manager.get_connection()
|
MEDfl/LearningManager/server.py
CHANGED
|
@@ -15,6 +15,12 @@ from .federated_dataset import FederatedDataset
|
|
|
15
15
|
from .model import Model
|
|
16
16
|
from .strategy import Strategy
|
|
17
17
|
|
|
18
|
+
from .shap import (
|
|
19
|
+
SHAPConfig,
|
|
20
|
+
LocalPyTorchSHAPExplainer,
|
|
21
|
+
aggregate_federated_shap,
|
|
22
|
+
)
|
|
23
|
+
|
|
18
24
|
|
|
19
25
|
# ---------------------------------------------------------------------------
|
|
20
26
|
# Logging setup — call this ONCE before starting the simulation.
|
|
@@ -64,7 +70,10 @@ class FlowerServer:
|
|
|
64
70
|
num_clients: int,
|
|
65
71
|
fed_dataset: FederatedDataset,
|
|
66
72
|
diff_privacy: bool = False,
|
|
67
|
-
client_resources: Optional[Dict[str, float]] = {'num_cpus': 1, 'num_gpus': 0.0}
|
|
73
|
+
client_resources: Optional[Dict[str, float]] = {'num_cpus': 1, 'num_gpus': 0.0} ,
|
|
74
|
+
shap_config: Optional[
|
|
75
|
+
SHAPConfig
|
|
76
|
+
] = None,
|
|
68
77
|
) -> None:
|
|
69
78
|
|
|
70
79
|
self.device = torch.device("cpu")
|
|
@@ -77,6 +86,19 @@ class FlowerServer:
|
|
|
77
86
|
self.strategy = strategy
|
|
78
87
|
self.client_resources = client_resources
|
|
79
88
|
|
|
89
|
+
self.shap_config = (
|
|
90
|
+
shap_config
|
|
91
|
+
if shap_config is not None
|
|
92
|
+
else SHAPConfig(enabled=False)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self.shap_config.validate(
|
|
96
|
+
input_size=self.fed_dataset.size
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
self.federated_shap_result = None
|
|
100
|
+
self.local_shap_results = []
|
|
101
|
+
|
|
80
102
|
setattr(self.strategy.strategy_object, "min_available_clients", self.num_clients)
|
|
81
103
|
setattr(self.strategy.strategy_object, "initial_parameters", fl.common.ndarrays_to_parameters(self.params))
|
|
82
104
|
setattr(self.strategy.strategy_object, "evaluate_fn", self.evaluate)
|
|
@@ -117,6 +139,319 @@ class FlowerServer:
|
|
|
117
139
|
if hasattr(self.global_model.criterion, "pos_weight") and self.global_model.criterion.pos_weight is not None:
|
|
118
140
|
self.global_model.criterion.pos_weight = self.global_model.criterion.pos_weight.detach().cpu()
|
|
119
141
|
self.validate()
|
|
142
|
+
|
|
143
|
+
def synchronize_final_global_model(
|
|
144
|
+
self,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""
|
|
147
|
+
Copy the last Flower-aggregated parameters into
|
|
148
|
+
the MEDfl global model wrapper.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
strategy_object = (
|
|
152
|
+
self.strategy.strategy_object
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if strategy_object is None:
|
|
156
|
+
raise RuntimeError(
|
|
157
|
+
"The Flower strategy has not been created"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
final_parameters = getattr(
|
|
161
|
+
strategy_object,
|
|
162
|
+
"latest_aggregated_parameters",
|
|
163
|
+
None,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if final_parameters is None:
|
|
167
|
+
# Your evaluate() method already records the latest
|
|
168
|
+
# centralized-evaluation parameter arrays.
|
|
169
|
+
final_arrays = getattr(
|
|
170
|
+
self,
|
|
171
|
+
"last_parameters",
|
|
172
|
+
None,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if final_arrays is None:
|
|
176
|
+
raise RuntimeError(
|
|
177
|
+
"The final global model parameters "
|
|
178
|
+
"could not be found"
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
final_arrays = (
|
|
182
|
+
fl.common.parameters_to_ndarrays(
|
|
183
|
+
final_parameters
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
self.global_model.set_parameters(
|
|
188
|
+
final_arrays
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
self.global_model.model.to(
|
|
192
|
+
self.device
|
|
193
|
+
)
|
|
194
|
+
self.global_model.model.eval()
|
|
195
|
+
|
|
196
|
+
logging.getLogger(
|
|
197
|
+
"MEDfl.server"
|
|
198
|
+
).info(
|
|
199
|
+
"Final global parameters synchronized "
|
|
200
|
+
"for post-training operations"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _get_shap_loaders(
|
|
204
|
+
self,
|
|
205
|
+
) -> list[DataLoader]:
|
|
206
|
+
"""
|
|
207
|
+
Return one loader per client according to SHAPConfig.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
split = self.shap_config.data_split
|
|
211
|
+
|
|
212
|
+
if split == "train":
|
|
213
|
+
return self.fed_dataset.trainloaders
|
|
214
|
+
|
|
215
|
+
if split == "validation":
|
|
216
|
+
return self.fed_dataset.valloaders
|
|
217
|
+
|
|
218
|
+
if split == "test":
|
|
219
|
+
return self.fed_dataset.testloaders
|
|
220
|
+
|
|
221
|
+
raise ValueError(
|
|
222
|
+
f"Unsupported SHAP data split: {split}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def calculate_local_shap_results(
|
|
226
|
+
self,
|
|
227
|
+
) -> list[dict]:
|
|
228
|
+
"""
|
|
229
|
+
Calculate local SHAP summaries for every client partition.
|
|
230
|
+
|
|
231
|
+
This method runs only after federated training has finished.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
if not self.shap_config.enabled:
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
shap_logger = logging.getLogger(
|
|
238
|
+
"MEDfl.shap"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
loaders = self._get_shap_loaders()
|
|
242
|
+
|
|
243
|
+
feature_names = (
|
|
244
|
+
self.shap_config.feature_names
|
|
245
|
+
if self.shap_config.feature_names
|
|
246
|
+
is not None
|
|
247
|
+
else self.fed_dataset.feature_names
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if len(feature_names) != self.fed_dataset.size:
|
|
251
|
+
raise ValueError(
|
|
252
|
+
"SHAP feature names do not match the "
|
|
253
|
+
"federated dataset input size"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
local_explainer = (
|
|
257
|
+
LocalPyTorchSHAPExplainer(
|
|
258
|
+
explainer_type=(
|
|
259
|
+
self.shap_config.explainer
|
|
260
|
+
),
|
|
261
|
+
background_size=(
|
|
262
|
+
self.shap_config.background_size
|
|
263
|
+
),
|
|
264
|
+
explanation_size=(
|
|
265
|
+
self.shap_config.explanation_size
|
|
266
|
+
),
|
|
267
|
+
random_seed=(
|
|
268
|
+
self.shap_config.random_seed
|
|
269
|
+
),
|
|
270
|
+
clipping_value=(
|
|
271
|
+
self.shap_config.clipping_value
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
local_results: list[dict] = []
|
|
277
|
+
|
|
278
|
+
for client_index, loader in enumerate(
|
|
279
|
+
loaders
|
|
280
|
+
):
|
|
281
|
+
client_id = str(client_index)
|
|
282
|
+
|
|
283
|
+
if loader is None:
|
|
284
|
+
shap_logger.warning(
|
|
285
|
+
"Skipping SHAP client %s: loader is None",
|
|
286
|
+
client_id,
|
|
287
|
+
)
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
available_samples = len(
|
|
291
|
+
loader.dataset
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if available_samples == 0:
|
|
295
|
+
shap_logger.warning(
|
|
296
|
+
"Skipping SHAP client %s: empty dataset",
|
|
297
|
+
client_id,
|
|
298
|
+
)
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
shap_logger.info(
|
|
302
|
+
"Starting local SHAP | client=%s | "
|
|
303
|
+
"available_samples=%d",
|
|
304
|
+
client_id,
|
|
305
|
+
available_samples,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
# The model is used for inference only.
|
|
310
|
+
# No optimizer or training method is called.
|
|
311
|
+
local_result = (
|
|
312
|
+
local_explainer.calculate(
|
|
313
|
+
model=self.global_model.model,
|
|
314
|
+
loader=loader,
|
|
315
|
+
device=self.device,
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
explained_samples = int(
|
|
320
|
+
local_result["sample_count"]
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
if (
|
|
324
|
+
explained_samples
|
|
325
|
+
< self.shap_config.minimum_samples
|
|
326
|
+
):
|
|
327
|
+
shap_logger.warning(
|
|
328
|
+
"Skipping client %s because only %d "
|
|
329
|
+
"samples were explained; minimum=%d",
|
|
330
|
+
client_id,
|
|
331
|
+
explained_samples,
|
|
332
|
+
self.shap_config.minimum_samples,
|
|
333
|
+
)
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
local_result[
|
|
337
|
+
"client_id"
|
|
338
|
+
] = client_id
|
|
339
|
+
|
|
340
|
+
local_result[
|
|
341
|
+
"feature_names"
|
|
342
|
+
] = list(feature_names)
|
|
343
|
+
|
|
344
|
+
local_result[
|
|
345
|
+
"available_samples"
|
|
346
|
+
] = available_samples
|
|
347
|
+
|
|
348
|
+
local_results.append(
|
|
349
|
+
local_result
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
shap_logger.info(
|
|
353
|
+
"Local SHAP completed | client=%s | "
|
|
354
|
+
"explained_samples=%d",
|
|
355
|
+
client_id,
|
|
356
|
+
explained_samples,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
except Exception as exc:
|
|
360
|
+
shap_logger.exception(
|
|
361
|
+
"Local SHAP failed for client %s: %s",
|
|
362
|
+
client_id,
|
|
363
|
+
exc,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
if not local_results:
|
|
367
|
+
raise RuntimeError(
|
|
368
|
+
"SHAP was enabled, but no client produced "
|
|
369
|
+
"a valid explanation result"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
self.local_shap_results = (
|
|
373
|
+
local_results
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
return local_results
|
|
377
|
+
|
|
378
|
+
def calculate_federated_shap(
|
|
379
|
+
self,
|
|
380
|
+
) -> dict:
|
|
381
|
+
"""
|
|
382
|
+
Run the complete post-training SHAP phase.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
shap_logger = logging.getLogger(
|
|
386
|
+
"MEDfl.shap"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
if not self.shap_config.enabled:
|
|
390
|
+
return {
|
|
391
|
+
"status": "disabled",
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
shap_logger.info(
|
|
395
|
+
"========================================"
|
|
396
|
+
)
|
|
397
|
+
shap_logger.info(
|
|
398
|
+
"Starting post-training federated SHAP"
|
|
399
|
+
)
|
|
400
|
+
shap_logger.info(
|
|
401
|
+
"Explainer: %s",
|
|
402
|
+
self.shap_config.explainer,
|
|
403
|
+
)
|
|
404
|
+
shap_logger.info(
|
|
405
|
+
"Data split: %s",
|
|
406
|
+
self.shap_config.data_split,
|
|
407
|
+
)
|
|
408
|
+
shap_logger.info(
|
|
409
|
+
"========================================"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
local_results = (
|
|
413
|
+
self.calculate_local_shap_results()
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
federated_result = (
|
|
417
|
+
aggregate_federated_shap(
|
|
418
|
+
client_results=local_results,
|
|
419
|
+
include_client_results=(
|
|
420
|
+
self.shap_config
|
|
421
|
+
.include_client_results
|
|
422
|
+
),
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
federated_result["explainer"] = (
|
|
427
|
+
self.shap_config.explainer
|
|
428
|
+
)
|
|
429
|
+
federated_result["data_split"] = (
|
|
430
|
+
self.shap_config.data_split
|
|
431
|
+
)
|
|
432
|
+
federated_result["background_size"] = (
|
|
433
|
+
self.shap_config.background_size
|
|
434
|
+
)
|
|
435
|
+
federated_result["requested_explanation_size"] = (
|
|
436
|
+
self.shap_config.explanation_size
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
self.federated_shap_result = (
|
|
440
|
+
federated_result
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
shap_logger.info(
|
|
444
|
+
"Federated SHAP completed | clients=%d | "
|
|
445
|
+
"samples=%d",
|
|
446
|
+
federated_result[
|
|
447
|
+
"participating_clients"
|
|
448
|
+
],
|
|
449
|
+
federated_result[
|
|
450
|
+
"explained_samples"
|
|
451
|
+
],
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
return federated_result
|
|
120
455
|
|
|
121
456
|
def validate(self) -> None:
|
|
122
457
|
if not isinstance(self.global_model, Model):
|
|
@@ -249,4 +584,32 @@ class FlowerServer:
|
|
|
249
584
|
ray_init_args=ray_init_args,
|
|
250
585
|
client_resources=self.client_resources
|
|
251
586
|
)
|
|
587
|
+
# Training has now completely finished.
|
|
588
|
+
self.synchronize_final_global_model()
|
|
589
|
+
|
|
590
|
+
if self.shap_config.enabled:
|
|
591
|
+
try:
|
|
592
|
+
self.federated_shap_result = (
|
|
593
|
+
self.calculate_federated_shap()
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
except Exception as exc:
|
|
597
|
+
logging.getLogger(
|
|
598
|
+
"MEDfl.shap"
|
|
599
|
+
).exception(
|
|
600
|
+
"Post-training federated SHAP failed: %s",
|
|
601
|
+
exc,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
# Important: a SHAP failure does not mean
|
|
605
|
+
# federated training failed.
|
|
606
|
+
self.federated_shap_result = {
|
|
607
|
+
"status": "failed",
|
|
608
|
+
"error": str(exc),
|
|
609
|
+
}
|
|
610
|
+
else:
|
|
611
|
+
self.federated_shap_result = {
|
|
612
|
+
"status": "disabled",
|
|
613
|
+
}
|
|
614
|
+
|
|
252
615
|
return history
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _validate_client_results(
|
|
9
|
+
client_results: list[dict],
|
|
10
|
+
) -> tuple[list[str], int]:
|
|
11
|
+
if not client_results:
|
|
12
|
+
raise ValueError(
|
|
13
|
+
"No client SHAP results were provided"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
reference_features = client_results[0][
|
|
17
|
+
"feature_names"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
feature_count = len(reference_features)
|
|
21
|
+
|
|
22
|
+
required_array_keys = {
|
|
23
|
+
"abs_sum",
|
|
24
|
+
"signed_sum",
|
|
25
|
+
"square_sum",
|
|
26
|
+
"positive_count",
|
|
27
|
+
"negative_count",
|
|
28
|
+
"zero_count",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for result in client_results:
|
|
32
|
+
if result["feature_names"] != reference_features:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
"Clients used different feature names or orders"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if int(result["sample_count"]) <= 0:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Client {result.get('client_id')} returned an "
|
|
40
|
+
"invalid SHAP sample count"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
for key in required_array_keys:
|
|
44
|
+
if key not in result:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"Client result is missing '{key}'"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
values = np.asarray(result[key])
|
|
50
|
+
|
|
51
|
+
if values.shape != (feature_count,):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Client {result.get('client_id')} returned "
|
|
54
|
+
f"invalid shape {values.shape} for {key}. "
|
|
55
|
+
f"Expected ({feature_count},)."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return reference_features, feature_count
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def aggregate_federated_shap(
|
|
62
|
+
client_results: list[dict],
|
|
63
|
+
include_client_results: bool = True,
|
|
64
|
+
) -> dict[str, Any]:
|
|
65
|
+
"""
|
|
66
|
+
Aggregate additive local SHAP statistics.
|
|
67
|
+
|
|
68
|
+
The global mean absolute SHAP is equivalent to calculating:
|
|
69
|
+
|
|
70
|
+
sum of all |SHAP values| / number of all explained samples
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
feature_names, feature_count = (
|
|
74
|
+
_validate_client_results(client_results)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
total_samples = sum(
|
|
78
|
+
int(result["sample_count"])
|
|
79
|
+
for result in client_results
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
abs_sum = np.zeros(
|
|
83
|
+
feature_count,
|
|
84
|
+
dtype=np.float64,
|
|
85
|
+
)
|
|
86
|
+
signed_sum = np.zeros(
|
|
87
|
+
feature_count,
|
|
88
|
+
dtype=np.float64,
|
|
89
|
+
)
|
|
90
|
+
square_sum = np.zeros(
|
|
91
|
+
feature_count,
|
|
92
|
+
dtype=np.float64,
|
|
93
|
+
)
|
|
94
|
+
positive_count = np.zeros(
|
|
95
|
+
feature_count,
|
|
96
|
+
dtype=np.float64,
|
|
97
|
+
)
|
|
98
|
+
negative_count = np.zeros(
|
|
99
|
+
feature_count,
|
|
100
|
+
dtype=np.float64,
|
|
101
|
+
)
|
|
102
|
+
zero_count = np.zeros(
|
|
103
|
+
feature_count,
|
|
104
|
+
dtype=np.float64,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
for result in client_results:
|
|
108
|
+
abs_sum += np.asarray(
|
|
109
|
+
result["abs_sum"],
|
|
110
|
+
dtype=np.float64,
|
|
111
|
+
)
|
|
112
|
+
signed_sum += np.asarray(
|
|
113
|
+
result["signed_sum"],
|
|
114
|
+
dtype=np.float64,
|
|
115
|
+
)
|
|
116
|
+
square_sum += np.asarray(
|
|
117
|
+
result["square_sum"],
|
|
118
|
+
dtype=np.float64,
|
|
119
|
+
)
|
|
120
|
+
positive_count += np.asarray(
|
|
121
|
+
result["positive_count"],
|
|
122
|
+
dtype=np.float64,
|
|
123
|
+
)
|
|
124
|
+
negative_count += np.asarray(
|
|
125
|
+
result["negative_count"],
|
|
126
|
+
dtype=np.float64,
|
|
127
|
+
)
|
|
128
|
+
zero_count += np.asarray(
|
|
129
|
+
result["zero_count"],
|
|
130
|
+
dtype=np.float64,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
mean_abs = abs_sum / total_samples
|
|
134
|
+
mean_signed = signed_sum / total_samples
|
|
135
|
+
mean_square = square_sum / total_samples
|
|
136
|
+
|
|
137
|
+
# Var(X) = E(X²) - E(X)²
|
|
138
|
+
variance = np.maximum(
|
|
139
|
+
mean_square - np.square(mean_signed),
|
|
140
|
+
0.0,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
standard_deviation = np.sqrt(variance)
|
|
144
|
+
|
|
145
|
+
features = []
|
|
146
|
+
|
|
147
|
+
for index, feature_name in enumerate(
|
|
148
|
+
feature_names
|
|
149
|
+
):
|
|
150
|
+
features.append(
|
|
151
|
+
{
|
|
152
|
+
"feature": feature_name,
|
|
153
|
+
"feature_index": index,
|
|
154
|
+
"mean_abs_shap": float(
|
|
155
|
+
mean_abs[index]
|
|
156
|
+
),
|
|
157
|
+
"mean_signed_shap": float(
|
|
158
|
+
mean_signed[index]
|
|
159
|
+
),
|
|
160
|
+
"shap_std": float(
|
|
161
|
+
standard_deviation[index]
|
|
162
|
+
),
|
|
163
|
+
"positive_rate": float(
|
|
164
|
+
positive_count[index]
|
|
165
|
+
/ total_samples
|
|
166
|
+
),
|
|
167
|
+
"negative_rate": float(
|
|
168
|
+
negative_count[index]
|
|
169
|
+
/ total_samples
|
|
170
|
+
),
|
|
171
|
+
"zero_rate": float(
|
|
172
|
+
zero_count[index]
|
|
173
|
+
/ total_samples
|
|
174
|
+
),
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Highest global importance first.
|
|
179
|
+
features.sort(
|
|
180
|
+
key=lambda item: item["mean_abs_shap"],
|
|
181
|
+
reverse=True,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
final_result: dict[str, Any] = {
|
|
185
|
+
"status": "completed",
|
|
186
|
+
"participating_clients": len(
|
|
187
|
+
client_results
|
|
188
|
+
),
|
|
189
|
+
"explained_samples": int(
|
|
190
|
+
total_samples
|
|
191
|
+
),
|
|
192
|
+
"feature_importance": features,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if include_client_results:
|
|
196
|
+
client_summaries = []
|
|
197
|
+
|
|
198
|
+
for result in client_results:
|
|
199
|
+
sample_count = int(
|
|
200
|
+
result["sample_count"]
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
client_mean_abs = (
|
|
204
|
+
np.asarray(
|
|
205
|
+
result["abs_sum"],
|
|
206
|
+
dtype=np.float64,
|
|
207
|
+
)
|
|
208
|
+
/ sample_count
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
local_features = [
|
|
212
|
+
{
|
|
213
|
+
"feature": feature_name,
|
|
214
|
+
"feature_index": index,
|
|
215
|
+
"mean_abs_shap": float(
|
|
216
|
+
client_mean_abs[index]
|
|
217
|
+
),
|
|
218
|
+
}
|
|
219
|
+
for index, feature_name in enumerate(
|
|
220
|
+
feature_names
|
|
221
|
+
)
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
local_features.sort(
|
|
225
|
+
key=lambda item: item[
|
|
226
|
+
"mean_abs_shap"
|
|
227
|
+
],
|
|
228
|
+
reverse=True,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
client_summaries.append(
|
|
232
|
+
{
|
|
233
|
+
"client_id": result[
|
|
234
|
+
"client_id"
|
|
235
|
+
],
|
|
236
|
+
"explained_samples": (
|
|
237
|
+
sample_count
|
|
238
|
+
),
|
|
239
|
+
"feature_importance": (
|
|
240
|
+
local_features
|
|
241
|
+
),
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
final_result["client_results"] = (
|
|
246
|
+
client_summaries
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return final_result
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Literal, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class SHAPConfig:
|
|
9
|
+
"""
|
|
10
|
+
Configuration for the optional post-training federated SHAP phase.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
enabled: bool = False
|
|
14
|
+
|
|
15
|
+
explainer: Literal[
|
|
16
|
+
"gradient",
|
|
17
|
+
"deep",
|
|
18
|
+
] = "gradient"
|
|
19
|
+
|
|
20
|
+
data_split: Literal[
|
|
21
|
+
"train",
|
|
22
|
+
"validation",
|
|
23
|
+
"test",
|
|
24
|
+
] = "validation"
|
|
25
|
+
|
|
26
|
+
background_size: int = 100
|
|
27
|
+
explanation_size: int = 500
|
|
28
|
+
random_seed: int = 42
|
|
29
|
+
|
|
30
|
+
feature_names: Optional[list[str]] = None
|
|
31
|
+
|
|
32
|
+
minimum_samples: int = 10
|
|
33
|
+
clipping_value: Optional[float] = None
|
|
34
|
+
|
|
35
|
+
# Whether individual client summaries should appear
|
|
36
|
+
# in the final result.
|
|
37
|
+
include_client_results: bool = True
|
|
38
|
+
|
|
39
|
+
def validate(self, input_size: int) -> None:
|
|
40
|
+
if not isinstance(self.enabled, bool):
|
|
41
|
+
raise TypeError("SHAP enabled must be a boolean")
|
|
42
|
+
|
|
43
|
+
if self.explainer not in {"gradient", "deep"}:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
"SHAP explainer must be 'gradient' or 'deep'"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if self.data_split not in {
|
|
49
|
+
"train",
|
|
50
|
+
"validation",
|
|
51
|
+
"test",
|
|
52
|
+
}:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"SHAP data_split must be train, validation, or test"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if self.background_size <= 0:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
"SHAP background_size must be greater than zero"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if self.explanation_size <= 0:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"SHAP explanation_size must be greater than zero"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if self.minimum_samples <= 0:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
"SHAP minimum_samples must be greater than zero"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if self.clipping_value is not None:
|
|
73
|
+
if self.clipping_value <= 0:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"SHAP clipping_value must be greater than zero"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if self.feature_names is not None:
|
|
79
|
+
if len(self.feature_names) != input_size:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
"The number of SHAP feature names must match "
|
|
82
|
+
f"the model input size. Expected {input_size}, "
|
|
83
|
+
f"received {len(self.feature_names)}."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if len(set(self.feature_names)) != len(
|
|
87
|
+
self.feature_names
|
|
88
|
+
):
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"SHAP feature names must be unique"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def resolved_feature_names(
|
|
94
|
+
self,
|
|
95
|
+
input_size: int,
|
|
96
|
+
) -> list[str]:
|
|
97
|
+
if self.feature_names is not None:
|
|
98
|
+
return list(self.feature_names)
|
|
99
|
+
|
|
100
|
+
return [
|
|
101
|
+
f"feature_{index}"
|
|
102
|
+
for index in range(input_size)
|
|
103
|
+
]
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import torch
|
|
8
|
+
from torch.utils.data import DataLoader
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("MEDfl.shap")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LocalPyTorchSHAPExplainer:
|
|
15
|
+
"""
|
|
16
|
+
Calculate SHAP values for one client partition.
|
|
17
|
+
|
|
18
|
+
Raw samples and per-sample SHAP values are not returned.
|
|
19
|
+
Only additive summary statistics are returned.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
explainer_type: str = "gradient",
|
|
25
|
+
background_size: int = 100,
|
|
26
|
+
explanation_size: int = 500,
|
|
27
|
+
random_seed: int = 42,
|
|
28
|
+
clipping_value: float | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.explainer_type = explainer_type
|
|
31
|
+
self.background_size = background_size
|
|
32
|
+
self.explanation_size = explanation_size
|
|
33
|
+
self.random_seed = random_seed
|
|
34
|
+
self.clipping_value = clipping_value
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _collect_features(
|
|
38
|
+
loader: DataLoader,
|
|
39
|
+
) -> torch.Tensor:
|
|
40
|
+
"""
|
|
41
|
+
Collect only X values from the selected client loader.
|
|
42
|
+
Labels are not required to calculate SHAP values.
|
|
43
|
+
"""
|
|
44
|
+
feature_batches: list[torch.Tensor] = []
|
|
45
|
+
|
|
46
|
+
for batch_index, batch in enumerate(loader):
|
|
47
|
+
if not isinstance(batch, (tuple, list)):
|
|
48
|
+
raise TypeError(
|
|
49
|
+
"Each DataLoader batch must be a tuple or list"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if len(batch) == 0:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"Empty batch received at index {batch_index}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
X = batch[0]
|
|
58
|
+
|
|
59
|
+
if not isinstance(X, torch.Tensor):
|
|
60
|
+
X = torch.as_tensor(X)
|
|
61
|
+
|
|
62
|
+
X = X.detach().cpu().float()
|
|
63
|
+
|
|
64
|
+
if torch.isnan(X).any():
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"SHAP input contains NaN values in batch "
|
|
67
|
+
f"{batch_index}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if torch.isinf(X).any():
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"SHAP input contains infinite values in batch "
|
|
73
|
+
f"{batch_index}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
feature_batches.append(X)
|
|
77
|
+
|
|
78
|
+
if not feature_batches:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
"Cannot calculate SHAP using an empty DataLoader"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
features = torch.cat(
|
|
84
|
+
feature_batches,
|
|
85
|
+
dim=0,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# First implementation: tabular data.
|
|
89
|
+
if features.ndim != 2:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
"The current SHAP implementation supports tabular "
|
|
92
|
+
"input with shape (samples, features). "
|
|
93
|
+
f"Received shape {tuple(features.shape)}."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return features
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _sample_rows(
|
|
100
|
+
data: torch.Tensor,
|
|
101
|
+
requested_size: int,
|
|
102
|
+
generator: torch.Generator,
|
|
103
|
+
) -> torch.Tensor:
|
|
104
|
+
selected_size = min(
|
|
105
|
+
requested_size,
|
|
106
|
+
len(data),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
indices = torch.randperm(
|
|
110
|
+
len(data),
|
|
111
|
+
generator=generator,
|
|
112
|
+
)[:selected_size]
|
|
113
|
+
|
|
114
|
+
return data[indices]
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _normalize_shap_values(
|
|
118
|
+
raw_values: Any,
|
|
119
|
+
sample_count: int,
|
|
120
|
+
feature_count: int,
|
|
121
|
+
) -> np.ndarray:
|
|
122
|
+
"""
|
|
123
|
+
Normalize the different shapes returned by SHAP versions.
|
|
124
|
+
|
|
125
|
+
Expected final shape:
|
|
126
|
+
(number_of_samples, number_of_features)
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
if hasattr(raw_values, "values"):
|
|
130
|
+
raw_values = raw_values.values
|
|
131
|
+
|
|
132
|
+
if isinstance(raw_values, list):
|
|
133
|
+
if len(raw_values) != 1:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
"The current implementation supports binary or "
|
|
136
|
+
"single-output models only. Multiclass SHAP "
|
|
137
|
+
"requires class-specific aggregation."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
raw_values = raw_values[0]
|
|
141
|
+
|
|
142
|
+
values = np.asarray(raw_values)
|
|
143
|
+
|
|
144
|
+
# Possible shape: (samples, features, 1)
|
|
145
|
+
if (
|
|
146
|
+
values.ndim == 3
|
|
147
|
+
and values.shape[-1] == 1
|
|
148
|
+
):
|
|
149
|
+
values = values[..., 0]
|
|
150
|
+
|
|
151
|
+
# Possible shape: (1, samples, features)
|
|
152
|
+
if (
|
|
153
|
+
values.ndim == 3
|
|
154
|
+
and values.shape[0] == 1
|
|
155
|
+
):
|
|
156
|
+
values = values[0]
|
|
157
|
+
|
|
158
|
+
expected_shape = (
|
|
159
|
+
sample_count,
|
|
160
|
+
feature_count,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if values.shape != expected_shape:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
"Unexpected SHAP output shape. "
|
|
166
|
+
f"Expected {expected_shape}, received {values.shape}."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
values = values.astype(
|
|
170
|
+
np.float64,
|
|
171
|
+
copy=False,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if np.isnan(values).any():
|
|
175
|
+
raise ValueError(
|
|
176
|
+
"Calculated SHAP values contain NaN values"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if np.isinf(values).any():
|
|
180
|
+
raise ValueError(
|
|
181
|
+
"Calculated SHAP values contain infinite values"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return values
|
|
185
|
+
|
|
186
|
+
def _create_explainer(
|
|
187
|
+
self,
|
|
188
|
+
model: torch.nn.Module,
|
|
189
|
+
background: torch.Tensor,
|
|
190
|
+
):
|
|
191
|
+
try:
|
|
192
|
+
import shap
|
|
193
|
+
except ImportError as exc:
|
|
194
|
+
raise ImportError(
|
|
195
|
+
"SHAP is not installed. Install it with: "
|
|
196
|
+
"pip install shap"
|
|
197
|
+
) from exc
|
|
198
|
+
|
|
199
|
+
if self.explainer_type == "gradient":
|
|
200
|
+
return shap.GradientExplainer(
|
|
201
|
+
model,
|
|
202
|
+
background,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if self.explainer_type == "deep":
|
|
206
|
+
return shap.DeepExplainer(
|
|
207
|
+
model,
|
|
208
|
+
background,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
raise ValueError(
|
|
212
|
+
f"Unsupported SHAP explainer: {self.explainer_type}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def calculate(
|
|
216
|
+
self,
|
|
217
|
+
model: torch.nn.Module,
|
|
218
|
+
loader: DataLoader,
|
|
219
|
+
device: torch.device,
|
|
220
|
+
) -> dict:
|
|
221
|
+
"""
|
|
222
|
+
Calculate SHAP for one client partition.
|
|
223
|
+
|
|
224
|
+
The returned values are additive summaries. They can be
|
|
225
|
+
combined across clients without transmitting raw SHAP rows.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
features = self._collect_features(loader)
|
|
229
|
+
|
|
230
|
+
if len(features) < 2:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
"At least two samples are required for SHAP"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
generator = torch.Generator()
|
|
236
|
+
generator.manual_seed(self.random_seed)
|
|
237
|
+
|
|
238
|
+
background = self._sample_rows(
|
|
239
|
+
data=features,
|
|
240
|
+
requested_size=self.background_size,
|
|
241
|
+
generator=generator,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
explanation_data = self._sample_rows(
|
|
245
|
+
data=features,
|
|
246
|
+
requested_size=self.explanation_size,
|
|
247
|
+
generator=generator,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
background = background.to(device)
|
|
251
|
+
explanation_data = explanation_data.to(device)
|
|
252
|
+
|
|
253
|
+
model = model.to(device)
|
|
254
|
+
model.eval()
|
|
255
|
+
|
|
256
|
+
logger.info(
|
|
257
|
+
"Calculating local SHAP | "
|
|
258
|
+
"explainer=%s | available=%d | background=%d | "
|
|
259
|
+
"explained=%d",
|
|
260
|
+
self.explainer_type,
|
|
261
|
+
len(features),
|
|
262
|
+
len(background),
|
|
263
|
+
len(explanation_data),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
explainer = self._create_explainer(
|
|
267
|
+
model=model,
|
|
268
|
+
background=background,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
raw_values = explainer.shap_values(
|
|
272
|
+
explanation_data
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
shap_values = self._normalize_shap_values(
|
|
276
|
+
raw_values=raw_values,
|
|
277
|
+
sample_count=len(explanation_data),
|
|
278
|
+
feature_count=explanation_data.shape[1],
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if self.clipping_value is not None:
|
|
282
|
+
shap_values = np.clip(
|
|
283
|
+
shap_values,
|
|
284
|
+
-self.clipping_value,
|
|
285
|
+
self.clipping_value,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Additive summaries are preferable because they can later
|
|
289
|
+
# be used with distributed or secure aggregation.
|
|
290
|
+
result = {
|
|
291
|
+
"sample_count": int(shap_values.shape[0]),
|
|
292
|
+
|
|
293
|
+
"abs_sum": np.sum(
|
|
294
|
+
np.abs(shap_values),
|
|
295
|
+
axis=0,
|
|
296
|
+
),
|
|
297
|
+
|
|
298
|
+
"signed_sum": np.sum(
|
|
299
|
+
shap_values,
|
|
300
|
+
axis=0,
|
|
301
|
+
),
|
|
302
|
+
|
|
303
|
+
"square_sum": np.sum(
|
|
304
|
+
np.square(shap_values),
|
|
305
|
+
axis=0,
|
|
306
|
+
),
|
|
307
|
+
|
|
308
|
+
"positive_count": np.sum(
|
|
309
|
+
shap_values > 0,
|
|
310
|
+
axis=0,
|
|
311
|
+
).astype(np.int64),
|
|
312
|
+
|
|
313
|
+
"negative_count": np.sum(
|
|
314
|
+
shap_values < 0,
|
|
315
|
+
axis=0,
|
|
316
|
+
).astype(np.int64),
|
|
317
|
+
|
|
318
|
+
"zero_count": np.sum(
|
|
319
|
+
shap_values == 0,
|
|
320
|
+
axis=0,
|
|
321
|
+
).astype(np.int64),
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return result
|
|
@@ -152,6 +152,8 @@ class Strategy:
|
|
|
152
152
|
# previous global model (flattened) for delta/cos sim
|
|
153
153
|
self._prev_global_flat: Optional[np.ndarray] = None
|
|
154
154
|
|
|
155
|
+
self.latest_aggregated_parameters = None
|
|
156
|
+
|
|
155
157
|
# logger (Flower config will handle output)
|
|
156
158
|
self._log = logging.getLogger("flwr")
|
|
157
159
|
self._debug_print = debug_print
|
|
@@ -225,10 +227,12 @@ class Strategy:
|
|
|
225
227
|
|
|
226
228
|
# Call base aggregation
|
|
227
229
|
agg = super().aggregate_fit(server_round, results, failures)
|
|
230
|
+
|
|
228
231
|
|
|
229
232
|
# Round summary + global fingerprint (only if aggregation produced parameters)
|
|
230
233
|
if agg is not None:
|
|
231
234
|
parameters_agg, metrics_agg = agg # type: ignore[misc]
|
|
235
|
+
self.latest_aggregated_parameters = (parameters_agg)
|
|
232
236
|
flat = self._params_to_flat(parameters_agg)
|
|
233
237
|
fp = self._fingerprint(flat)
|
|
234
238
|
self._prev_global_flat = flat
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: medfl
|
|
3
|
-
Version: 2.0.10.
|
|
3
|
+
Version: 2.0.10.dev3
|
|
4
4
|
Summary: Python Open-source package for simulating federated learning and differential privacy
|
|
5
5
|
Home-page: https://github.com/MEDomics-UdeS/MEDfl
|
|
6
6
|
Author: MEDomics consortium
|
|
@@ -34,6 +34,7 @@ Requires-Dist: Sphinx<6.0,>=5.3
|
|
|
34
34
|
Requires-Dist: sphinx-jsonschema==1.19.1
|
|
35
35
|
Requires-Dist: sphinx-rtd-dark-mode==1.2.4
|
|
36
36
|
Requires-Dist: xgboost
|
|
37
|
+
Requires-Dist: shap
|
|
37
38
|
|
|
38
39
|
# MEDfl: A Collaborative Framework for Federated Learning in Medicine
|
|
39
40
|
|
|
@@ -2,15 +2,19 @@ MEDfl/__init__.py,sha256=70DmtU4C3A-1XYoaYm0moXBe-YGJ2FhEe3ga5SQVTts,97
|
|
|
2
2
|
MEDfl/LearningManager/__init__.py,sha256=IMHJVeyx5ew0U_90LNMNCd4QISzWv3XCCri7fQRvcsM,341
|
|
3
3
|
MEDfl/LearningManager/client.py,sha256=DQqptRl9Yff3G-THpYz7XOYzUePHKCkPKHBGg3tw88Q,6427
|
|
4
4
|
MEDfl/LearningManager/dynamicModal.py,sha256=q8u7xPpj_TdZnSr8kYj0Xx7Sdz-diXsKBAfVce8-qSU,10534
|
|
5
|
-
MEDfl/LearningManager/federated_dataset.py,sha256=
|
|
5
|
+
MEDfl/LearningManager/federated_dataset.py,sha256=tUuM3fThIFAtUzCdBFSJNaNbGmT5zKF_M-AxIksDJbY,2623
|
|
6
6
|
MEDfl/LearningManager/flpipeline.py,sha256=5lT2uod5EqnkRQ04cgm0gYyZz0djumfIYipCrzX1fdo,7111
|
|
7
7
|
MEDfl/LearningManager/model.py,sha256=f6_S0pGtal9d314YHQh8EDkBnpCnlAS_Ak-dly4w-B8,10718
|
|
8
8
|
MEDfl/LearningManager/params.yaml,sha256=joS329zAmYwKzfMVaZt12vx53FL9G3KWTPfYQZck1WI,434
|
|
9
9
|
MEDfl/LearningManager/params_optimiser.py,sha256=8e0gCt4imwQHlNSJ3A2EAuc3wSr6yfSI6JDghohfmZQ,17618
|
|
10
10
|
MEDfl/LearningManager/plot.py,sha256=h9k5KuPN4xncK-M4X4llUWug1gziGtzq4WeoJ4UQ8qI,10704
|
|
11
|
-
MEDfl/LearningManager/server.py,sha256=
|
|
12
|
-
MEDfl/LearningManager/strategy.py,sha256=
|
|
11
|
+
MEDfl/LearningManager/server.py,sha256=zx2yc70TfDVwuJ31SD0SdaHk31cThspTlCdxDI5FCSM,19737
|
|
12
|
+
MEDfl/LearningManager/strategy.py,sha256=YsQ1tFCuFjoRemNBjoygnrNzo59UdYvggbIT8zP2YCE,14071
|
|
13
13
|
MEDfl/LearningManager/utils.py,sha256=90J-d2JiHbaQT2MdlfdW_YnpCUcjX16uE_vyW9MdFMY,9600
|
|
14
|
+
MEDfl/LearningManager/shap/__init__.py,sha256=TKc24eg88e89vfoOgLfyKuImkLgGU4Bsl9Kcf-XRHuk,227
|
|
15
|
+
MEDfl/LearningManager/shap/aggregation.py,sha256=Kg8LbxBXDMGqQ4s2-uy5f2vxa44K6Wj15e0QGxgX67E,6330
|
|
16
|
+
MEDfl/LearningManager/shap/config.py,sha256=vSKHHO7x34-CeEc7lvcrj7CsAnBKvE3yv9f-4mF4fiM,2813
|
|
17
|
+
MEDfl/LearningManager/shap/explainer.py,sha256=p4Ri3xmjJpk4cfq2F4ewaNxLSw68rM4Rl7T_S0DnJpU,8598
|
|
14
18
|
MEDfl/LearningManager/xgboost/__init__.py,sha256=rc8fJMNPc8U3crWaEA2yZ0-Uw2Nn-3HiEqCGoG_eZN0,362
|
|
15
19
|
MEDfl/LearningManager/xgboost/client.py,sha256=czrk90vNZwRpiIRrOtYZZUfYVIZu18NRgcdDCBvnjZo,8363
|
|
16
20
|
MEDfl/LearningManager/xgboost/config.py,sha256=TAS4evXe1DmhTWEduKbeG_Dg4D_oTQCQn96LmQvlIHM,1911
|
|
@@ -18,7 +22,7 @@ MEDfl/LearningManager/xgboost/data.py,sha256=UTFx3SOQuKZP94kBeExB1r4MBFKQXx2z4Kq
|
|
|
18
22
|
MEDfl/LearningManager/xgboost/metrics.py,sha256=lkIChQH6wh6PUT5chIn85jKMpC4IjcERra2cI-SX8o4,3999
|
|
19
23
|
MEDfl/LearningManager/xgboost/serialization.py,sha256=ymoywErp3a8MvitypXYhtQ6feviFsOUsJbPD57VEKBQ,1005
|
|
20
24
|
MEDfl/LearningManager/xgboost/server.py,sha256=JPkWOoHD6TKkLlsvtFlCFws-exZDHDVEdPYUHlfknAM,7913
|
|
21
|
-
MEDfl/LearningManager/xgboost/strategy.py,sha256=
|
|
25
|
+
MEDfl/LearningManager/xgboost/strategy.py,sha256=tEsXYrAVy6zYO1E-0VV9RvIwcWUBmlwhs4V8UOYzL8U,18653
|
|
22
26
|
MEDfl/NetManager/__init__.py,sha256=OpgsIiBg7UA6Bfnu_kqGfEPxU8JfpPxSFU98TOeDTP0,273
|
|
23
27
|
MEDfl/NetManager/database_connector.py,sha256=PZEAesMR-k9jSuthx4wOTkMzUWB3INODOrhQgpMO0kY,1554
|
|
24
28
|
MEDfl/NetManager/dataset.py,sha256=OxUGlUDyjpD9Q0JhyVq7GMvzh42wcqYUArX-YXfouB4,2795
|
|
@@ -44,8 +48,8 @@ MEDfl/scripts/base.py,sha256=QrmG7gkiPYkAy-5tXxJgJmOSLGAKeIVH6i0jq7G9xnA,752
|
|
|
44
48
|
MEDfl/scripts/create_db.py,sha256=MnFtZkTueRZ-3qXPNX4JsXjOKj-4mlkxoRhSFdRcvJw,3817
|
|
45
49
|
alembic/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
46
50
|
alembic/env.py,sha256=-aSZ6SlJeK1ZeqHgM-54hOi9LhJRFP0SZGjut-JnY-4,1588
|
|
47
|
-
medfl-2.0.10.
|
|
48
|
-
medfl-2.0.10.
|
|
49
|
-
medfl-2.0.10.
|
|
50
|
-
medfl-2.0.10.
|
|
51
|
-
medfl-2.0.10.
|
|
51
|
+
medfl-2.0.10.dev3.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
52
|
+
medfl-2.0.10.dev3.dist-info/METADATA,sha256=9SdM6St_DWRV-JQMtV0VTYkMMV8jboxmIW7RVSiRq5s,6652
|
|
53
|
+
medfl-2.0.10.dev3.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
|
|
54
|
+
medfl-2.0.10.dev3.dist-info/top_level.txt,sha256=dIL9X8HOFuaVSzpg40DVveDPrymWRoShHtspH7kkjdI,14
|
|
55
|
+
medfl-2.0.10.dev3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|