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.
Files changed (67) hide show
  1. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/client.py +195 -0
  2. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/server.py +93 -2
  3. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/strategy.py +262 -5
  4. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/client.py +291 -0
  5. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/strategy.py +290 -10
  6. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/PKG-INFO +1 -1
  7. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/medfl.egg-info/PKG-INFO +1 -1
  8. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/setup.py +1 -1
  9. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/LICENSE +0 -0
  10. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/__init__.py +0 -0
  11. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/client.py +0 -0
  12. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/dynamicModal.py +0 -0
  13. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/federated_dataset.py +0 -0
  14. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/flpipeline.py +0 -0
  15. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/model.py +0 -0
  16. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/params.yaml +0 -0
  17. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/params_optimiser.py +0 -0
  18. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/plot.py +0 -0
  19. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/server.py +0 -0
  20. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/__init__.py +0 -0
  21. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/aggregation.py +0 -0
  22. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/config.py +0 -0
  23. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/explainer.py +0 -0
  24. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/xgboost_explainer.py +0 -0
  25. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/strategy.py +0 -0
  26. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/utils.py +0 -0
  27. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/__init__.py +0 -0
  28. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/client.py +0 -0
  29. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/config.py +0 -0
  30. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/data.py +0 -0
  31. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/metrics.py +0 -0
  32. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/serialization.py +0 -0
  33. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/server.py +0 -0
  34. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/strategy.py +0 -0
  35. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/__init__.py +0 -0
  36. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/database_connector.py +0 -0
  37. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/dataset.py +0 -0
  38. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/flsetup.py +0 -0
  39. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/net_helper.py +0 -0
  40. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/net_manager_queries.py +0 -0
  41. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/network.py +0 -0
  42. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/NetManager/node.py +0 -0
  43. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/__init__.py +0 -0
  44. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/__init__.py +0 -0
  45. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/model.py +0 -0
  46. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/__init__.py +0 -0
  47. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/config.py +0 -0
  48. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/data.py +0 -0
  49. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/metrics.py +0 -0
  50. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/serialization.py +0 -0
  51. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/scripts/__init__.py +0 -0
  52. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/scripts/base.py +0 -0
  53. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/MEDfl/scripts/create_db.py +0 -0
  54. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/README.md +0 -0
  55. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/alembic/__init__.py +0 -0
  56. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/alembic/env.py +0 -0
  57. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/medfl.egg-info/SOURCES.txt +0 -0
  58. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/medfl.egg-info/dependency_links.txt +0 -0
  59. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/medfl.egg-info/requires.txt +0 -0
  60. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/medfl.egg-info/top_level.txt +0 -0
  61. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/setup.cfg +0 -0
  62. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/tests/test_dataset.py +0 -0
  63. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/tests/test_federated_dataset.py +0 -0
  64. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/tests/test_flpipeline.py +0 -0
  65. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/tests/test_flsetup.py +0 -0
  66. {medfl-2.0.10.dev5 → medfl-2.0.10.dev7}/tests/test_network.py +0 -0
  67. {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("Strategy object not initialized. Call create_strategy() first.")
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(f"\n[Server] 📊 Round {server_round} - Client Evaluation Metrics:")
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(f" CEM Round {server_round} Client:{client_id.cid}: {eval_res.metrics}")
211
- loss, metrics = original_agg_eval(server_round, results, failures)
212
- print(f"[Server] ✅ Round {server_round} - Aggregated Evaluation Metrics:")
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)