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.
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/server.py +32 -6
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/client.py +291 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/strategy.py +290 -10
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/PKG-INFO +1 -1
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/medfl.egg-info/PKG-INFO +1 -1
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/setup.py +1 -1
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/LICENSE +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/client.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/dynamicModal.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/federated_dataset.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/flpipeline.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/model.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/params.yaml +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/params_optimiser.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/plot.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/server.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/aggregation.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/config.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/explainer.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/shap/xgboost_explainer.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/strategy.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/utils.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/client.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/config.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/data.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/metrics.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/serialization.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/server.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/LearningManager/xgboost/strategy.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/database_connector.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/dataset.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/flsetup.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/net_helper.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/net_manager_queries.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/network.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/NetManager/node.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/client.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/model.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/strategy.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/config.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/data.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/metrics.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/rw/xgboost/serialization.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/scripts/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/scripts/base.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/MEDfl/scripts/create_db.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/README.md +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/alembic/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/alembic/env.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/medfl.egg-info/SOURCES.txt +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/medfl.egg-info/dependency_links.txt +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/medfl.egg-info/requires.txt +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/medfl.egg-info/top_level.txt +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/setup.cfg +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/tests/test_dataset.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/tests/test_federated_dataset.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/tests/test_flpipeline.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/tests/test_flsetup.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev7}/tests/test_network.py +0 -0
- {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
|
|
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
|
-
|
|
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
|
|
83
|
-
"
|
|
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=
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
531
|
+
original_aggregate_evaluate = (
|
|
532
|
+
strategy.aggregate_evaluate
|
|
533
|
+
)
|
|
403
534
|
|
|
404
|
-
def wrapped_aggregate_evaluate(
|
|
405
|
-
|
|
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(
|
|
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
|
-
|
|
416
|
-
|
|
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 =
|
|
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
|
"""
|
|
@@ -14,7 +14,7 @@ with open('requirements.txt') as f:
|
|
|
14
14
|
|
|
15
15
|
setup(
|
|
16
16
|
name="medfl",
|
|
17
|
-
version="2.0.10.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|