MEDfl 2.0.10.dev6__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.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/server.py +32 -6
  2. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/client.py +291 -0
  3. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/strategy.py +290 -10
  4. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/PKG-INFO +1 -1
  5. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/medfl.egg-info/PKG-INFO +1 -1
  6. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/setup.py +1 -1
  7. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/LICENSE +0 -0
  8. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/__init__.py +0 -0
  9. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/client.py +0 -0
  10. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/dynamicModal.py +0 -0
  11. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/federated_dataset.py +0 -0
  12. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/flpipeline.py +0 -0
  13. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/model.py +0 -0
  14. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/params.yaml +0 -0
  15. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/params_optimiser.py +0 -0
  16. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/plot.py +0 -0
  17. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/server.py +0 -0
  18. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/__init__.py +0 -0
  19. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/aggregation.py +0 -0
  20. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/config.py +0 -0
  21. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/explainer.py +0 -0
  22. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/xgboost_explainer.py +0 -0
  23. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/strategy.py +0 -0
  24. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/utils.py +0 -0
  25. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/__init__.py +0 -0
  26. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/client.py +0 -0
  27. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/config.py +0 -0
  28. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/data.py +0 -0
  29. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/metrics.py +0 -0
  30. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/serialization.py +0 -0
  31. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/server.py +0 -0
  32. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/strategy.py +0 -0
  33. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/__init__.py +0 -0
  34. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/database_connector.py +0 -0
  35. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/dataset.py +0 -0
  36. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/flsetup.py +0 -0
  37. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/net_helper.py +0 -0
  38. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/net_manager_queries.py +0 -0
  39. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/network.py +0 -0
  40. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/node.py +0 -0
  41. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/__init__.py +0 -0
  42. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/__init__.py +0 -0
  43. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/client.py +0 -0
  44. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/model.py +0 -0
  45. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/strategy.py +0 -0
  46. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/__init__.py +0 -0
  47. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/config.py +0 -0
  48. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/data.py +0 -0
  49. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/metrics.py +0 -0
  50. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/serialization.py +0 -0
  51. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/scripts/__init__.py +0 -0
  52. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/scripts/base.py +0 -0
  53. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/scripts/create_db.py +0 -0
  54. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/README.md +0 -0
  55. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/alembic/__init__.py +0 -0
  56. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/alembic/env.py +0 -0
  57. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/medfl.egg-info/SOURCES.txt +0 -0
  58. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/medfl.egg-info/dependency_links.txt +0 -0
  59. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/medfl.egg-info/requires.txt +0 -0
  60. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/medfl.egg-info/top_level.txt +0 -0
  61. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/setup.cfg +0 -0
  62. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/tests/test_dataset.py +0 -0
  63. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/tests/test_federated_dataset.py +0 -0
  64. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/tests/test_flpipeline.py +0 -0
  65. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/tests/test_flsetup.py +0 -0
  66. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/tests/test_network.py +0 -0
  67. {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/tests/test_node.py +0 -0
@@ -66,12 +66,17 @@ class FederatedServer:
66
66
  # the actual Flower ServerConfig.
67
67
  self.strategy_wrapper.total_rounds = int(num_rounds)
68
68
 
69
- # Validate the PyTorch SHAP configuration before the server starts.
69
+ # Validate SHAP against the active real-world backend.
70
70
  if self.shap_config.enabled:
71
71
  feature_columns = [
72
72
  column.strip()
73
73
  for column in (
74
- self.strategy_wrapper._features or ""
74
+ getattr(
75
+ self.strategy_wrapper,
76
+ "_features",
77
+ "",
78
+ )
79
+ or ""
75
80
  ).split(",")
76
81
  if column.strip()
77
82
  ]
@@ -79,13 +84,34 @@ class FederatedServer:
79
84
  if not feature_columns:
80
85
  raise ValueError(
81
86
  "Federated SHAP requires the Strategy 'features' "
82
- "configuration so the neural-network input size "
83
- "can be validated."
87
+ "configuration so the model input size can be "
88
+ "validated."
84
89
  )
85
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
+
86
111
  self.shap_config.validate_backend(
87
112
  input_size=len(feature_columns),
88
- backend="pytorch",
113
+ backend=backend,
114
+ task=task,
89
115
  )
90
116
 
91
117
  self.strategy_wrapper.create_strategy()
@@ -196,4 +222,4 @@ class TrackingClientManager(fl.server.client_manager.SimpleClientManager):
196
222
  # hostname = f"Error: {e}"
197
223
 
198
224
  print(f"✅ Client connected - CID: {client.cid}")
199
- self.server.connected_clients.append(client.cid)
225
+ self.server.connected_clients.append(client.cid)
@@ -10,6 +10,8 @@ import numpy as np
10
10
  import pandas as pd
11
11
  import psutil
12
12
  import xgboost as xgb
13
+ import torch
14
+ from torch.utils.data import DataLoader, TensorDataset
13
15
  from flwr.common import (
14
16
  Code,
15
17
  EvaluateIns,
@@ -35,6 +37,9 @@ from MEDfl.rw.xgboost.serialization import (
35
37
  booster_to_parameters,
36
38
  parameters_to_booster,
37
39
  )
40
+ from MEDfl.LearningManager.shap import (
41
+ LocalXGBoostSHAPExplainer,
42
+ )
38
43
 
39
44
 
40
45
  class XGBoostFlowerClient(fl.client.Client):
@@ -144,6 +149,250 @@ class XGBoostFlowerClient(fl.client.Client):
144
149
  f"test={self.metadata.get('num_test')}"
145
150
  )
146
151
 
152
+ # ------------------------------------------------------------------
153
+ # Federated SHAP helpers
154
+ # ------------------------------------------------------------------
155
+
156
+ def _get_shap_dmatrix(self, data_split: str) -> xgb.DMatrix:
157
+ """
158
+ Return the local DMatrix selected for the post-training SHAP phase.
159
+ """
160
+ split = str(data_split).lower()
161
+
162
+ if split == "train":
163
+ matrix = self.dtrain
164
+ elif split == "validation":
165
+ matrix = self.dval
166
+ elif split == "test":
167
+ matrix = self.dtest
168
+ else:
169
+ raise ValueError(
170
+ f"Unsupported SHAP data split '{data_split}'. "
171
+ "Expected train, validation, or test."
172
+ )
173
+
174
+ if matrix is None:
175
+ raise ValueError(
176
+ f"The selected SHAP split '{split}' is not available "
177
+ "on this XGBoost client."
178
+ )
179
+
180
+ if int(matrix.num_row()) <= 0:
181
+ raise ValueError(
182
+ f"The selected SHAP split '{split}' is empty "
183
+ "on this XGBoost client."
184
+ )
185
+
186
+ return matrix
187
+
188
+ @staticmethod
189
+ def _dmatrix_to_shap_loader(
190
+ matrix: xgb.DMatrix,
191
+ batch_size: int = 256,
192
+ ) -> DataLoader:
193
+ """
194
+ Convert a local XGBoost DMatrix into the DataLoader interface expected
195
+ by LocalXGBoostSHAPExplainer.
196
+
197
+ The conversion is local only. No samples are transmitted.
198
+ """
199
+ if matrix is None:
200
+ raise ValueError("Cannot create a SHAP loader from None.")
201
+
202
+ try:
203
+ features = matrix.get_data()
204
+ except AttributeError as exc:
205
+ raise RuntimeError(
206
+ "This XGBoost version does not expose DMatrix.get_data(). "
207
+ "Upgrade XGBoost or adapt XGBoostDataModule to retain the "
208
+ "local NumPy feature splits for SHAP."
209
+ ) from exc
210
+
211
+ # DMatrix.get_data() commonly returns a scipy sparse matrix.
212
+ if hasattr(features, "toarray"):
213
+ features = features.toarray()
214
+
215
+ features = np.asarray(features, dtype=np.float32)
216
+
217
+ if features.ndim != 2:
218
+ raise ValueError(
219
+ "SHAP expects 2-D tabular XGBoost features. "
220
+ f"Received shape {features.shape}."
221
+ )
222
+
223
+ labels = np.asarray(
224
+ matrix.get_label(),
225
+ dtype=np.float32,
226
+ )
227
+
228
+ x_tensor = torch.tensor(
229
+ features,
230
+ dtype=torch.float32,
231
+ )
232
+ y_tensor = torch.tensor(
233
+ labels,
234
+ dtype=torch.float32,
235
+ )
236
+
237
+ return DataLoader(
238
+ TensorDataset(x_tensor, y_tensor),
239
+ batch_size=max(1, min(int(batch_size), len(x_tensor))),
240
+ shuffle=False,
241
+ )
242
+
243
+ def _calculate_local_shap(
244
+ self,
245
+ config: Dict[str, Any],
246
+ ) -> Dict[str, Any]:
247
+ """
248
+ Explain the final federated XGBoost booster on this client's selected
249
+ local data partition.
250
+
251
+ Only additive SHAP summaries are serialized back to the server.
252
+ """
253
+ if not self._initialized:
254
+ self._initialize_from_config(config)
255
+
256
+ if self.booster is None:
257
+ raise RuntimeError(
258
+ "Cannot calculate XGBoost SHAP without a booster."
259
+ )
260
+
261
+ explainer_name = str(
262
+ config.get("shap_explainer", "tree")
263
+ ).lower()
264
+
265
+ if explainer_name != "tree":
266
+ raise ValueError(
267
+ "Real-world XGBoost SHAP requires explainer='tree'."
268
+ )
269
+
270
+ data_split = str(
271
+ config.get("shap_data_split", "validation")
272
+ )
273
+ background_size = int(
274
+ config.get("shap_background_size", 100)
275
+ )
276
+ explanation_size = int(
277
+ config.get("shap_explanation_size", 500)
278
+ )
279
+ random_seed = int(
280
+ config.get("shap_random_seed", 42)
281
+ )
282
+ minimum_samples = int(
283
+ config.get("shap_minimum_samples", 10)
284
+ )
285
+ model_output = str(
286
+ config.get("shap_model_output", "raw")
287
+ )
288
+
289
+ clipping_raw = float(
290
+ config.get("shap_clipping_value", -1.0)
291
+ )
292
+ clipping_value = (
293
+ None if clipping_raw <= 0 else clipping_raw
294
+ )
295
+
296
+ feature_names_raw = str(
297
+ config.get("shap_feature_names_json", "[]")
298
+ )
299
+
300
+ try:
301
+ configured_feature_names = json.loads(
302
+ feature_names_raw
303
+ )
304
+ except Exception:
305
+ configured_feature_names = []
306
+
307
+ effective_features = list(
308
+ self.metadata.get("features", [])
309
+ )
310
+
311
+ feature_names = (
312
+ list(configured_feature_names)
313
+ if configured_feature_names
314
+ else effective_features
315
+ )
316
+
317
+ if len(feature_names) != int(
318
+ self.metadata.get("num_features", 0)
319
+ ):
320
+ raise ValueError(
321
+ "SHAP feature names do not match the XGBoost input size. "
322
+ f"Expected {self.metadata.get('num_features', 0)}, "
323
+ f"received {len(feature_names)}."
324
+ )
325
+
326
+ matrix = self._get_shap_dmatrix(data_split)
327
+
328
+ available_samples = int(matrix.num_row())
329
+
330
+ if available_samples < minimum_samples:
331
+ raise ValueError(
332
+ f"Only {available_samples} samples are available in "
333
+ f"the '{data_split}' split, but "
334
+ f"minimum_samples={minimum_samples}."
335
+ )
336
+
337
+ loader = self._dmatrix_to_shap_loader(
338
+ matrix=matrix,
339
+ batch_size=min(256, available_samples),
340
+ )
341
+
342
+ local_explainer = LocalXGBoostSHAPExplainer(
343
+ background_size=background_size,
344
+ explanation_size=explanation_size,
345
+ random_seed=random_seed,
346
+ clipping_value=clipping_value,
347
+ model_output=model_output,
348
+ )
349
+
350
+ result = local_explainer.calculate(
351
+ booster=self.booster,
352
+ loader=loader,
353
+ )
354
+
355
+ explained_samples = int(
356
+ result["sample_count"]
357
+ )
358
+
359
+ if explained_samples < minimum_samples:
360
+ raise ValueError(
361
+ f"Only {explained_samples} samples were explained, "
362
+ f"but minimum_samples={minimum_samples}."
363
+ )
364
+
365
+ return {
366
+ "client_id": socket.gethostname(),
367
+ "feature_names": feature_names,
368
+ "available_samples": available_samples,
369
+ "sample_count": explained_samples,
370
+ "abs_sum": np.asarray(
371
+ result["abs_sum"],
372
+ dtype=np.float64,
373
+ ).tolist(),
374
+ "signed_sum": np.asarray(
375
+ result["signed_sum"],
376
+ dtype=np.float64,
377
+ ).tolist(),
378
+ "square_sum": np.asarray(
379
+ result["square_sum"],
380
+ dtype=np.float64,
381
+ ).tolist(),
382
+ "positive_count": np.asarray(
383
+ result["positive_count"],
384
+ dtype=np.int64,
385
+ ).tolist(),
386
+ "negative_count": np.asarray(
387
+ result["negative_count"],
388
+ dtype=np.int64,
389
+ ).tolist(),
390
+ "zero_count": np.asarray(
391
+ result["zero_count"],
392
+ dtype=np.int64,
393
+ ).tolist(),
394
+ }
395
+
147
396
  def get_parameters(self, ins: GetParametersIns) -> GetParametersRes:
148
397
  """
149
398
  Return current booster parameters.
@@ -265,6 +514,48 @@ class XGBoostFlowerClient(fl.client.Client):
265
514
  else:
266
515
  loss = float(eval_metrics.get("mlogloss", 0.0))
267
516
 
517
+ # --------------------------------------------------------------
518
+ # Final-round federated SHAP.
519
+ #
520
+ # ins.parameters contains the server-aggregated booster for this
521
+ # evaluation. Therefore all participating clients explain the
522
+ # same final global XGBoost model.
523
+ # --------------------------------------------------------------
524
+ if bool(config.get("shap_enabled", False)):
525
+ print(
526
+ "[XGBoostClient/SHAP] Starting local SHAP on "
527
+ "the final global booster...",
528
+ flush=True,
529
+ )
530
+
531
+ try:
532
+ local_shap_result = (
533
+ self._calculate_local_shap(config)
534
+ )
535
+
536
+ metrics["shap_status"] = "completed"
537
+ metrics["shap_result"] = json.dumps(
538
+ local_shap_result,
539
+ allow_nan=False,
540
+ )
541
+
542
+ print(
543
+ "[XGBoostClient/SHAP] Local SHAP completed | "
544
+ f"client={local_shap_result['client_id']} | "
545
+ f"samples={local_shap_result['sample_count']}",
546
+ flush=True,
547
+ )
548
+
549
+ except Exception as exc:
550
+ metrics["shap_status"] = "failed"
551
+ metrics["shap_error"] = str(exc)
552
+
553
+ print(
554
+ "[XGBoostClient/SHAP] Local SHAP failed: "
555
+ f"{exc}",
556
+ flush=True,
557
+ )
558
+
268
559
  return EvaluateRes(
269
560
  status=Status(code=Code.OK, message="Success"),
270
561
  loss=loss,
@@ -8,6 +8,7 @@ import flwr as fl
8
8
  from flwr.common import (
9
9
  Parameters,
10
10
  FitRes,
11
+ EvaluateIns,
11
12
  parameters_to_ndarrays,
12
13
  )
13
14
  from flwr.common import GetPropertiesIns
@@ -19,6 +20,10 @@ from MEDfl.rw.xgboost.serialization import (
19
20
  save_booster,
20
21
  XGBOOST_TENSOR_TYPE,
21
22
  )
23
+ from MEDfl.LearningManager.shap import (
24
+ SHAPConfig,
25
+ aggregate_federated_shap,
26
+ )
22
27
 
23
28
 
24
29
  class XGBoostStrategy:
@@ -65,6 +70,7 @@ class XGBoostStrategy:
65
70
  savingPath: str = "",
66
71
  saveOnRounds: int = 1,
67
72
  total_rounds: int = 3,
73
+ shap_config: Optional[SHAPConfig] = None,
68
74
  ):
69
75
  self.name = "FedXgbBagging"
70
76
  self.mode = mode
@@ -109,6 +115,20 @@ class XGBoostStrategy:
109
115
  self.saveOnRounds = saveOnRounds
110
116
  self.total_rounds = total_rounds
111
117
 
118
+ self.shap_config = (
119
+ shap_config
120
+ if shap_config is not None
121
+ else SHAPConfig(enabled=False)
122
+ )
123
+ self.federated_shap_result = {
124
+ "status": (
125
+ "pending"
126
+ if self.shap_config.enabled
127
+ else "disabled"
128
+ )
129
+ }
130
+ self.local_shap_results = []
131
+
112
132
  self.strategy_object = None
113
133
  self.current_booster: Optional[xgb.Booster] = None
114
134
  self.current_num_trees: int = 0
@@ -137,7 +157,11 @@ class XGBoostStrategy:
137
157
  }
138
158
 
139
159
  def evaluate_config_fn(server_round: int) -> Dict[str, Any]:
140
- return {
160
+ is_final_round = (
161
+ int(server_round) == int(self.total_rounds)
162
+ )
163
+
164
+ cfg = {
141
165
  "backend": "xgboost",
142
166
  "mode": self.mode,
143
167
  "task": self.task,
@@ -147,10 +171,56 @@ class XGBoostStrategy:
147
171
  "val_fraction": float(self._val_fraction),
148
172
  "test_fraction": float(self._test_fraction),
149
173
  "id_col": self.id_col,
150
- "local_num_boost_round": int(self.local_num_boost_round),
174
+ "local_num_boost_round": int(
175
+ self.local_num_boost_round
176
+ ),
151
177
  "xgb_params": json.dumps(self.xgb_params),
178
+ "shap_enabled": bool(
179
+ self.shap_config.enabled
180
+ and is_final_round
181
+ ),
152
182
  }
153
183
 
184
+ if cfg["shap_enabled"]:
185
+ cfg.update(
186
+ {
187
+ "shap_explainer": (
188
+ self.shap_config.explainer
189
+ ),
190
+ "shap_data_split": (
191
+ self.shap_config.data_split
192
+ ),
193
+ "shap_background_size": int(
194
+ self.shap_config.background_size
195
+ ),
196
+ "shap_explanation_size": int(
197
+ self.shap_config.explanation_size
198
+ ),
199
+ "shap_random_seed": int(
200
+ self.shap_config.random_seed
201
+ ),
202
+ "shap_minimum_samples": int(
203
+ self.shap_config.minimum_samples
204
+ ),
205
+ "shap_clipping_value": (
206
+ -1.0
207
+ if self.shap_config.clipping_value
208
+ is None
209
+ else float(
210
+ self.shap_config.clipping_value
211
+ )
212
+ ),
213
+ "shap_feature_names_json": json.dumps(
214
+ self.shap_config.feature_names or []
215
+ ),
216
+ "shap_model_output": (
217
+ self.shap_config.model_output
218
+ ),
219
+ }
220
+ )
221
+
222
+ return cfg
223
+
154
224
  strategy = fl.server.strategy.FedAvg(
155
225
  fraction_fit=self.fraction_fit,
156
226
  fraction_evaluate=self.fraction_evaluate,
@@ -168,6 +238,7 @@ class XGBoostStrategy:
168
238
  )
169
239
 
170
240
  self._wrap_configure_fit(strategy)
241
+ self._wrap_configure_evaluate(strategy, evaluate_config_fn)
171
242
  self._wrap_aggregate_fit(strategy)
172
243
  self._wrap_aggregate_evaluate(strategy)
173
244
 
@@ -259,6 +330,63 @@ class XGBoostStrategy:
259
330
 
260
331
  strategy.configure_fit = wrapped_configure_fit
261
332
 
333
+ def _wrap_configure_evaluate(
334
+ self,
335
+ strategy,
336
+ evaluate_config_fn,
337
+ ) -> None:
338
+ """
339
+ On the final round, request evaluation/SHAP from every currently
340
+ connected client so the federated SHAP result represents all
341
+ available institutions.
342
+ """
343
+ original_configure_evaluate = (
344
+ strategy.configure_evaluate
345
+ )
346
+
347
+ def wrapped_configure_evaluate(
348
+ server_round,
349
+ parameters,
350
+ client_manager,
351
+ ):
352
+ is_final_shap_round = (
353
+ self.shap_config.enabled
354
+ and int(server_round)
355
+ == int(self.total_rounds)
356
+ )
357
+
358
+ if not is_final_shap_round:
359
+ return original_configure_evaluate(
360
+ server_round=server_round,
361
+ parameters=parameters,
362
+ client_manager=client_manager,
363
+ )
364
+
365
+ evaluate_ins = EvaluateIns(
366
+ parameters=parameters,
367
+ config=evaluate_config_fn(server_round),
368
+ )
369
+
370
+ available_clients = list(
371
+ client_manager.all().values()
372
+ )
373
+
374
+ print(
375
+ "[Server/SHAP] XGBoost final round: "
376
+ "requesting local SHAP from "
377
+ f"{len(available_clients)} connected client(s).",
378
+ flush=True,
379
+ )
380
+
381
+ return [
382
+ (client, evaluate_ins)
383
+ for client in available_clients
384
+ ]
385
+
386
+ strategy.configure_evaluate = (
387
+ wrapped_configure_evaluate
388
+ )
389
+
262
390
  def _wrap_aggregate_fit(self, strategy) -> None:
263
391
  """
264
392
  Wrap aggregate_fit for XGBoost.
@@ -396,13 +524,23 @@ class XGBoostStrategy:
396
524
 
397
525
  def _wrap_aggregate_evaluate(self, strategy) -> None:
398
526
  """
399
- Wrap aggregate_evaluate for logging.
527
+ Wrap aggregate_evaluate for logging and final federated SHAP
528
+ aggregation.
400
529
  """
401
530
 
402
- original_aggregate_evaluate = strategy.aggregate_evaluate
531
+ original_aggregate_evaluate = (
532
+ strategy.aggregate_evaluate
533
+ )
403
534
 
404
- def wrapped_aggregate_evaluate(server_round, results, failures):
405
- print(f"\n[Server/XGBoost] 📊 Round {server_round} - Client Evaluation Metrics:")
535
+ def wrapped_aggregate_evaluate(
536
+ server_round,
537
+ results,
538
+ failures,
539
+ ):
540
+ print(
541
+ f"\n[Server/XGBoost] 📊 Round {server_round} - "
542
+ "Client Evaluation Metrics:"
543
+ )
406
544
 
407
545
  for client, eval_res in results:
408
546
  print(
@@ -410,14 +548,156 @@ class XGBoostStrategy:
410
548
  f"Client:{client.cid}: {eval_res.metrics}"
411
549
  )
412
550
 
413
- loss, metrics = original_aggregate_evaluate(server_round, results, failures)
551
+ loss, metrics = original_aggregate_evaluate(
552
+ server_round,
553
+ results,
554
+ failures,
555
+ )
556
+
557
+ print(
558
+ f"[Server/XGBoost] ✅ Round {server_round} - "
559
+ "Aggregated Evaluation:"
560
+ )
561
+ print(
562
+ f" Loss: {loss}, Metrics: {metrics}\n"
563
+ )
564
+
565
+ is_final_round = (
566
+ int(server_round) == int(self.total_rounds)
567
+ )
568
+
569
+ if self.shap_config.enabled and is_final_round:
570
+ local_results = []
571
+ shap_failures = []
414
572
 
415
- print(f"[Server/XGBoost] ✅ Round {server_round} - Aggregated Evaluation:")
416
- print(f" Loss: {loss}, Metrics: {metrics}\n")
573
+ for client_proxy, eval_res in results:
574
+ client_metrics = eval_res.metrics or {}
575
+
576
+ if (
577
+ client_metrics.get("shap_status")
578
+ == "completed"
579
+ and client_metrics.get("shap_result")
580
+ ):
581
+ try:
582
+ local_result = json.loads(
583
+ client_metrics["shap_result"]
584
+ )
585
+ local_results.append(
586
+ local_result
587
+ )
588
+ except Exception as exc:
589
+ shap_failures.append(
590
+ {
591
+ "client_id": client_proxy.cid,
592
+ "error": (
593
+ "Invalid SHAP JSON: "
594
+ f"{exc}"
595
+ ),
596
+ }
597
+ )
598
+ else:
599
+ shap_failures.append(
600
+ {
601
+ "client_id": client_proxy.cid,
602
+ "error": client_metrics.get(
603
+ "shap_error",
604
+ "No SHAP result returned",
605
+ ),
606
+ }
607
+ )
608
+
609
+ self.local_shap_results = local_results
610
+
611
+ if local_results:
612
+ try:
613
+ federated_result = (
614
+ aggregate_federated_shap(
615
+ client_results=local_results,
616
+ include_client_results=(
617
+ self.shap_config
618
+ .include_client_results
619
+ ),
620
+ )
621
+ )
622
+
623
+ federated_result["backend"] = (
624
+ "xgboost"
625
+ )
626
+ federated_result["explainer"] = (
627
+ self.shap_config.explainer
628
+ )
629
+ federated_result["data_split"] = (
630
+ self.shap_config.data_split
631
+ )
632
+ federated_result["background_size"] = (
633
+ self.shap_config.background_size
634
+ )
635
+ federated_result[
636
+ "requested_explanation_size"
637
+ ] = (
638
+ self.shap_config.explanation_size
639
+ )
640
+ federated_result["model_output"] = (
641
+ self.shap_config.model_output
642
+ )
643
+
644
+ if shap_failures:
645
+ federated_result[
646
+ "failed_clients"
647
+ ] = shap_failures
648
+
649
+ self.federated_shap_result = (
650
+ federated_result
651
+ )
652
+
653
+ print(
654
+ "[Server/SHAP] Federated SHAP "
655
+ "completed:"
656
+ )
657
+ print(
658
+ json.dumps(
659
+ self.federated_shap_result,
660
+ indent=2,
661
+ ),
662
+ flush=True,
663
+ )
664
+
665
+ except Exception as exc:
666
+ self.federated_shap_result = {
667
+ "status": "failed",
668
+ "backend": "xgboost",
669
+ "error": str(exc),
670
+ "failed_clients": shap_failures,
671
+ }
672
+
673
+ print(
674
+ "[Server/SHAP] XGBoost federated "
675
+ f"SHAP aggregation failed: {exc}",
676
+ flush=True,
677
+ )
678
+
679
+ else:
680
+ self.federated_shap_result = {
681
+ "status": "failed",
682
+ "backend": "xgboost",
683
+ "error": (
684
+ "SHAP was enabled but no XGBoost "
685
+ "client returned a valid SHAP result."
686
+ ),
687
+ "failed_clients": shap_failures,
688
+ }
689
+
690
+ print(
691
+ "[Server/SHAP] No valid XGBoost local "
692
+ "SHAP results were returned.",
693
+ flush=True,
694
+ )
417
695
 
418
696
  return loss, metrics
419
697
 
420
- strategy.aggregate_evaluate = wrapped_aggregate_evaluate
698
+ strategy.aggregate_evaluate = (
699
+ wrapped_aggregate_evaluate
700
+ )
421
701
 
422
702
  def _save_if_needed(self, server_round: int, parameters: Parameters) -> None:
423
703
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: medfl
3
- Version: 2.0.10.dev6
3
+ Version: 2.0.10.dev7
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: medfl
3
- Version: 2.0.10.dev6
3
+ Version: 2.0.10.dev7
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
@@ -14,7 +14,7 @@ with open('requirements.txt') as f:
14
14
 
15
15
  setup(
16
16
  name="medfl",
17
- version="2.0.10.dev6",
17
+ version="2.0.10.dev7",
18
18
  author="MEDomics consortium",
19
19
  author_email="medomics.info@gmail.com",
20
20
  description="Python Open-source package for simulating federated learning and differential privacy",
File without changes
File without changes
File without changes
File without changes