treeig 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- treeig/__init__.py +24 -0
- treeig/api.py +291 -0
- treeig/core.py +455 -0
- treeig/dispatch.py +93 -0
- treeig/lightgbm_backend.py +208 -0
- treeig/sklearn_backend.py +184 -0
- treeig/utils.py +74 -0
- treeig/xgboost_backend.py +302 -0
- treeig-0.1.1.dist-info/METADATA +389 -0
- treeig-0.1.1.dist-info/RECORD +13 -0
- treeig-0.1.1.dist-info/WHEEL +5 -0
- treeig-0.1.1.dist-info/licenses/LICENSE +11 -0
- treeig-0.1.1.dist-info/top_level.txt +1 -0
treeig/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .api import (
|
|
2
|
+
TreeIG,
|
|
3
|
+
compute,
|
|
4
|
+
exact_gb_ig_batch_fast,
|
|
5
|
+
extract_gb_tree_arrays,
|
|
6
|
+
timed_call,
|
|
7
|
+
warmup_exact_gb_ig,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"TreeIG",
|
|
12
|
+
"compute",
|
|
13
|
+
"exact_gb_ig_batch_fast",
|
|
14
|
+
"extract_gb_tree_arrays",
|
|
15
|
+
"timed_call",
|
|
16
|
+
"warmup_exact_gb_ig",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from importlib.metadata import version
|
|
21
|
+
except ImportError:
|
|
22
|
+
from importlib_metadata import version
|
|
23
|
+
|
|
24
|
+
__version__ = version("treeig")
|
treeig/api.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TreeIG: Exact Integrated Gradients for Tree Ensembles.
|
|
3
|
+
Currently supported backends
|
|
4
|
+
----------------------------
|
|
5
|
+
Regression:
|
|
6
|
+
sklearn.tree.DecisionTreeRegressor
|
|
7
|
+
sklearn.ensemble.RandomForestRegressor
|
|
8
|
+
sklearn.ensemble.ExtraTreesRegressor
|
|
9
|
+
sklearn.ensemble.GradientBoostingRegressor
|
|
10
|
+
xgboost.XGBRegressor
|
|
11
|
+
xgboost.Booster
|
|
12
|
+
lightgbm.LGBMRegressor
|
|
13
|
+
lightgbm.Booster
|
|
14
|
+
|
|
15
|
+
Classification, raw margins/logits only:
|
|
16
|
+
sklearn.ensemble.GradientBoostingClassifier
|
|
17
|
+
xgboost.XGBClassifier
|
|
18
|
+
lightgbm.LGBMClassifier
|
|
19
|
+
|
|
20
|
+
Classification target convention
|
|
21
|
+
--------------------------------
|
|
22
|
+
For regression models, target must be None or 0.
|
|
23
|
+
|
|
24
|
+
For additive-score classifiers, TreeIG attributes raw class scores rather
|
|
25
|
+
than probabilities, following the standard Integrated Gradients convention
|
|
26
|
+
for classification models.
|
|
27
|
+
|
|
28
|
+
binary classifiers:
|
|
29
|
+
target=None or target=1 attributes the positive-class margin.
|
|
30
|
+
target=0 attributes the negative margin, implemented as the
|
|
31
|
+
negative of the positive-class margin.
|
|
32
|
+
|
|
33
|
+
multiclass classifiers:
|
|
34
|
+
target must select the class-margin output.
|
|
35
|
+
|
|
36
|
+
Deliberately deferred
|
|
37
|
+
---------------------
|
|
38
|
+
TreeIG does not currently support probability-output attribution,
|
|
39
|
+
missing-value routing, categorical splits, CatBoost, or classifiers that
|
|
40
|
+
average probabilities or vote shares directly, such as
|
|
41
|
+
DecisionTreeClassifier, RandomForestClassifier, and ExtraTreesClassifier.
|
|
42
|
+
|
|
43
|
+
Scope
|
|
44
|
+
-----
|
|
45
|
+
Only finite numeric inputs and baselines are currently supported.
|
|
46
|
+
"""
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import time
|
|
50
|
+
from typing import Any, Dict, Optional, Tuple
|
|
51
|
+
|
|
52
|
+
import numpy as np
|
|
53
|
+
|
|
54
|
+
from .core import (
|
|
55
|
+
_baseline_cache_key,
|
|
56
|
+
_compute_attributions_with_y0,
|
|
57
|
+
_compute_core,
|
|
58
|
+
_compute_y0_per_tree,
|
|
59
|
+
)
|
|
60
|
+
from .dispatch import extract_tree_arrays, model_predict
|
|
61
|
+
from .utils import _as_float32_float64, _as_target_key, _check_finite_numeric
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TreeIG:
|
|
65
|
+
"""
|
|
66
|
+
Exact Integrated Gradients for tree-based regression and additive-score
|
|
67
|
+
classification models.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
model: Any,
|
|
73
|
+
baseline: Optional[np.ndarray] = None,
|
|
74
|
+
time_tol: float = 1e-10,
|
|
75
|
+
tie_policy: str = "first",
|
|
76
|
+
target: Optional[int] = None,
|
|
77
|
+
):
|
|
78
|
+
if tie_policy != "first":
|
|
79
|
+
raise NotImplementedError(
|
|
80
|
+
"Only tie_policy='first' is currently implemented in the fast "
|
|
81
|
+
"Numba path. The tie_policy argument is reserved for future "
|
|
82
|
+
"allocation rules for coincident active crossings."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
self.model = model
|
|
86
|
+
self.time_tol = float(time_tol)
|
|
87
|
+
self.tie_policy = tie_policy
|
|
88
|
+
self.target = _as_target_key(target)
|
|
89
|
+
self._arrays_by_target: Dict[Optional[int], Dict[str, Any]] = {}
|
|
90
|
+
self._y0_cache: Dict[Tuple[str, Optional[int], bytes], np.ndarray] = {}
|
|
91
|
+
|
|
92
|
+
arrays = extract_tree_arrays(model, self.target)
|
|
93
|
+
self._arrays = arrays
|
|
94
|
+
self._arrays_by_target[self.target] = arrays
|
|
95
|
+
self.n_features_in_ = int(arrays["n_features"])
|
|
96
|
+
self.backend = arrays.get("backend", "unknown")
|
|
97
|
+
|
|
98
|
+
if baseline is None:
|
|
99
|
+
self._baseline = None
|
|
100
|
+
else:
|
|
101
|
+
self._baseline = self._prepare_baseline(baseline)
|
|
102
|
+
|
|
103
|
+
def _prepare_baseline(self, b: np.ndarray) -> np.ndarray:
|
|
104
|
+
b = np.asarray(b, dtype=np.float64)
|
|
105
|
+
n = self.n_features_in_
|
|
106
|
+
|
|
107
|
+
if b.ndim != 1 or b.shape[0] != n:
|
|
108
|
+
raise ValueError(f"baseline must have shape ({n},), got {b.shape}.")
|
|
109
|
+
|
|
110
|
+
_check_finite_numeric(b, "baseline")
|
|
111
|
+
return _as_float32_float64(b)
|
|
112
|
+
|
|
113
|
+
def _prepare_X(self, X: np.ndarray) -> np.ndarray:
|
|
114
|
+
X = np.asarray(X, dtype=np.float64)
|
|
115
|
+
n = self.n_features_in_
|
|
116
|
+
|
|
117
|
+
if X.ndim != 2:
|
|
118
|
+
raise ValueError(f"X must be 2-D, got shape {X.shape}.")
|
|
119
|
+
|
|
120
|
+
if X.shape[1] != n:
|
|
121
|
+
raise ValueError(f"X has {X.shape[1]} features; model expects {n}.")
|
|
122
|
+
|
|
123
|
+
_check_finite_numeric(X, "X")
|
|
124
|
+
return _as_float32_float64(X)
|
|
125
|
+
|
|
126
|
+
def _resolve_baseline(self, baseline: Optional[np.ndarray]) -> np.ndarray:
|
|
127
|
+
if baseline is not None:
|
|
128
|
+
return self._prepare_baseline(baseline)
|
|
129
|
+
|
|
130
|
+
if self._baseline is not None:
|
|
131
|
+
return self._baseline
|
|
132
|
+
|
|
133
|
+
raise ValueError(
|
|
134
|
+
"A baseline is required. Pass baseline= to this method, or set a "
|
|
135
|
+
"default at construction: TreeIG(model, baseline=x0)."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _resolve_arrays_for_target(self, target: Optional[int]) -> Dict[str, Any]:
|
|
139
|
+
target_key = self.target if target is None else _as_target_key(target)
|
|
140
|
+
|
|
141
|
+
if target_key in self._arrays_by_target:
|
|
142
|
+
return self._arrays_by_target[target_key]
|
|
143
|
+
|
|
144
|
+
arrays = extract_tree_arrays(self.model, target_key)
|
|
145
|
+
self._arrays_by_target[target_key] = arrays
|
|
146
|
+
return arrays
|
|
147
|
+
|
|
148
|
+
def _get_y0_per_tree(self, arrays: Dict[str, Any], baseline: np.ndarray) -> np.ndarray:
|
|
149
|
+
key = _baseline_cache_key(arrays, baseline)
|
|
150
|
+
cached = self._y0_cache.get(key)
|
|
151
|
+
if cached is not None:
|
|
152
|
+
return cached
|
|
153
|
+
|
|
154
|
+
y0 = _compute_y0_per_tree(arrays, baseline)
|
|
155
|
+
self._y0_cache[key] = y0
|
|
156
|
+
return y0
|
|
157
|
+
|
|
158
|
+
def attribute(
|
|
159
|
+
self,
|
|
160
|
+
X: np.ndarray,
|
|
161
|
+
baseline: Optional[np.ndarray] = None,
|
|
162
|
+
target: Optional[int] = None,
|
|
163
|
+
) -> np.ndarray:
|
|
164
|
+
"""
|
|
165
|
+
Compute feature attributions.
|
|
166
|
+
|
|
167
|
+
This is the fastest public path: it does not compute residual
|
|
168
|
+
diagnostics or call model.predict().
|
|
169
|
+
"""
|
|
170
|
+
b = self._resolve_baseline(baseline)
|
|
171
|
+
X_prep = self._prepare_X(X)
|
|
172
|
+
arrays = self._resolve_arrays_for_target(target)
|
|
173
|
+
y0 = self._get_y0_per_tree(arrays, b)
|
|
174
|
+
|
|
175
|
+
phis, _ = _compute_attributions_with_y0(arrays, b, X_prep, self.time_tol, y0)
|
|
176
|
+
return phis
|
|
177
|
+
|
|
178
|
+
def explain(
|
|
179
|
+
self,
|
|
180
|
+
X: np.ndarray,
|
|
181
|
+
baseline: Optional[np.ndarray] = None,
|
|
182
|
+
target: Optional[int] = None,
|
|
183
|
+
):
|
|
184
|
+
"""Compute attributions with per-observation diagnostics."""
|
|
185
|
+
b = self._resolve_baseline(baseline)
|
|
186
|
+
X_prep = self._prepare_X(X)
|
|
187
|
+
arrays = self._resolve_arrays_for_target(target)
|
|
188
|
+
y0 = self._get_y0_per_tree(arrays, b)
|
|
189
|
+
|
|
190
|
+
resolved_target = arrays.get("target", None)
|
|
191
|
+
endpoint_delta = model_predict(self.model, X_prep, resolved_target) - model_predict(
|
|
192
|
+
self.model, b.reshape(1, -1), resolved_target
|
|
193
|
+
)[0]
|
|
194
|
+
|
|
195
|
+
return _compute_core(arrays, b, X_prep, self.time_tol, y0, endpoint_delta)
|
|
196
|
+
|
|
197
|
+
def warmup(
|
|
198
|
+
self,
|
|
199
|
+
X: np.ndarray,
|
|
200
|
+
baseline: Optional[np.ndarray] = None,
|
|
201
|
+
target: Optional[int] = None,
|
|
202
|
+
):
|
|
203
|
+
"""Trigger Numba JIT compilation on a small sample and cache y0."""
|
|
204
|
+
b = self._resolve_baseline(baseline)
|
|
205
|
+
X_prep = self._prepare_X(X)
|
|
206
|
+
arrays = self._resolve_arrays_for_target(target)
|
|
207
|
+
y0 = self._get_y0_per_tree(arrays, b)
|
|
208
|
+
|
|
209
|
+
_compute_attributions_with_y0(
|
|
210
|
+
arrays,
|
|
211
|
+
b,
|
|
212
|
+
X_prep[: min(2, len(X_prep))],
|
|
213
|
+
self.time_tol,
|
|
214
|
+
y0,
|
|
215
|
+
)
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def compute(
|
|
220
|
+
model: Any,
|
|
221
|
+
baseline: np.ndarray,
|
|
222
|
+
X: np.ndarray,
|
|
223
|
+
time_tol: float = 1e-10,
|
|
224
|
+
tie_policy: str = "first",
|
|
225
|
+
target: Optional[int] = None,
|
|
226
|
+
):
|
|
227
|
+
"""Functional interface to TreeIG; returns phis, infos, summary."""
|
|
228
|
+
return TreeIG(
|
|
229
|
+
model,
|
|
230
|
+
baseline=baseline,
|
|
231
|
+
time_tol=time_tol,
|
|
232
|
+
tie_policy=tie_policy,
|
|
233
|
+
target=target,
|
|
234
|
+
).explain(X)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def timed_call(fn, *args, **kwargs):
|
|
238
|
+
"""Return (result, elapsed_seconds) for any callable."""
|
|
239
|
+
t0 = time.perf_counter()
|
|
240
|
+
out = fn(*args, **kwargs)
|
|
241
|
+
return out, time.perf_counter() - t0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def exact_gb_ig_batch_fast(
|
|
245
|
+
model: Any,
|
|
246
|
+
x0: np.ndarray,
|
|
247
|
+
X: np.ndarray,
|
|
248
|
+
tol: float = 1e-10,
|
|
249
|
+
boundary_tol: float = 1e-8,
|
|
250
|
+
tie_policy: str = "first",
|
|
251
|
+
target: Optional[int] = None,
|
|
252
|
+
):
|
|
253
|
+
"""Backward-compatible alias for compute(); boundary_tol is ignored."""
|
|
254
|
+
return compute(
|
|
255
|
+
model,
|
|
256
|
+
x0,
|
|
257
|
+
X,
|
|
258
|
+
time_tol=tol,
|
|
259
|
+
tie_policy=tie_policy,
|
|
260
|
+
target=target,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def warmup_exact_gb_ig(
|
|
265
|
+
model: Any,
|
|
266
|
+
x0: np.ndarray,
|
|
267
|
+
X: np.ndarray,
|
|
268
|
+
tol: float = 1e-10,
|
|
269
|
+
boundary_tol: float = 1e-8,
|
|
270
|
+
tie_policy: str = "first",
|
|
271
|
+
target: Optional[int] = None,
|
|
272
|
+
):
|
|
273
|
+
"""
|
|
274
|
+
Backward-compatible warmup alias.
|
|
275
|
+
|
|
276
|
+
Returns the same object shape as the old helper: phis, infos, summary for
|
|
277
|
+
up to the first two observations. boundary_tol is ignored.
|
|
278
|
+
"""
|
|
279
|
+
X_arr = np.asarray(X)
|
|
280
|
+
n = min(2, X_arr.shape[0])
|
|
281
|
+
return compute(
|
|
282
|
+
model,
|
|
283
|
+
x0,
|
|
284
|
+
X_arr[:n],
|
|
285
|
+
time_tol=tol,
|
|
286
|
+
tie_policy=tie_policy,
|
|
287
|
+
target=target,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
extract_gb_tree_arrays = extract_tree_arrays
|