MEDfl 2.0.10.dev2__tar.gz → 2.0.10.dev4__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 (66) hide show
  1. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/federated_dataset.py +22 -0
  2. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/server.py +364 -1
  3. medfl-2.0.10.dev4/MEDfl/LearningManager/shap/__init__.py +9 -0
  4. medfl-2.0.10.dev4/MEDfl/LearningManager/shap/aggregation.py +249 -0
  5. medfl-2.0.10.dev4/MEDfl/LearningManager/shap/config.py +103 -0
  6. medfl-2.0.10.dev4/MEDfl/LearningManager/shap/explainer.py +324 -0
  7. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/strategy.py +4 -0
  8. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/xgboost/strategy.py +1 -1
  9. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/PKG-INFO +1 -1
  10. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/medfl.egg-info/PKG-INFO +1 -1
  11. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/medfl.egg-info/SOURCES.txt +4 -0
  12. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/medfl.egg-info/requires.txt +1 -0
  13. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/setup.py +1 -1
  14. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/LICENSE +0 -0
  15. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/__init__.py +0 -0
  16. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/client.py +0 -0
  17. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/dynamicModal.py +0 -0
  18. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/flpipeline.py +0 -0
  19. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/model.py +0 -0
  20. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/params.yaml +0 -0
  21. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/params_optimiser.py +0 -0
  22. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/plot.py +0 -0
  23. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/utils.py +0 -0
  24. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/xgboost/__init__.py +0 -0
  25. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/xgboost/client.py +0 -0
  26. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/xgboost/config.py +0 -0
  27. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/xgboost/data.py +0 -0
  28. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/xgboost/metrics.py +0 -0
  29. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/xgboost/serialization.py +0 -0
  30. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/LearningManager/xgboost/server.py +0 -0
  31. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/NetManager/__init__.py +0 -0
  32. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/NetManager/database_connector.py +0 -0
  33. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/NetManager/dataset.py +0 -0
  34. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/NetManager/flsetup.py +0 -0
  35. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/NetManager/net_helper.py +0 -0
  36. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/NetManager/net_manager_queries.py +0 -0
  37. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/NetManager/network.py +0 -0
  38. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/NetManager/node.py +0 -0
  39. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/__init__.py +0 -0
  40. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/rw/__init__.py +0 -0
  41. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/rw/client.py +0 -0
  42. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/rw/model.py +0 -0
  43. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/rw/server.py +0 -0
  44. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/rw/strategy.py +0 -0
  45. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/rw/xgboost/__init__.py +0 -0
  46. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/rw/xgboost/client.py +0 -0
  47. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/rw/xgboost/config.py +0 -0
  48. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/rw/xgboost/data.py +0 -0
  49. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/rw/xgboost/metrics.py +0 -0
  50. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/rw/xgboost/serialization.py +0 -0
  51. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/rw/xgboost/strategy.py +0 -0
  52. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/scripts/__init__.py +0 -0
  53. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/scripts/base.py +0 -0
  54. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/MEDfl/scripts/create_db.py +0 -0
  55. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/README.md +0 -0
  56. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/alembic/__init__.py +0 -0
  57. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/alembic/env.py +0 -0
  58. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/medfl.egg-info/dependency_links.txt +0 -0
  59. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/medfl.egg-info/top_level.txt +0 -0
  60. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/setup.cfg +0 -0
  61. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/tests/test_dataset.py +0 -0
  62. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/tests/test_federated_dataset.py +0 -0
  63. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/tests/test_flpipeline.py +0 -0
  64. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/tests/test_flsetup.py +0 -0
  65. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/tests/test_network.py +0 -0
  66. {medfl-2.0.10.dev2 → medfl-2.0.10.dev4}/tests/test_node.py +0 -0
@@ -1,6 +1,8 @@
1
1
  from MEDfl.NetManager.net_helper import *
2
2
  from MEDfl.NetManager.net_manager_queries import *
3
3
  from MEDfl.NetManager.database_connector import DatabaseManager
4
+ from typing import List, Optional
5
+
4
6
 
5
7
  class FederatedDataset:
6
8
  def __init__(
@@ -11,6 +13,7 @@ class FederatedDataset:
11
13
  trainloaders: list,
12
14
  valloaders: list,
13
15
  testloaders: list,
16
+ feature_names: Optional[List[str]] = None,
14
17
  ):
15
18
  """
16
19
  Represents a Federated Dataset.
@@ -30,6 +33,25 @@ class FederatedDataset:
30
33
  self.testloaders = testloaders
31
34
  self.size = len(self.trainloaders[0].dataset[0][0])
32
35
 
36
+ if feature_names is None:
37
+ feature_names = [
38
+ f"feature_{index}"
39
+ for index in range(self.size)
40
+ ]
41
+
42
+ if len(feature_names) != self.size:
43
+ raise ValueError(
44
+ "feature_names length must match the "
45
+ f"federated dataset size. Expected {self.size}, "
46
+ f"received {len(feature_names)}."
47
+ )
48
+
49
+ self.feature_names = list(
50
+ feature_names
51
+ )
52
+
53
+
54
+
33
55
  db_manager = DatabaseManager()
34
56
  db_manager.connect()
35
57
  self.eng = db_manager.get_connection()
@@ -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,9 @@
1
+ from .config import SHAPConfig
2
+ from .explainer import LocalPyTorchSHAPExplainer
3
+ from .aggregation import aggregate_federated_shap
4
+
5
+ __all__ = [
6
+ "SHAPConfig",
7
+ "LocalPyTorchSHAPExplainer",
8
+ "aggregate_federated_shap",
9
+ ]
@@ -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