MEDfl 2.0.10.dev2__py3-none-any.whl → 2.0.10.dev4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal, Optional
5
+
6
+
7
+ @dataclass
8
+ class SHAPConfig:
9
+ """
10
+ Configuration for the optional post-training federated SHAP phase.
11
+ """
12
+
13
+ enabled: bool = False
14
+
15
+ explainer: Literal[
16
+ "gradient",
17
+ "deep",
18
+ ] = "gradient"
19
+
20
+ data_split: Literal[
21
+ "train",
22
+ "validation",
23
+ "test",
24
+ ] = "validation"
25
+
26
+ background_size: int = 100
27
+ explanation_size: int = 500
28
+ random_seed: int = 42
29
+
30
+ feature_names: Optional[list[str]] = None
31
+
32
+ minimum_samples: int = 10
33
+ clipping_value: Optional[float] = None
34
+
35
+ # Whether individual client summaries should appear
36
+ # in the final result.
37
+ include_client_results: bool = True
38
+
39
+ def validate(self, input_size: int) -> None:
40
+ if not isinstance(self.enabled, bool):
41
+ raise TypeError("SHAP enabled must be a boolean")
42
+
43
+ if self.explainer not in {"gradient", "deep"}:
44
+ raise ValueError(
45
+ "SHAP explainer must be 'gradient' or 'deep'"
46
+ )
47
+
48
+ if self.data_split not in {
49
+ "train",
50
+ "validation",
51
+ "test",
52
+ }:
53
+ raise ValueError(
54
+ "SHAP data_split must be train, validation, or test"
55
+ )
56
+
57
+ if self.background_size <= 0:
58
+ raise ValueError(
59
+ "SHAP background_size must be greater than zero"
60
+ )
61
+
62
+ if self.explanation_size <= 0:
63
+ raise ValueError(
64
+ "SHAP explanation_size must be greater than zero"
65
+ )
66
+
67
+ if self.minimum_samples <= 0:
68
+ raise ValueError(
69
+ "SHAP minimum_samples must be greater than zero"
70
+ )
71
+
72
+ if self.clipping_value is not None:
73
+ if self.clipping_value <= 0:
74
+ raise ValueError(
75
+ "SHAP clipping_value must be greater than zero"
76
+ )
77
+
78
+ if self.feature_names is not None:
79
+ if len(self.feature_names) != input_size:
80
+ raise ValueError(
81
+ "The number of SHAP feature names must match "
82
+ f"the model input size. Expected {input_size}, "
83
+ f"received {len(self.feature_names)}."
84
+ )
85
+
86
+ if len(set(self.feature_names)) != len(
87
+ self.feature_names
88
+ ):
89
+ raise ValueError(
90
+ "SHAP feature names must be unique"
91
+ )
92
+
93
+ def resolved_feature_names(
94
+ self,
95
+ input_size: int,
96
+ ) -> list[str]:
97
+ if self.feature_names is not None:
98
+ return list(self.feature_names)
99
+
100
+ return [
101
+ f"feature_{index}"
102
+ for index in range(input_size)
103
+ ]
@@ -0,0 +1,324 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ import torch
8
+ from torch.utils.data import DataLoader
9
+
10
+
11
+ logger = logging.getLogger("MEDfl.shap")
12
+
13
+
14
+ class LocalPyTorchSHAPExplainer:
15
+ """
16
+ Calculate SHAP values for one client partition.
17
+
18
+ Raw samples and per-sample SHAP values are not returned.
19
+ Only additive summary statistics are returned.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ explainer_type: str = "gradient",
25
+ background_size: int = 100,
26
+ explanation_size: int = 500,
27
+ random_seed: int = 42,
28
+ clipping_value: float | None = None,
29
+ ) -> None:
30
+ self.explainer_type = explainer_type
31
+ self.background_size = background_size
32
+ self.explanation_size = explanation_size
33
+ self.random_seed = random_seed
34
+ self.clipping_value = clipping_value
35
+
36
+ @staticmethod
37
+ def _collect_features(
38
+ loader: DataLoader,
39
+ ) -> torch.Tensor:
40
+ """
41
+ Collect only X values from the selected client loader.
42
+ Labels are not required to calculate SHAP values.
43
+ """
44
+ feature_batches: list[torch.Tensor] = []
45
+
46
+ for batch_index, batch in enumerate(loader):
47
+ if not isinstance(batch, (tuple, list)):
48
+ raise TypeError(
49
+ "Each DataLoader batch must be a tuple or list"
50
+ )
51
+
52
+ if len(batch) == 0:
53
+ raise ValueError(
54
+ f"Empty batch received at index {batch_index}"
55
+ )
56
+
57
+ X = batch[0]
58
+
59
+ if not isinstance(X, torch.Tensor):
60
+ X = torch.as_tensor(X)
61
+
62
+ X = X.detach().cpu().float()
63
+
64
+ if torch.isnan(X).any():
65
+ raise ValueError(
66
+ f"SHAP input contains NaN values in batch "
67
+ f"{batch_index}"
68
+ )
69
+
70
+ if torch.isinf(X).any():
71
+ raise ValueError(
72
+ f"SHAP input contains infinite values in batch "
73
+ f"{batch_index}"
74
+ )
75
+
76
+ feature_batches.append(X)
77
+
78
+ if not feature_batches:
79
+ raise ValueError(
80
+ "Cannot calculate SHAP using an empty DataLoader"
81
+ )
82
+
83
+ features = torch.cat(
84
+ feature_batches,
85
+ dim=0,
86
+ )
87
+
88
+ # First implementation: tabular data.
89
+ if features.ndim != 2:
90
+ raise ValueError(
91
+ "The current SHAP implementation supports tabular "
92
+ "input with shape (samples, features). "
93
+ f"Received shape {tuple(features.shape)}."
94
+ )
95
+
96
+ return features
97
+
98
+ @staticmethod
99
+ def _sample_rows(
100
+ data: torch.Tensor,
101
+ requested_size: int,
102
+ generator: torch.Generator,
103
+ ) -> torch.Tensor:
104
+ selected_size = min(
105
+ requested_size,
106
+ len(data),
107
+ )
108
+
109
+ indices = torch.randperm(
110
+ len(data),
111
+ generator=generator,
112
+ )[:selected_size]
113
+
114
+ return data[indices]
115
+
116
+ @staticmethod
117
+ def _normalize_shap_values(
118
+ raw_values: Any,
119
+ sample_count: int,
120
+ feature_count: int,
121
+ ) -> np.ndarray:
122
+ """
123
+ Normalize the different shapes returned by SHAP versions.
124
+
125
+ Expected final shape:
126
+ (number_of_samples, number_of_features)
127
+ """
128
+
129
+ if hasattr(raw_values, "values"):
130
+ raw_values = raw_values.values
131
+
132
+ if isinstance(raw_values, list):
133
+ if len(raw_values) != 1:
134
+ raise ValueError(
135
+ "The current implementation supports binary or "
136
+ "single-output models only. Multiclass SHAP "
137
+ "requires class-specific aggregation."
138
+ )
139
+
140
+ raw_values = raw_values[0]
141
+
142
+ values = np.asarray(raw_values)
143
+
144
+ # Possible shape: (samples, features, 1)
145
+ if (
146
+ values.ndim == 3
147
+ and values.shape[-1] == 1
148
+ ):
149
+ values = values[..., 0]
150
+
151
+ # Possible shape: (1, samples, features)
152
+ if (
153
+ values.ndim == 3
154
+ and values.shape[0] == 1
155
+ ):
156
+ values = values[0]
157
+
158
+ expected_shape = (
159
+ sample_count,
160
+ feature_count,
161
+ )
162
+
163
+ if values.shape != expected_shape:
164
+ raise ValueError(
165
+ "Unexpected SHAP output shape. "
166
+ f"Expected {expected_shape}, received {values.shape}."
167
+ )
168
+
169
+ values = values.astype(
170
+ np.float64,
171
+ copy=False,
172
+ )
173
+
174
+ if np.isnan(values).any():
175
+ raise ValueError(
176
+ "Calculated SHAP values contain NaN values"
177
+ )
178
+
179
+ if np.isinf(values).any():
180
+ raise ValueError(
181
+ "Calculated SHAP values contain infinite values"
182
+ )
183
+
184
+ return values
185
+
186
+ def _create_explainer(
187
+ self,
188
+ model: torch.nn.Module,
189
+ background: torch.Tensor,
190
+ ):
191
+ try:
192
+ import shap
193
+ except ImportError as exc:
194
+ raise ImportError(
195
+ "SHAP is not installed. Install it with: "
196
+ "pip install shap"
197
+ ) from exc
198
+
199
+ if self.explainer_type == "gradient":
200
+ return shap.GradientExplainer(
201
+ model,
202
+ background,
203
+ )
204
+
205
+ if self.explainer_type == "deep":
206
+ return shap.DeepExplainer(
207
+ model,
208
+ background,
209
+ )
210
+
211
+ raise ValueError(
212
+ f"Unsupported SHAP explainer: {self.explainer_type}"
213
+ )
214
+
215
+ def calculate(
216
+ self,
217
+ model: torch.nn.Module,
218
+ loader: DataLoader,
219
+ device: torch.device,
220
+ ) -> dict:
221
+ """
222
+ Calculate SHAP for one client partition.
223
+
224
+ The returned values are additive summaries. They can be
225
+ combined across clients without transmitting raw SHAP rows.
226
+ """
227
+
228
+ features = self._collect_features(loader)
229
+
230
+ if len(features) < 2:
231
+ raise ValueError(
232
+ "At least two samples are required for SHAP"
233
+ )
234
+
235
+ generator = torch.Generator()
236
+ generator.manual_seed(self.random_seed)
237
+
238
+ background = self._sample_rows(
239
+ data=features,
240
+ requested_size=self.background_size,
241
+ generator=generator,
242
+ )
243
+
244
+ explanation_data = self._sample_rows(
245
+ data=features,
246
+ requested_size=self.explanation_size,
247
+ generator=generator,
248
+ )
249
+
250
+ background = background.to(device)
251
+ explanation_data = explanation_data.to(device)
252
+
253
+ model = model.to(device)
254
+ model.eval()
255
+
256
+ logger.info(
257
+ "Calculating local SHAP | "
258
+ "explainer=%s | available=%d | background=%d | "
259
+ "explained=%d",
260
+ self.explainer_type,
261
+ len(features),
262
+ len(background),
263
+ len(explanation_data),
264
+ )
265
+
266
+ explainer = self._create_explainer(
267
+ model=model,
268
+ background=background,
269
+ )
270
+
271
+ raw_values = explainer.shap_values(
272
+ explanation_data
273
+ )
274
+
275
+ shap_values = self._normalize_shap_values(
276
+ raw_values=raw_values,
277
+ sample_count=len(explanation_data),
278
+ feature_count=explanation_data.shape[1],
279
+ )
280
+
281
+ if self.clipping_value is not None:
282
+ shap_values = np.clip(
283
+ shap_values,
284
+ -self.clipping_value,
285
+ self.clipping_value,
286
+ )
287
+
288
+ # Additive summaries are preferable because they can later
289
+ # be used with distributed or secure aggregation.
290
+ result = {
291
+ "sample_count": int(shap_values.shape[0]),
292
+
293
+ "abs_sum": np.sum(
294
+ np.abs(shap_values),
295
+ axis=0,
296
+ ),
297
+
298
+ "signed_sum": np.sum(
299
+ shap_values,
300
+ axis=0,
301
+ ),
302
+
303
+ "square_sum": np.sum(
304
+ np.square(shap_values),
305
+ axis=0,
306
+ ),
307
+
308
+ "positive_count": np.sum(
309
+ shap_values > 0,
310
+ axis=0,
311
+ ).astype(np.int64),
312
+
313
+ "negative_count": np.sum(
314
+ shap_values < 0,
315
+ axis=0,
316
+ ).astype(np.int64),
317
+
318
+ "zero_count": np.sum(
319
+ shap_values == 0,
320
+ axis=0,
321
+ ).astype(np.int64),
322
+ }
323
+
324
+ return result
@@ -152,6 +152,8 @@ class Strategy:
152
152
  # previous global model (flattened) for delta/cos sim
153
153
  self._prev_global_flat: Optional[np.ndarray] = None
154
154
 
155
+ self.latest_aggregated_parameters = None
156
+
155
157
  # logger (Flower config will handle output)
156
158
  self._log = logging.getLogger("flwr")
157
159
  self._debug_print = debug_print
@@ -225,10 +227,12 @@ class Strategy:
225
227
 
226
228
  # Call base aggregation
227
229
  agg = super().aggregate_fit(server_round, results, failures)
230
+
228
231
 
229
232
  # Round summary + global fingerprint (only if aggregation produced parameters)
230
233
  if agg is not None:
231
234
  parameters_agg, metrics_agg = agg # type: ignore[misc]
235
+ self.latest_aggregated_parameters = (parameters_agg)
232
236
  flat = self._params_to_flat(parameters_agg)
233
237
  fp = self._fingerprint(flat)
234
238
  self._prev_global_flat = flat
@@ -336,7 +336,7 @@ class XGBoostSimulationStrategy:
336
336
  return
337
337
 
338
338
  should_save = (
339
- server_round % self.saveOnRounds == 0
339
+ server_round % self.save_on_rounds == 0
340
340
  or server_round == self.total_rounds
341
341
  )
342
342
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: medfl
3
- Version: 2.0.10.dev2
3
+ Version: 2.0.10.dev4
4
4
  Summary: Python Open-source package for simulating federated learning and differential privacy
5
5
  Home-page: https://github.com/MEDomics-UdeS/MEDfl
6
6
  Author: MEDomics consortium
@@ -34,6 +34,7 @@ Requires-Dist: Sphinx<6.0,>=5.3
34
34
  Requires-Dist: sphinx-jsonschema==1.19.1
35
35
  Requires-Dist: sphinx-rtd-dark-mode==1.2.4
36
36
  Requires-Dist: xgboost
37
+ Requires-Dist: shap
37
38
 
38
39
  # MEDfl: A Collaborative Framework for Federated Learning in Medicine
39
40
 
@@ -2,15 +2,19 @@ MEDfl/__init__.py,sha256=70DmtU4C3A-1XYoaYm0moXBe-YGJ2FhEe3ga5SQVTts,97
2
2
  MEDfl/LearningManager/__init__.py,sha256=IMHJVeyx5ew0U_90LNMNCd4QISzWv3XCCri7fQRvcsM,341
3
3
  MEDfl/LearningManager/client.py,sha256=DQqptRl9Yff3G-THpYz7XOYzUePHKCkPKHBGg3tw88Q,6427
4
4
  MEDfl/LearningManager/dynamicModal.py,sha256=q8u7xPpj_TdZnSr8kYj0Xx7Sdz-diXsKBAfVce8-qSU,10534
5
- MEDfl/LearningManager/federated_dataset.py,sha256=InsZ5Rys2dgqaPxVyP5G3TrJMwiCNHOoTd3tCpUwUVM,2081
5
+ MEDfl/LearningManager/federated_dataset.py,sha256=9U1yBE_rsHiTe8AgqyYmr5semqvsPlxvefzhHxTWsik,2661
6
6
  MEDfl/LearningManager/flpipeline.py,sha256=5lT2uod5EqnkRQ04cgm0gYyZz0djumfIYipCrzX1fdo,7111
7
7
  MEDfl/LearningManager/model.py,sha256=f6_S0pGtal9d314YHQh8EDkBnpCnlAS_Ak-dly4w-B8,10718
8
8
  MEDfl/LearningManager/params.yaml,sha256=joS329zAmYwKzfMVaZt12vx53FL9G3KWTPfYQZck1WI,434
9
9
  MEDfl/LearningManager/params_optimiser.py,sha256=8e0gCt4imwQHlNSJ3A2EAuc3wSr6yfSI6JDghohfmZQ,17618
10
10
  MEDfl/LearningManager/plot.py,sha256=h9k5KuPN4xncK-M4X4llUWug1gziGtzq4WeoJ4UQ8qI,10704
11
- MEDfl/LearningManager/server.py,sha256=cM-q1W2hV2Tp4K_3geVEc_4clW30Kuqq9VXHDFv-mWw,9921
12
- MEDfl/LearningManager/strategy.py,sha256=3wZC1a8-dwg1fSJrY6sVZPihw-fI0Hzj6jam7QpfCfA,13931
11
+ MEDfl/LearningManager/server.py,sha256=zx2yc70TfDVwuJ31SD0SdaHk31cThspTlCdxDI5FCSM,19737
12
+ MEDfl/LearningManager/strategy.py,sha256=YsQ1tFCuFjoRemNBjoygnrNzo59UdYvggbIT8zP2YCE,14071
13
13
  MEDfl/LearningManager/utils.py,sha256=90J-d2JiHbaQT2MdlfdW_YnpCUcjX16uE_vyW9MdFMY,9600
14
+ MEDfl/LearningManager/shap/__init__.py,sha256=TKc24eg88e89vfoOgLfyKuImkLgGU4Bsl9Kcf-XRHuk,227
15
+ MEDfl/LearningManager/shap/aggregation.py,sha256=Kg8LbxBXDMGqQ4s2-uy5f2vxa44K6Wj15e0QGxgX67E,6330
16
+ MEDfl/LearningManager/shap/config.py,sha256=vSKHHO7x34-CeEc7lvcrj7CsAnBKvE3yv9f-4mF4fiM,2813
17
+ MEDfl/LearningManager/shap/explainer.py,sha256=p4Ri3xmjJpk4cfq2F4ewaNxLSw68rM4Rl7T_S0DnJpU,8598
14
18
  MEDfl/LearningManager/xgboost/__init__.py,sha256=rc8fJMNPc8U3crWaEA2yZ0-Uw2Nn-3HiEqCGoG_eZN0,362
15
19
  MEDfl/LearningManager/xgboost/client.py,sha256=czrk90vNZwRpiIRrOtYZZUfYVIZu18NRgcdDCBvnjZo,8363
16
20
  MEDfl/LearningManager/xgboost/config.py,sha256=TAS4evXe1DmhTWEduKbeG_Dg4D_oTQCQn96LmQvlIHM,1911
@@ -18,7 +22,7 @@ MEDfl/LearningManager/xgboost/data.py,sha256=UTFx3SOQuKZP94kBeExB1r4MBFKQXx2z4Kq
18
22
  MEDfl/LearningManager/xgboost/metrics.py,sha256=lkIChQH6wh6PUT5chIn85jKMpC4IjcERra2cI-SX8o4,3999
19
23
  MEDfl/LearningManager/xgboost/serialization.py,sha256=ymoywErp3a8MvitypXYhtQ6feviFsOUsJbPD57VEKBQ,1005
20
24
  MEDfl/LearningManager/xgboost/server.py,sha256=JPkWOoHD6TKkLlsvtFlCFws-exZDHDVEdPYUHlfknAM,7913
21
- MEDfl/LearningManager/xgboost/strategy.py,sha256=GeiZRebgHQbsYyr2b5_kJft2Rcb_uNUlsexumui_Uic,18651
25
+ MEDfl/LearningManager/xgboost/strategy.py,sha256=tEsXYrAVy6zYO1E-0VV9RvIwcWUBmlwhs4V8UOYzL8U,18653
22
26
  MEDfl/NetManager/__init__.py,sha256=OpgsIiBg7UA6Bfnu_kqGfEPxU8JfpPxSFU98TOeDTP0,273
23
27
  MEDfl/NetManager/database_connector.py,sha256=PZEAesMR-k9jSuthx4wOTkMzUWB3INODOrhQgpMO0kY,1554
24
28
  MEDfl/NetManager/dataset.py,sha256=OxUGlUDyjpD9Q0JhyVq7GMvzh42wcqYUArX-YXfouB4,2795
@@ -44,8 +48,8 @@ MEDfl/scripts/base.py,sha256=QrmG7gkiPYkAy-5tXxJgJmOSLGAKeIVH6i0jq7G9xnA,752
44
48
  MEDfl/scripts/create_db.py,sha256=MnFtZkTueRZ-3qXPNX4JsXjOKj-4mlkxoRhSFdRcvJw,3817
45
49
  alembic/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
50
  alembic/env.py,sha256=-aSZ6SlJeK1ZeqHgM-54hOi9LhJRFP0SZGjut-JnY-4,1588
47
- medfl-2.0.10.dev2.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
48
- medfl-2.0.10.dev2.dist-info/METADATA,sha256=FeWXwOneIWGjq0LETpHsLPQd0YcxINH_stRjqBkS8j8,6632
49
- medfl-2.0.10.dev2.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
50
- medfl-2.0.10.dev2.dist-info/top_level.txt,sha256=dIL9X8HOFuaVSzpg40DVveDPrymWRoShHtspH7kkjdI,14
51
- medfl-2.0.10.dev2.dist-info/RECORD,,
51
+ medfl-2.0.10.dev4.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
52
+ medfl-2.0.10.dev4.dist-info/METADATA,sha256=aoOACHdZ5B8gMyBBLvuDSNezoLVt3MgzXeJs1NcsVss,6652
53
+ medfl-2.0.10.dev4.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
54
+ medfl-2.0.10.dev4.dist-info/top_level.txt,sha256=dIL9X8HOFuaVSzpg40DVveDPrymWRoShHtspH7kkjdI,14
55
+ medfl-2.0.10.dev4.dist-info/RECORD,,