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 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