MEDfl 2.0.10.dev2__tar.gz → 2.0.10.dev3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/federated_dataset.py +20 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/server.py +364 -1
- medfl-2.0.10.dev3/MEDfl/LearningManager/shap/__init__.py +9 -0
- medfl-2.0.10.dev3/MEDfl/LearningManager/shap/aggregation.py +249 -0
- medfl-2.0.10.dev3/MEDfl/LearningManager/shap/config.py +103 -0
- medfl-2.0.10.dev3/MEDfl/LearningManager/shap/explainer.py +324 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/strategy.py +4 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/xgboost/strategy.py +1 -1
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/PKG-INFO +1 -1
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/medfl.egg-info/PKG-INFO +1 -1
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/medfl.egg-info/SOURCES.txt +4 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/medfl.egg-info/requires.txt +1 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/setup.py +1 -1
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/LICENSE +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/__init__.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/client.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/dynamicModal.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/flpipeline.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/model.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/params.yaml +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/params_optimiser.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/plot.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/utils.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/xgboost/__init__.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/xgboost/client.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/xgboost/config.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/xgboost/data.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/xgboost/metrics.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/xgboost/serialization.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/LearningManager/xgboost/server.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/NetManager/__init__.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/NetManager/database_connector.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/NetManager/dataset.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/NetManager/flsetup.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/NetManager/net_helper.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/NetManager/net_manager_queries.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/NetManager/network.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/NetManager/node.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/__init__.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/rw/__init__.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/rw/client.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/rw/model.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/rw/server.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/rw/strategy.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/rw/xgboost/__init__.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/rw/xgboost/client.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/rw/xgboost/config.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/rw/xgboost/data.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/rw/xgboost/metrics.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/rw/xgboost/serialization.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/rw/xgboost/strategy.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/scripts/__init__.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/scripts/base.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/MEDfl/scripts/create_db.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/README.md +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/alembic/__init__.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/alembic/env.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/medfl.egg-info/dependency_links.txt +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/medfl.egg-info/top_level.txt +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/setup.cfg +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/tests/test_dataset.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/tests/test_federated_dataset.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/tests/test_flpipeline.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/tests/test_flsetup.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/tests/test_network.py +0 -0
- {medfl-2.0.10.dev2 → medfl-2.0.10.dev3}/tests/test_node.py +0 -0
|
@@ -11,6 +11,7 @@ class FederatedDataset:
|
|
|
11
11
|
trainloaders: list,
|
|
12
12
|
valloaders: list,
|
|
13
13
|
testloaders: list,
|
|
14
|
+
feature_names: list[str] | None = None,
|
|
14
15
|
):
|
|
15
16
|
"""
|
|
16
17
|
Represents a Federated Dataset.
|
|
@@ -30,6 +31,25 @@ class FederatedDataset:
|
|
|
30
31
|
self.testloaders = testloaders
|
|
31
32
|
self.size = len(self.trainloaders[0].dataset[0][0])
|
|
32
33
|
|
|
34
|
+
if feature_names is None:
|
|
35
|
+
feature_names = [
|
|
36
|
+
f"feature_{index}"
|
|
37
|
+
for index in range(self.size)
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
if len(feature_names) != self.size:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"feature_names length must match the "
|
|
43
|
+
f"federated dataset size. Expected {self.size}, "
|
|
44
|
+
f"received {len(feature_names)}."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
self.feature_names = list(
|
|
48
|
+
feature_names
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
33
53
|
db_manager = DatabaseManager()
|
|
34
54
|
db_manager.connect()
|
|
35
55
|
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,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
|