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.
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/shap/__init__.py +5 -0
- medfl-2.0.10.dev5/MEDfl/LearningManager/shap/config.py +196 -0
- medfl-2.0.10.dev5/MEDfl/LearningManager/shap/xgboost_explainer.py +436 -0
- medfl-2.0.10.dev5/MEDfl/LearningManager/xgboost/server.py +746 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/PKG-INFO +1 -1
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/medfl.egg-info/PKG-INFO +1 -1
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/medfl.egg-info/SOURCES.txt +1 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/setup.py +1 -1
- medfl-2.0.10.dev4/MEDfl/LearningManager/shap/config.py +0 -103
- medfl-2.0.10.dev4/MEDfl/LearningManager/xgboost/server.py +0 -262
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/LICENSE +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/__init__.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/client.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/dynamicModal.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/federated_dataset.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/flpipeline.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/model.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/params.yaml +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/params_optimiser.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/plot.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/server.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/shap/aggregation.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/shap/explainer.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/strategy.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/utils.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/xgboost/__init__.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/xgboost/client.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/xgboost/config.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/xgboost/data.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/xgboost/metrics.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/xgboost/serialization.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/LearningManager/xgboost/strategy.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/__init__.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/database_connector.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/dataset.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/flsetup.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/net_helper.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/net_manager_queries.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/network.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/NetManager/node.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/__init__.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/__init__.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/client.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/model.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/server.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/strategy.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/xgboost/__init__.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/xgboost/client.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/xgboost/config.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/xgboost/data.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/xgboost/metrics.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/xgboost/serialization.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/rw/xgboost/strategy.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/scripts/__init__.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/scripts/base.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/MEDfl/scripts/create_db.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/README.md +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/alembic/__init__.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/alembic/env.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/medfl.egg-info/dependency_links.txt +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/medfl.egg-info/requires.txt +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/medfl.egg-info/top_level.txt +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/setup.cfg +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/tests/test_dataset.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/tests/test_federated_dataset.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/tests/test_flpipeline.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/tests/test_flsetup.py +0 -0
- {medfl-2.0.10.dev4 → medfl-2.0.10.dev5}/tests/test_network.py +0 -0
- {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
|