MEDfl 2.0.10.dev4__tar.gz → 2.0.10.dev5__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 (69) hide show
  1. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/shap/__init__.py +5 -0
  2. medfl-2.0.10.dev5/MEDfl/LearningManager/shap/config.py +196 -0
  3. medfl-2.0.10.dev5/MEDfl/LearningManager/shap/xgboost_explainer.py +436 -0
  4. medfl-2.0.10.dev5/MEDfl/LearningManager/xgboost/server.py +746 -0
  5. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/PKG-INFO +1 -1
  6. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/medfl.egg-info/PKG-INFO +1 -1
  7. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/medfl.egg-info/SOURCES.txt +1 -0
  8. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/setup.py +1 -1
  9. medfl-2.0.10.dev4/MEDfl/LearningManager/shap/config.py +0 -103
  10. medfl-2.0.10.dev4/MEDfl/LearningManager/xgboost/server.py +0 -262
  11. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/LICENSE +0 -0
  12. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/__init__.py +0 -0
  13. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/client.py +0 -0
  14. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/dynamicModal.py +0 -0
  15. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/federated_dataset.py +0 -0
  16. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/flpipeline.py +0 -0
  17. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/model.py +0 -0
  18. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/params.yaml +0 -0
  19. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/params_optimiser.py +0 -0
  20. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/plot.py +0 -0
  21. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/server.py +0 -0
  22. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/shap/aggregation.py +0 -0
  23. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/shap/explainer.py +0 -0
  24. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/strategy.py +0 -0
  25. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/utils.py +0 -0
  26. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/xgboost/__init__.py +0 -0
  27. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/xgboost/client.py +0 -0
  28. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/xgboost/config.py +0 -0
  29. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/xgboost/data.py +0 -0
  30. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/xgboost/metrics.py +0 -0
  31. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/xgboost/serialization.py +0 -0
  32. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/xgboost/strategy.py +0 -0
  33. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/__init__.py +0 -0
  34. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/database_connector.py +0 -0
  35. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/dataset.py +0 -0
  36. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/flsetup.py +0 -0
  37. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/net_helper.py +0 -0
  38. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/net_manager_queries.py +0 -0
  39. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/network.py +0 -0
  40. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/node.py +0 -0
  41. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/__init__.py +0 -0
  42. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/__init__.py +0 -0
  43. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/client.py +0 -0
  44. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/model.py +0 -0
  45. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/server.py +0 -0
  46. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/strategy.py +0 -0
  47. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/xgboost/__init__.py +0 -0
  48. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/xgboost/client.py +0 -0
  49. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/xgboost/config.py +0 -0
  50. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/xgboost/data.py +0 -0
  51. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/xgboost/metrics.py +0 -0
  52. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/xgboost/serialization.py +0 -0
  53. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/xgboost/strategy.py +0 -0
  54. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/scripts/__init__.py +0 -0
  55. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/scripts/base.py +0 -0
  56. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/scripts/create_db.py +0 -0
  57. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/README.md +0 -0
  58. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/alembic/__init__.py +0 -0
  59. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/alembic/env.py +0 -0
  60. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/medfl.egg-info/dependency_links.txt +0 -0
  61. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/medfl.egg-info/requires.txt +0 -0
  62. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/medfl.egg-info/top_level.txt +0 -0
  63. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/setup.cfg +0 -0
  64. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/tests/test_dataset.py +0 -0
  65. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/tests/test_federated_dataset.py +0 -0
  66. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/tests/test_flpipeline.py +0 -0
  67. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/tests/test_flsetup.py +0 -0
  68. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/tests/test_network.py +0 -0
  69. {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/tests/test_node.py +0 -0
@@ -1,9 +1,14 @@
1
1
  from .config import SHAPConfig
2
2
  from .explainer import LocalPyTorchSHAPExplainer
3
+ from .xgboost_explainer import (
4
+ LocalXGBoostSHAPExplainer,
5
+ )
3
6
  from .aggregation import aggregate_federated_shap
4
7
 
8
+
5
9
  __all__ = [
6
10
  "SHAPConfig",
7
11
  "LocalPyTorchSHAPExplainer",
12
+ "LocalXGBoostSHAPExplainer",
8
13
  "aggregate_federated_shap",
9
14
  ]
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import List, 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
+ # Neural networks:
16
+ # gradient
17
+ # deep
18
+ #
19
+ # XGBoost:
20
+ # tree
21
+ explainer: Literal[
22
+ "gradient",
23
+ "deep",
24
+ "tree",
25
+ ] = "gradient"
26
+
27
+ data_split: Literal[
28
+ "train",
29
+ "validation",
30
+ "test",
31
+ ] = "validation"
32
+
33
+ background_size: int = 100
34
+ explanation_size: int = 500
35
+ random_seed: int = 42
36
+
37
+ feature_names: Optional[List[str]] = None
38
+
39
+ minimum_samples: int = 10
40
+ clipping_value: Optional[float] = None
41
+
42
+ include_client_results: bool = True
43
+
44
+ # TreeExplainer output:
45
+ #
46
+ # raw:
47
+ # Binary classification → log-odds margin
48
+ # Regression → raw prediction
49
+ #
50
+ # probability:
51
+ # Binary classification probability when supported by the
52
+ # installed SHAP/XGBoost versions.
53
+ model_output: Literal[
54
+ "raw",
55
+ "probability",
56
+ ] = "raw"
57
+
58
+ def validate(
59
+ self,
60
+ input_size: int,
61
+ ) -> None:
62
+ if not isinstance(self.enabled, bool):
63
+ raise TypeError(
64
+ "SHAP enabled must be a boolean"
65
+ )
66
+
67
+ if self.explainer not in {
68
+ "gradient",
69
+ "deep",
70
+ "tree",
71
+ }:
72
+ raise ValueError(
73
+ "SHAP explainer must be 'gradient', "
74
+ "'deep', or 'tree'"
75
+ )
76
+
77
+ if self.data_split not in {
78
+ "train",
79
+ "validation",
80
+ "test",
81
+ }:
82
+ raise ValueError(
83
+ "SHAP data_split must be train, "
84
+ "validation, or test"
85
+ )
86
+
87
+ if self.background_size <= 0:
88
+ raise ValueError(
89
+ "SHAP background_size must be greater than zero"
90
+ )
91
+
92
+ if self.explanation_size <= 0:
93
+ raise ValueError(
94
+ "SHAP explanation_size must be greater than zero"
95
+ )
96
+
97
+ if self.minimum_samples <= 0:
98
+ raise ValueError(
99
+ "SHAP minimum_samples must be greater than zero"
100
+ )
101
+
102
+ if self.clipping_value is not None:
103
+ if self.clipping_value <= 0:
104
+ raise ValueError(
105
+ "SHAP clipping_value must be greater than zero"
106
+ )
107
+
108
+ if self.model_output not in {
109
+ "raw",
110
+ "probability",
111
+ }:
112
+ raise ValueError(
113
+ "SHAP model_output must be 'raw' or "
114
+ "'probability'"
115
+ )
116
+
117
+ if self.feature_names is not None:
118
+ if len(self.feature_names) != input_size:
119
+ raise ValueError(
120
+ "The number of SHAP feature names must "
121
+ "match the model input size. "
122
+ f"Expected {input_size}, "
123
+ f"received {len(self.feature_names)}."
124
+ )
125
+
126
+ if len(set(self.feature_names)) != len(
127
+ self.feature_names
128
+ ):
129
+ raise ValueError(
130
+ "SHAP feature names must be unique"
131
+ )
132
+
133
+ def validate_backend(
134
+ self,
135
+ input_size: int,
136
+ backend: str,
137
+ task: Optional[str] = None,
138
+ ) -> None:
139
+ """
140
+ Validate both the general configuration and the selected
141
+ machine-learning backend.
142
+ """
143
+
144
+ self.validate(input_size)
145
+
146
+ backend = str(backend).lower()
147
+
148
+ if backend == "pytorch":
149
+ if self.explainer not in {
150
+ "gradient",
151
+ "deep",
152
+ }:
153
+ raise ValueError(
154
+ "PyTorch models require explainer='gradient' "
155
+ "or explainer='deep'"
156
+ )
157
+
158
+ elif backend == "xgboost":
159
+ if self.explainer != "tree":
160
+ raise ValueError(
161
+ "XGBoost models require explainer='tree'"
162
+ )
163
+
164
+ if task == "multiclass":
165
+ raise ValueError(
166
+ "The current federated XGBoost SHAP "
167
+ "implementation supports binary classification "
168
+ "and regression. Multiclass requires "
169
+ "class-specific SHAP aggregation."
170
+ )
171
+
172
+ if (
173
+ task == "regression"
174
+ and self.model_output == "probability"
175
+ ):
176
+ raise ValueError(
177
+ "model_output='probability' is not valid for "
178
+ "XGBoost regression"
179
+ )
180
+
181
+ else:
182
+ raise ValueError(
183
+ f"Unsupported SHAP backend: {backend}"
184
+ )
185
+
186
+ def resolved_feature_names(
187
+ self,
188
+ input_size: int,
189
+ ) -> List[str]:
190
+ if self.feature_names is not None:
191
+ return list(self.feature_names)
192
+
193
+ return [
194
+ f"feature_{index}"
195
+ for index in range(input_size)
196
+ ]
@@ -0,0 +1,436 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ import numpy as np
7
+ import torch
8
+ import xgboost as xgb
9
+ from torch.utils.data import DataLoader
10
+
11
+
12
+ logger = logging.getLogger("MEDfl.shap.xgboost")
13
+
14
+
15
+ class LocalXGBoostSHAPExplainer:
16
+ """
17
+ Calculate local SHAP values for a federated XGBoost client.
18
+
19
+ The final federated XGBoost booster is explained separately on
20
+ every client's selected local partition.
21
+
22
+ Raw samples and raw per-sample SHAP values are never returned.
23
+ Only additive summary statistics are returned so the existing
24
+ federated SHAP aggregation function can be reused.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ background_size: int = 100,
30
+ explanation_size: int = 500,
31
+ random_seed: int = 42,
32
+ clipping_value: Optional[float] = None,
33
+ model_output: str = "raw",
34
+ ) -> None:
35
+ self.background_size = int(background_size)
36
+ self.explanation_size = int(explanation_size)
37
+ self.random_seed = int(random_seed)
38
+ self.clipping_value = clipping_value
39
+ self.model_output = str(model_output)
40
+
41
+ self._validate_configuration()
42
+
43
+ def _validate_configuration(self) -> None:
44
+ if self.background_size <= 0:
45
+ raise ValueError(
46
+ "background_size must be greater than zero"
47
+ )
48
+
49
+ if self.explanation_size <= 0:
50
+ raise ValueError(
51
+ "explanation_size must be greater than zero"
52
+ )
53
+
54
+ if self.clipping_value is not None:
55
+ if self.clipping_value <= 0:
56
+ raise ValueError(
57
+ "clipping_value must be greater than zero"
58
+ )
59
+
60
+ if self.model_output not in {
61
+ "raw",
62
+ "probability",
63
+ }:
64
+ raise ValueError(
65
+ "model_output must be 'raw' or 'probability'"
66
+ )
67
+
68
+ @staticmethod
69
+ def _collect_features(
70
+ loader: DataLoader,
71
+ ) -> np.ndarray:
72
+ """
73
+ Collect only the input features from a client DataLoader.
74
+
75
+ Labels are not needed for SHAP calculation.
76
+ """
77
+
78
+ if loader is None:
79
+ raise ValueError(
80
+ "Cannot calculate XGBoost SHAP using a None loader"
81
+ )
82
+
83
+ feature_batches: List[np.ndarray] = []
84
+
85
+ for batch_index, batch in enumerate(loader):
86
+ if not isinstance(batch, (tuple, list)):
87
+ raise TypeError(
88
+ "Each DataLoader batch must be a tuple or list"
89
+ )
90
+
91
+ if len(batch) == 0:
92
+ raise ValueError(
93
+ f"Empty batch received at index {batch_index}"
94
+ )
95
+
96
+ features = batch[0]
97
+
98
+ if isinstance(features, torch.Tensor):
99
+ features = (
100
+ features
101
+ .detach()
102
+ .cpu()
103
+ .numpy()
104
+ )
105
+
106
+ features = np.asarray(features)
107
+
108
+ if features.ndim == 1:
109
+ features = features.reshape(1, -1)
110
+
111
+ if features.ndim != 2:
112
+ raise ValueError(
113
+ "The XGBoost SHAP implementation supports "
114
+ "two-dimensional tabular inputs. "
115
+ f"Received shape {features.shape}."
116
+ )
117
+
118
+ if not np.issubdtype(
119
+ features.dtype,
120
+ np.number,
121
+ ):
122
+ try:
123
+ features = features.astype(
124
+ np.float32
125
+ )
126
+ except Exception as exc:
127
+ raise TypeError(
128
+ "XGBoost SHAP features must be numeric"
129
+ ) from exc
130
+
131
+ features = features.astype(
132
+ np.float32,
133
+ copy=False,
134
+ )
135
+
136
+ if np.isnan(features).any():
137
+ raise ValueError(
138
+ "XGBoost SHAP input contains NaN values "
139
+ f"in batch {batch_index}"
140
+ )
141
+
142
+ if np.isinf(features).any():
143
+ raise ValueError(
144
+ "XGBoost SHAP input contains infinite values "
145
+ f"in batch {batch_index}"
146
+ )
147
+
148
+ feature_batches.append(features)
149
+
150
+ if not feature_batches:
151
+ raise ValueError(
152
+ "Cannot calculate XGBoost SHAP using an empty "
153
+ "DataLoader"
154
+ )
155
+
156
+ collected_features = np.concatenate(
157
+ feature_batches,
158
+ axis=0,
159
+ )
160
+
161
+ if collected_features.ndim != 2:
162
+ raise ValueError(
163
+ "Unexpected collected XGBoost feature shape: "
164
+ f"{collected_features.shape}"
165
+ )
166
+
167
+ return collected_features
168
+
169
+ @staticmethod
170
+ def _sample_rows(
171
+ data: np.ndarray,
172
+ requested_size: int,
173
+ random_generator: np.random.Generator,
174
+ ) -> np.ndarray:
175
+ selected_size = min(
176
+ int(requested_size),
177
+ int(data.shape[0]),
178
+ )
179
+
180
+ indices = random_generator.choice(
181
+ data.shape[0],
182
+ size=selected_size,
183
+ replace=False,
184
+ )
185
+
186
+ return data[indices]
187
+
188
+ @staticmethod
189
+ def _normalize_shap_values(
190
+ raw_values: Any,
191
+ sample_count: int,
192
+ feature_count: int,
193
+ ) -> np.ndarray:
194
+ """
195
+ Normalize SHAP output to:
196
+
197
+ (number_of_samples, number_of_features)
198
+
199
+ This first implementation supports binary classification and
200
+ regression. Multiclass SHAP requires class-specific aggregation.
201
+ """
202
+
203
+ if hasattr(raw_values, "values"):
204
+ raw_values = raw_values.values
205
+
206
+ if isinstance(raw_values, list):
207
+ if len(raw_values) != 1:
208
+ raise ValueError(
209
+ "Multiclass XGBoost SHAP is not yet supported. "
210
+ "The returned SHAP result contains several "
211
+ "class-specific arrays."
212
+ )
213
+
214
+ raw_values = raw_values[0]
215
+
216
+ shap_values = np.asarray(raw_values)
217
+
218
+ # Possible binary/single-output shape:
219
+ # (samples, features, 1)
220
+ if (
221
+ shap_values.ndim == 3
222
+ and shap_values.shape[-1] == 1
223
+ ):
224
+ shap_values = shap_values[..., 0]
225
+
226
+ # Possible shape:
227
+ # (1, samples, features)
228
+ if (
229
+ shap_values.ndim == 3
230
+ and shap_values.shape[0] == 1
231
+ ):
232
+ shap_values = shap_values[0]
233
+
234
+ if shap_values.ndim == 3:
235
+ raise ValueError(
236
+ "Multiclass XGBoost SHAP is not yet supported. "
237
+ "Class-specific SHAP values were returned with "
238
+ f"shape {shap_values.shape}."
239
+ )
240
+
241
+ expected_shape = (
242
+ sample_count,
243
+ feature_count,
244
+ )
245
+
246
+ if shap_values.shape != expected_shape:
247
+ raise ValueError(
248
+ "Unexpected XGBoost SHAP output shape. "
249
+ f"Expected {expected_shape}, "
250
+ f"received {shap_values.shape}."
251
+ )
252
+
253
+ shap_values = shap_values.astype(
254
+ np.float64,
255
+ copy=False,
256
+ )
257
+
258
+ if np.isnan(shap_values).any():
259
+ raise ValueError(
260
+ "Calculated XGBoost SHAP values contain NaN values"
261
+ )
262
+
263
+ if np.isinf(shap_values).any():
264
+ raise ValueError(
265
+ "Calculated XGBoost SHAP values contain "
266
+ "infinite values"
267
+ )
268
+
269
+ return shap_values
270
+
271
+ def _create_explainer(
272
+ self,
273
+ booster: xgb.Booster,
274
+ background: np.ndarray,
275
+ ):
276
+ try:
277
+ import shap
278
+ except ImportError as exc:
279
+ raise ImportError(
280
+ "SHAP is enabled for XGBoost, but the shap "
281
+ "package is not installed."
282
+ ) from exc
283
+
284
+ if not isinstance(booster, xgb.Booster):
285
+ raise TypeError(
286
+ "LocalXGBoostSHAPExplainer expects an "
287
+ "xgboost.Booster"
288
+ )
289
+
290
+ try:
291
+ return shap.TreeExplainer(
292
+ model=booster,
293
+ data=background,
294
+ feature_perturbation="interventional",
295
+ model_output=self.model_output,
296
+ )
297
+
298
+ except Exception as exc:
299
+ # Some SHAP/XGBoost combinations may not support every
300
+ # TreeExplainer argument. Keep a conservative fallback.
301
+ logger.warning(
302
+ "Could not initialize TreeExplainer with the "
303
+ "configured background and model output. "
304
+ "Falling back to the default TreeExplainer: %s",
305
+ exc,
306
+ )
307
+
308
+ return shap.TreeExplainer(
309
+ model=booster,
310
+ )
311
+
312
+ @staticmethod
313
+ def _calculate_raw_values(
314
+ explainer,
315
+ explanation_data: np.ndarray,
316
+ ):
317
+ """
318
+ Support different SHAP versions.
319
+ """
320
+
321
+ try:
322
+ return explainer.shap_values(
323
+ explanation_data,
324
+ check_additivity=False,
325
+ )
326
+
327
+ except TypeError:
328
+ # Older SHAP versions may not accept
329
+ # check_additivity here.
330
+ return explainer.shap_values(
331
+ explanation_data
332
+ )
333
+
334
+ def calculate(
335
+ self,
336
+ booster: xgb.Booster,
337
+ loader: DataLoader,
338
+ ) -> Dict[str, Any]:
339
+ """
340
+ Calculate local XGBoost SHAP additive summaries.
341
+ """
342
+
343
+ features = self._collect_features(loader)
344
+
345
+ if len(features) < 2:
346
+ raise ValueError(
347
+ "At least two samples are required for "
348
+ "XGBoost SHAP"
349
+ )
350
+
351
+ random_generator = np.random.default_rng(
352
+ self.random_seed
353
+ )
354
+
355
+ background = self._sample_rows(
356
+ data=features,
357
+ requested_size=self.background_size,
358
+ random_generator=random_generator,
359
+ )
360
+
361
+ explanation_data = self._sample_rows(
362
+ data=features,
363
+ requested_size=self.explanation_size,
364
+ random_generator=random_generator,
365
+ )
366
+
367
+ logger.info(
368
+ "Calculating local XGBoost SHAP | "
369
+ "available=%d | background=%d | explained=%d | "
370
+ "model_output=%s",
371
+ len(features),
372
+ len(background),
373
+ len(explanation_data),
374
+ self.model_output,
375
+ )
376
+
377
+ explainer = self._create_explainer(
378
+ booster=booster,
379
+ background=background,
380
+ )
381
+
382
+ raw_values = self._calculate_raw_values(
383
+ explainer=explainer,
384
+ explanation_data=explanation_data,
385
+ )
386
+
387
+ shap_values = self._normalize_shap_values(
388
+ raw_values=raw_values,
389
+ sample_count=len(explanation_data),
390
+ feature_count=explanation_data.shape[1],
391
+ )
392
+
393
+ if self.clipping_value is not None:
394
+ shap_values = np.clip(
395
+ shap_values,
396
+ -self.clipping_value,
397
+ self.clipping_value,
398
+ )
399
+
400
+ result: Dict[str, Any] = {
401
+ "sample_count": int(
402
+ shap_values.shape[0]
403
+ ),
404
+
405
+ "abs_sum": np.sum(
406
+ np.abs(shap_values),
407
+ axis=0,
408
+ ),
409
+
410
+ "signed_sum": np.sum(
411
+ shap_values,
412
+ axis=0,
413
+ ),
414
+
415
+ "square_sum": np.sum(
416
+ np.square(shap_values),
417
+ axis=0,
418
+ ),
419
+
420
+ "positive_count": np.sum(
421
+ shap_values > 0,
422
+ axis=0,
423
+ ).astype(np.int64),
424
+
425
+ "negative_count": np.sum(
426
+ shap_values < 0,
427
+ axis=0,
428
+ ).astype(np.int64),
429
+
430
+ "zero_count": np.sum(
431
+ shap_values == 0,
432
+ axis=0,
433
+ ).astype(np.int64),
434
+ }
435
+
436
+ return result