MEDfl 2.0.10.dev5__tar.gz → 2.0.10.dev7__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.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/client.py +195 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/server.py +93 -2
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/strategy.py +262 -5
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/client.py +291 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/strategy.py +290 -10
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/PKG-INFO +1 -1
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/medfl.egg-info/PKG-INFO +1 -1
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/setup.py +1 -1
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/LICENSE +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/__init__.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/client.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/dynamicModal.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/federated_dataset.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/flpipeline.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/model.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/params.yaml +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/params_optimiser.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/plot.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/server.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/__init__.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/aggregation.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/config.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/explainer.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/xgboost_explainer.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/strategy.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/utils.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/__init__.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/client.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/config.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/data.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/metrics.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/serialization.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/server.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/strategy.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/__init__.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/database_connector.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/dataset.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/flsetup.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/net_helper.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/net_manager_queries.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/network.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/node.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/__init__.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/__init__.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/model.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/__init__.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/config.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/data.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/metrics.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/serialization.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/scripts/__init__.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/scripts/base.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/scripts/create_db.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/README.md +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/alembic/__init__.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/alembic/env.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/medfl.egg-info/SOURCES.txt +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/medfl.egg-info/dependency_links.txt +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/medfl.egg-info/requires.txt +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/medfl.egg-info/top_level.txt +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/setup.cfg +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/tests/test_dataset.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/tests/test_federated_dataset.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/tests/test_flpipeline.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/tests/test_flsetup.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/tests/test_network.py +0 -0
- {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/tests/test_node.py +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# File: client.py
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import json
|
|
4
5
|
import pandas as pd
|
|
5
6
|
import flwr as fl
|
|
6
7
|
import torch
|
|
@@ -10,6 +11,7 @@ from torch.utils.data import TensorDataset, DataLoader
|
|
|
10
11
|
from sklearn.metrics import accuracy_score, roc_auc_score
|
|
11
12
|
from sklearn.model_selection import train_test_split
|
|
12
13
|
from MEDfl.rw.model import Net # votre définition de modèle
|
|
14
|
+
from MEDfl.LearningManager.shap import LocalPyTorchSHAPExplainer
|
|
13
15
|
import socket
|
|
14
16
|
import platform
|
|
15
17
|
import psutil
|
|
@@ -293,6 +295,155 @@ class FlowerClient(fl.client.NumPyClient):
|
|
|
293
295
|
self._initialized = True
|
|
294
296
|
print(f"[Client] Initialized with features={feat_cols}, target={target_col}, val={val_frac}, test={self.effective_test_frac}")
|
|
295
297
|
|
|
298
|
+
|
|
299
|
+
# ---------- Federated SHAP helpers ----------
|
|
300
|
+
|
|
301
|
+
def _get_shap_loader(self, data_split):
|
|
302
|
+
"""
|
|
303
|
+
Return the local partition selected for the post-training SHAP phase.
|
|
304
|
+
"""
|
|
305
|
+
split = str(data_split).lower()
|
|
306
|
+
|
|
307
|
+
if split == "train":
|
|
308
|
+
loader = self.train_loader
|
|
309
|
+
elif split == "validation":
|
|
310
|
+
loader = self.val_loader
|
|
311
|
+
elif split == "test":
|
|
312
|
+
loader = self.test_loader
|
|
313
|
+
else:
|
|
314
|
+
raise ValueError(
|
|
315
|
+
f"Unsupported SHAP data split '{data_split}'. "
|
|
316
|
+
"Expected train, validation, or test."
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if loader is None:
|
|
320
|
+
raise ValueError(
|
|
321
|
+
f"The selected SHAP split '{split}' is not available "
|
|
322
|
+
"on this client."
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if len(loader.dataset) == 0:
|
|
326
|
+
raise ValueError(
|
|
327
|
+
f"The selected SHAP split '{split}' is empty "
|
|
328
|
+
"on this client."
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
return loader
|
|
332
|
+
|
|
333
|
+
def _calculate_local_shap(self, config):
|
|
334
|
+
"""
|
|
335
|
+
Explain the final global model on this client's local data.
|
|
336
|
+
|
|
337
|
+
Only additive SHAP summary statistics are returned. Raw samples and
|
|
338
|
+
per-sample SHAP values never leave the client.
|
|
339
|
+
"""
|
|
340
|
+
if not self._initialized:
|
|
341
|
+
self._lazy_init_from_server_config(config)
|
|
342
|
+
|
|
343
|
+
data_split = str(
|
|
344
|
+
config.get("shap_data_split", "validation")
|
|
345
|
+
)
|
|
346
|
+
explainer_type = str(
|
|
347
|
+
config.get("shap_explainer", "gradient")
|
|
348
|
+
)
|
|
349
|
+
background_size = int(
|
|
350
|
+
config.get("shap_background_size", 100)
|
|
351
|
+
)
|
|
352
|
+
explanation_size = int(
|
|
353
|
+
config.get("shap_explanation_size", 500)
|
|
354
|
+
)
|
|
355
|
+
random_seed = int(
|
|
356
|
+
config.get("shap_random_seed", 42)
|
|
357
|
+
)
|
|
358
|
+
minimum_samples = int(
|
|
359
|
+
config.get("shap_minimum_samples", 10)
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
clipping_raw = float(
|
|
363
|
+
config.get("shap_clipping_value", -1.0)
|
|
364
|
+
)
|
|
365
|
+
clipping_value = (
|
|
366
|
+
None if clipping_raw <= 0 else clipping_raw
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
feature_names_raw = str(
|
|
370
|
+
config.get("shap_feature_names_json", "[]")
|
|
371
|
+
)
|
|
372
|
+
try:
|
|
373
|
+
configured_feature_names = json.loads(
|
|
374
|
+
feature_names_raw
|
|
375
|
+
)
|
|
376
|
+
except Exception:
|
|
377
|
+
configured_feature_names = []
|
|
378
|
+
|
|
379
|
+
feature_names = (
|
|
380
|
+
list(configured_feature_names)
|
|
381
|
+
if configured_feature_names
|
|
382
|
+
else list(self.effective_features)
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
if len(feature_names) != len(self.effective_features):
|
|
386
|
+
raise ValueError(
|
|
387
|
+
"SHAP feature names do not match the local model input size. "
|
|
388
|
+
f"Expected {len(self.effective_features)}, "
|
|
389
|
+
f"received {len(feature_names)}."
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
loader = self._get_shap_loader(data_split)
|
|
393
|
+
|
|
394
|
+
# Opacus can wrap the original torch.nn.Module in GradSampleModule.
|
|
395
|
+
# SHAP should explain the underlying neural network.
|
|
396
|
+
shap_model = getattr(self.model, "_module", self.model)
|
|
397
|
+
|
|
398
|
+
local_explainer = LocalPyTorchSHAPExplainer(
|
|
399
|
+
explainer_type=explainer_type,
|
|
400
|
+
background_size=background_size,
|
|
401
|
+
explanation_size=explanation_size,
|
|
402
|
+
random_seed=random_seed,
|
|
403
|
+
clipping_value=clipping_value,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
result = local_explainer.calculate(
|
|
407
|
+
model=shap_model,
|
|
408
|
+
loader=loader,
|
|
409
|
+
device=torch.device("cpu"),
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
explained_samples = int(result["sample_count"])
|
|
413
|
+
if explained_samples < minimum_samples:
|
|
414
|
+
raise ValueError(
|
|
415
|
+
f"Only {explained_samples} samples were explained, "
|
|
416
|
+
f"but minimum_samples={minimum_samples}."
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# Convert NumPy arrays to lists before JSON serialization.
|
|
420
|
+
serializable_result = {
|
|
421
|
+
"client_id": socket.gethostname(),
|
|
422
|
+
"feature_names": feature_names,
|
|
423
|
+
"available_samples": int(len(loader.dataset)),
|
|
424
|
+
"sample_count": explained_samples,
|
|
425
|
+
"abs_sum": np.asarray(
|
|
426
|
+
result["abs_sum"], dtype=np.float64
|
|
427
|
+
).tolist(),
|
|
428
|
+
"signed_sum": np.asarray(
|
|
429
|
+
result["signed_sum"], dtype=np.float64
|
|
430
|
+
).tolist(),
|
|
431
|
+
"square_sum": np.asarray(
|
|
432
|
+
result["square_sum"], dtype=np.float64
|
|
433
|
+
).tolist(),
|
|
434
|
+
"positive_count": np.asarray(
|
|
435
|
+
result["positive_count"], dtype=np.int64
|
|
436
|
+
).tolist(),
|
|
437
|
+
"negative_count": np.asarray(
|
|
438
|
+
result["negative_count"], dtype=np.int64
|
|
439
|
+
).tolist(),
|
|
440
|
+
"zero_count": np.asarray(
|
|
441
|
+
result["zero_count"], dtype=np.int64
|
|
442
|
+
).tolist(),
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return serializable_result
|
|
446
|
+
|
|
296
447
|
# ---------- FL API (unchanged behavior) ----------
|
|
297
448
|
|
|
298
449
|
def get_parameters(self, config):
|
|
@@ -390,6 +541,50 @@ class FlowerClient(fl.client.NumPyClient):
|
|
|
390
541
|
"eval_accuracy": acc,
|
|
391
542
|
"eval_auc": auc,
|
|
392
543
|
}
|
|
544
|
+
|
|
545
|
+
# ---------------------------------------------------------------
|
|
546
|
+
# Final-round federated SHAP
|
|
547
|
+
#
|
|
548
|
+
# Flower calls evaluate() with the parameters produced by the
|
|
549
|
+
# current round's aggregation. On the last round, these are the
|
|
550
|
+
# final global parameters. Therefore every participating client
|
|
551
|
+
# explains exactly the same final global model.
|
|
552
|
+
# ---------------------------------------------------------------
|
|
553
|
+
if bool(config.get("shap_enabled", False)):
|
|
554
|
+
print(
|
|
555
|
+
"[Client/SHAP] Starting local SHAP on the final "
|
|
556
|
+
"global model...",
|
|
557
|
+
flush=True,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
local_shap_result = self._calculate_local_shap(
|
|
562
|
+
config
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
metrics["shap_status"] = "completed"
|
|
566
|
+
metrics["shap_result"] = json.dumps(
|
|
567
|
+
local_shap_result,
|
|
568
|
+
allow_nan=False,
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
print(
|
|
572
|
+
"[Client/SHAP] Local SHAP completed | "
|
|
573
|
+
f"client={local_shap_result['client_id']} | "
|
|
574
|
+
f"samples={local_shap_result['sample_count']}",
|
|
575
|
+
flush=True,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
except Exception as exc:
|
|
579
|
+
metrics["shap_status"] = "failed"
|
|
580
|
+
metrics["shap_error"] = str(exc)
|
|
581
|
+
|
|
582
|
+
print(
|
|
583
|
+
"[Client/SHAP] Local SHAP failed: "
|
|
584
|
+
f"{exc}",
|
|
585
|
+
flush=True,
|
|
586
|
+
)
|
|
587
|
+
|
|
393
588
|
print(f"Evaluation metrics: {metrics}")
|
|
394
589
|
|
|
395
590
|
return float(avg_loss), len(self.test_loader.dataset), metrics
|
|
@@ -2,6 +2,7 @@ import flwr as fl
|
|
|
2
2
|
from flwr.server.strategy import FedAvg
|
|
3
3
|
from flwr.server.server import ServerConfig
|
|
4
4
|
from MEDfl.rw.strategy import Strategy
|
|
5
|
+
from MEDfl.LearningManager.shap import SHAPConfig
|
|
5
6
|
import asyncio
|
|
6
7
|
from flwr.server.client_manager import ClientManager
|
|
7
8
|
from flwr.server.client_proxy import ClientProxy
|
|
@@ -33,6 +34,7 @@ class FederatedServer:
|
|
|
33
34
|
num_rounds=3,
|
|
34
35
|
strategy=None,
|
|
35
36
|
certificates=None,
|
|
37
|
+
shap_config=None,
|
|
36
38
|
):
|
|
37
39
|
"""
|
|
38
40
|
Initialize the FederatedServer.
|
|
@@ -50,14 +52,88 @@ class FederatedServer:
|
|
|
50
52
|
|
|
51
53
|
# Use custom or default strategy
|
|
52
54
|
self.strategy_wrapper = strategy or Strategy()
|
|
55
|
+
|
|
56
|
+
self.shap_config = (
|
|
57
|
+
shap_config
|
|
58
|
+
if shap_config is not None
|
|
59
|
+
else SHAPConfig(enabled=False)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# The Strategy owns the final-round SHAP orchestration.
|
|
63
|
+
self.strategy_wrapper.shap_config = self.shap_config
|
|
64
|
+
|
|
65
|
+
# Keep the strategy's final-round marker synchronized with
|
|
66
|
+
# the actual Flower ServerConfig.
|
|
67
|
+
self.strategy_wrapper.total_rounds = int(num_rounds)
|
|
68
|
+
|
|
69
|
+
# Validate SHAP against the active real-world backend.
|
|
70
|
+
if self.shap_config.enabled:
|
|
71
|
+
feature_columns = [
|
|
72
|
+
column.strip()
|
|
73
|
+
for column in (
|
|
74
|
+
getattr(
|
|
75
|
+
self.strategy_wrapper,
|
|
76
|
+
"_features",
|
|
77
|
+
"",
|
|
78
|
+
)
|
|
79
|
+
or ""
|
|
80
|
+
).split(",")
|
|
81
|
+
if column.strip()
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
if not feature_columns:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
"Federated SHAP requires the Strategy 'features' "
|
|
87
|
+
"configuration so the model input size can be "
|
|
88
|
+
"validated."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
backend = (
|
|
92
|
+
"xgboost"
|
|
93
|
+
if getattr(
|
|
94
|
+
self.strategy_wrapper,
|
|
95
|
+
"name",
|
|
96
|
+
"",
|
|
97
|
+
) == "FedXgbBagging"
|
|
98
|
+
else "pytorch"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
task = (
|
|
102
|
+
getattr(
|
|
103
|
+
self.strategy_wrapper,
|
|
104
|
+
"task",
|
|
105
|
+
None,
|
|
106
|
+
)
|
|
107
|
+
if backend == "xgboost"
|
|
108
|
+
else None
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
self.shap_config.validate_backend(
|
|
112
|
+
input_size=len(feature_columns),
|
|
113
|
+
backend=backend,
|
|
114
|
+
task=task,
|
|
115
|
+
)
|
|
116
|
+
|
|
53
117
|
self.strategy_wrapper.create_strategy()
|
|
54
118
|
if self.strategy_wrapper.strategy_object is None:
|
|
55
|
-
raise ValueError(
|
|
119
|
+
raise ValueError(
|
|
120
|
+
"Strategy object not initialized. "
|
|
121
|
+
"Call create_strategy() first."
|
|
122
|
+
)
|
|
56
123
|
self.strategy = self.strategy_wrapper.strategy_object
|
|
57
124
|
|
|
58
125
|
self.certificates = certificates
|
|
59
126
|
self.connected_clients = [] # Track connected client IDs
|
|
60
127
|
|
|
128
|
+
self.federated_shap_result = {
|
|
129
|
+
"status": (
|
|
130
|
+
"pending"
|
|
131
|
+
if self.shap_config.enabled
|
|
132
|
+
else "disabled"
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
self.local_shap_results = []
|
|
136
|
+
|
|
61
137
|
|
|
62
138
|
def start(self):
|
|
63
139
|
"""
|
|
@@ -78,6 +154,21 @@ class FederatedServer:
|
|
|
78
154
|
client_manager=client_manager,
|
|
79
155
|
)
|
|
80
156
|
|
|
157
|
+
# The strategy aggregates SHAP during the final evaluation.
|
|
158
|
+
self.federated_shap_result = (
|
|
159
|
+
self.strategy_wrapper.federated_shap_result
|
|
160
|
+
)
|
|
161
|
+
self.local_shap_results = (
|
|
162
|
+
self.strategy_wrapper.local_shap_results
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if self.shap_config.enabled:
|
|
166
|
+
print(
|
|
167
|
+
"[Server/SHAP] Final federated SHAP status: "
|
|
168
|
+
f"{self.federated_shap_result.get('status')}",
|
|
169
|
+
flush=True,
|
|
170
|
+
)
|
|
171
|
+
|
|
81
172
|
|
|
82
173
|
class TrackingClientManager(fl.server.client_manager.SimpleClientManager):
|
|
83
174
|
"""
|
|
@@ -131,4 +222,4 @@ class TrackingClientManager(fl.server.client_manager.SimpleClientManager):
|
|
|
131
222
|
# hostname = f"Error: {e}"
|
|
132
223
|
|
|
133
224
|
print(f"✅ Client connected - CID: {client.cid}")
|
|
134
|
-
self.server.connected_clients.append(client.cid)
|
|
225
|
+
self.server.connected_clients.append(client.cid)
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
# File: MEDfl/rw/strategy.py
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import json
|
|
4
5
|
import numpy as np
|
|
5
6
|
import flwr as fl
|
|
6
|
-
from flwr.common import GetPropertiesIns
|
|
7
|
+
from flwr.common import GetPropertiesIns, EvaluateIns
|
|
7
8
|
from flwr.server.client_manager import ClientManager
|
|
8
9
|
from flwr.server.client_proxy import ClientProxy
|
|
9
10
|
import time
|
|
10
11
|
from MEDfl.rw.model import Net
|
|
11
12
|
import torch
|
|
13
|
+
from MEDfl.LearningManager.shap import (
|
|
14
|
+
SHAPConfig,
|
|
15
|
+
aggregate_federated_shap,
|
|
16
|
+
)
|
|
12
17
|
|
|
13
18
|
# ===================================================
|
|
14
19
|
# Custom metric aggregation functions
|
|
@@ -92,6 +97,8 @@ class Strategy:
|
|
|
92
97
|
client_fractions=None,
|
|
93
98
|
# NEW: id column for test_ids mapping
|
|
94
99
|
id_col="id",
|
|
100
|
+
# Optional post-training federated SHAP configuration
|
|
101
|
+
shap_config=None,
|
|
95
102
|
):
|
|
96
103
|
self.name = name
|
|
97
104
|
self.fraction_fit = fraction_fit
|
|
@@ -123,6 +130,16 @@ class Strategy:
|
|
|
123
130
|
self.client_fractions = client_fractions or {}
|
|
124
131
|
self.id_col = id_col
|
|
125
132
|
|
|
133
|
+
self.shap_config = (
|
|
134
|
+
shap_config
|
|
135
|
+
if shap_config is not None
|
|
136
|
+
else SHAPConfig(enabled=False)
|
|
137
|
+
)
|
|
138
|
+
self.federated_shap_result = {
|
|
139
|
+
"status": "disabled"
|
|
140
|
+
}
|
|
141
|
+
self.local_shap_results = []
|
|
142
|
+
|
|
126
143
|
self.strategy_object = None
|
|
127
144
|
|
|
128
145
|
def create_strategy(self):
|
|
@@ -144,6 +161,66 @@ class Strategy:
|
|
|
144
161
|
"id_col": self.id_col,
|
|
145
162
|
}
|
|
146
163
|
|
|
164
|
+
def evaluate_config_fn(server_round):
|
|
165
|
+
"""
|
|
166
|
+
Send the ordinary dataset/schema configuration on every
|
|
167
|
+
evaluation. Enable SHAP only on the last federated round.
|
|
168
|
+
"""
|
|
169
|
+
is_final_round = (
|
|
170
|
+
int(server_round) == int(self.total_rounds)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
cfg = {
|
|
174
|
+
"features": self._features,
|
|
175
|
+
"target": self._target,
|
|
176
|
+
"val_fraction": float(self._val_fraction),
|
|
177
|
+
"test_fraction": float(self._test_fraction),
|
|
178
|
+
"id_col": self.id_col,
|
|
179
|
+
"shap_enabled": bool(
|
|
180
|
+
self.shap_config.enabled
|
|
181
|
+
and is_final_round
|
|
182
|
+
),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if cfg["shap_enabled"]:
|
|
186
|
+
cfg.update(
|
|
187
|
+
{
|
|
188
|
+
"shap_explainer": (
|
|
189
|
+
self.shap_config.explainer
|
|
190
|
+
),
|
|
191
|
+
"shap_data_split": (
|
|
192
|
+
self.shap_config.data_split
|
|
193
|
+
),
|
|
194
|
+
"shap_background_size": int(
|
|
195
|
+
self.shap_config.background_size
|
|
196
|
+
),
|
|
197
|
+
"shap_explanation_size": int(
|
|
198
|
+
self.shap_config.explanation_size
|
|
199
|
+
),
|
|
200
|
+
"shap_random_seed": int(
|
|
201
|
+
self.shap_config.random_seed
|
|
202
|
+
),
|
|
203
|
+
"shap_minimum_samples": int(
|
|
204
|
+
self.shap_config.minimum_samples
|
|
205
|
+
),
|
|
206
|
+
# Flower configs are Scalar-only, so None is
|
|
207
|
+
# represented by a negative sentinel.
|
|
208
|
+
"shap_clipping_value": (
|
|
209
|
+
-1.0
|
|
210
|
+
if self.shap_config.clipping_value
|
|
211
|
+
is None
|
|
212
|
+
else float(
|
|
213
|
+
self.shap_config.clipping_value
|
|
214
|
+
)
|
|
215
|
+
),
|
|
216
|
+
"shap_feature_names_json": json.dumps(
|
|
217
|
+
self.shap_config.feature_names or []
|
|
218
|
+
),
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return cfg
|
|
223
|
+
|
|
147
224
|
# 3) Build params including on_fit_config_fn
|
|
148
225
|
params = {
|
|
149
226
|
"fraction_fit": self.fraction_fit,
|
|
@@ -153,6 +230,7 @@ class Strategy:
|
|
|
153
230
|
"min_available_clients": self.min_available_clients,
|
|
154
231
|
"evaluate_fn": self.evaluate_fn,
|
|
155
232
|
"on_fit_config_fn": fit_config_fn,
|
|
233
|
+
"on_evaluate_config_fn": evaluate_config_fn,
|
|
156
234
|
"fit_metrics_aggregation_fn": self.fit_metrics_aggregation_fn,
|
|
157
235
|
"evaluate_metrics_aggregation_fn": self.evaluate_metrics_aggregation_fn,
|
|
158
236
|
}
|
|
@@ -205,16 +283,195 @@ class Strategy:
|
|
|
205
283
|
original_agg_eval = strat.aggregate_evaluate
|
|
206
284
|
|
|
207
285
|
def logged_agg_eval(server_round, results, failures):
|
|
208
|
-
print(
|
|
286
|
+
print(
|
|
287
|
+
f"\n[Server] 📊 Round {server_round} - "
|
|
288
|
+
"Client Evaluation Metrics:"
|
|
289
|
+
)
|
|
209
290
|
for i, (client_id, eval_res) in enumerate(results):
|
|
210
|
-
print(
|
|
211
|
-
|
|
212
|
-
|
|
291
|
+
print(
|
|
292
|
+
f" CEM Round {server_round} "
|
|
293
|
+
f"Client:{client_id.cid}: {eval_res.metrics}"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
loss, metrics = original_agg_eval(
|
|
297
|
+
server_round,
|
|
298
|
+
results,
|
|
299
|
+
failures,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
print(
|
|
303
|
+
f"[Server] ✅ Round {server_round} - "
|
|
304
|
+
"Aggregated Evaluation Metrics:"
|
|
305
|
+
)
|
|
213
306
|
print(f" Loss: {loss}, Metrics: {metrics}\n")
|
|
307
|
+
|
|
308
|
+
is_final_round = (
|
|
309
|
+
int(server_round) == int(self.total_rounds)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if self.shap_config.enabled and is_final_round:
|
|
313
|
+
local_results = []
|
|
314
|
+
shap_failures = []
|
|
315
|
+
|
|
316
|
+
for client_proxy, eval_res in results:
|
|
317
|
+
client_metrics = eval_res.metrics or {}
|
|
318
|
+
|
|
319
|
+
if (
|
|
320
|
+
client_metrics.get("shap_status")
|
|
321
|
+
== "completed"
|
|
322
|
+
and client_metrics.get("shap_result")
|
|
323
|
+
):
|
|
324
|
+
try:
|
|
325
|
+
local_result = json.loads(
|
|
326
|
+
client_metrics["shap_result"]
|
|
327
|
+
)
|
|
328
|
+
local_results.append(local_result)
|
|
329
|
+
except Exception as exc:
|
|
330
|
+
shap_failures.append(
|
|
331
|
+
{
|
|
332
|
+
"client_id": client_proxy.cid,
|
|
333
|
+
"error": (
|
|
334
|
+
"Invalid SHAP JSON: "
|
|
335
|
+
f"{exc}"
|
|
336
|
+
),
|
|
337
|
+
}
|
|
338
|
+
)
|
|
339
|
+
else:
|
|
340
|
+
shap_failures.append(
|
|
341
|
+
{
|
|
342
|
+
"client_id": client_proxy.cid,
|
|
343
|
+
"error": client_metrics.get(
|
|
344
|
+
"shap_error",
|
|
345
|
+
"No SHAP result returned",
|
|
346
|
+
),
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
self.local_shap_results = local_results
|
|
351
|
+
|
|
352
|
+
if local_results:
|
|
353
|
+
try:
|
|
354
|
+
federated_result = (
|
|
355
|
+
aggregate_federated_shap(
|
|
356
|
+
client_results=local_results,
|
|
357
|
+
include_client_results=(
|
|
358
|
+
self.shap_config
|
|
359
|
+
.include_client_results
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
federated_result["explainer"] = (
|
|
365
|
+
self.shap_config.explainer
|
|
366
|
+
)
|
|
367
|
+
federated_result["data_split"] = (
|
|
368
|
+
self.shap_config.data_split
|
|
369
|
+
)
|
|
370
|
+
federated_result["background_size"] = (
|
|
371
|
+
self.shap_config.background_size
|
|
372
|
+
)
|
|
373
|
+
federated_result[
|
|
374
|
+
"requested_explanation_size"
|
|
375
|
+
] = (
|
|
376
|
+
self.shap_config.explanation_size
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
if shap_failures:
|
|
380
|
+
federated_result[
|
|
381
|
+
"failed_clients"
|
|
382
|
+
] = shap_failures
|
|
383
|
+
|
|
384
|
+
self.federated_shap_result = (
|
|
385
|
+
federated_result
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
print(
|
|
389
|
+
"[Server/SHAP] Federated SHAP "
|
|
390
|
+
"completed:"
|
|
391
|
+
)
|
|
392
|
+
print(
|
|
393
|
+
json.dumps(
|
|
394
|
+
self.federated_shap_result,
|
|
395
|
+
indent=2,
|
|
396
|
+
),
|
|
397
|
+
flush=True,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
self.federated_shap_result = {
|
|
402
|
+
"status": "failed",
|
|
403
|
+
"error": str(exc),
|
|
404
|
+
"failed_clients": shap_failures,
|
|
405
|
+
}
|
|
406
|
+
print(
|
|
407
|
+
"[Server/SHAP] Federated SHAP "
|
|
408
|
+
f"aggregation failed: {exc}",
|
|
409
|
+
flush=True,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
else:
|
|
413
|
+
self.federated_shap_result = {
|
|
414
|
+
"status": "failed",
|
|
415
|
+
"error": (
|
|
416
|
+
"SHAP was enabled but no client "
|
|
417
|
+
"returned a valid SHAP result."
|
|
418
|
+
),
|
|
419
|
+
"failed_clients": shap_failures,
|
|
420
|
+
}
|
|
421
|
+
print(
|
|
422
|
+
"[Server/SHAP] No valid local SHAP "
|
|
423
|
+
"results were returned.",
|
|
424
|
+
flush=True,
|
|
425
|
+
)
|
|
426
|
+
|
|
214
427
|
return loss, metrics
|
|
215
428
|
|
|
216
429
|
strat.aggregate_evaluate = logged_agg_eval
|
|
217
430
|
|
|
431
|
+
# 6b) On the last round, federated SHAP should run on every
|
|
432
|
+
# currently connected client, regardless of fraction_evaluate.
|
|
433
|
+
original_conf_eval = strat.configure_evaluate
|
|
434
|
+
|
|
435
|
+
def wrapped_conf_eval(
|
|
436
|
+
server_round,
|
|
437
|
+
parameters,
|
|
438
|
+
client_manager,
|
|
439
|
+
):
|
|
440
|
+
is_final_shap_round = (
|
|
441
|
+
self.shap_config.enabled
|
|
442
|
+
and int(server_round) == int(self.total_rounds)
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
if not is_final_shap_round:
|
|
446
|
+
return original_conf_eval(
|
|
447
|
+
server_round=server_round,
|
|
448
|
+
parameters=parameters,
|
|
449
|
+
client_manager=client_manager,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
evaluate_ins = EvaluateIns(
|
|
453
|
+
parameters=parameters,
|
|
454
|
+
config=evaluate_config_fn(server_round),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
available_clients = list(
|
|
458
|
+
client_manager.all().values()
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
print(
|
|
462
|
+
"[Server/SHAP] Final round: requesting local "
|
|
463
|
+
f"SHAP from {len(available_clients)} connected "
|
|
464
|
+
"client(s).",
|
|
465
|
+
flush=True,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
return [
|
|
469
|
+
(client, evaluate_ins)
|
|
470
|
+
for client in available_clients
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
strat.configure_evaluate = wrapped_conf_eval
|
|
474
|
+
|
|
218
475
|
# 7) Wrap configure_fit to:
|
|
219
476
|
# - log client properties (unchanged)
|
|
220
477
|
# - apply split_mode/client_fractions to fit_ins.config (NEW)
|