zoneboost 0.37.0__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.
- zoneboost/__init__.py +36 -0
- zoneboost/_bootstrap.py +336 -0
- zoneboost/_common.py +206 -0
- zoneboost/_conformal.py +198 -0
- zoneboost/_drift.py +168 -0
- zoneboost/_evidence_card.py +204 -0
- zoneboost/_explain.py +116 -0
- zoneboost/_purify.py +151 -0
- zoneboost/_reliability.py +255 -0
- zoneboost/_shrinkage.py +81 -0
- zoneboost/_sql_export.py +299 -0
- zoneboost/_survival.py +305 -0
- zoneboost/_version.py +1 -0
- zoneboost/_weak_learner.py +1906 -0
- zoneboost/_zones.py +313 -0
- zoneboost/classifier.py +1116 -0
- zoneboost/regressor.py +1833 -0
- zoneboost-0.37.0.dist-info/METADATA +1903 -0
- zoneboost-0.37.0.dist-info/RECORD +21 -0
- zoneboost-0.37.0.dist-info/WHEEL +4 -0
- zoneboost-0.37.0.dist-info/licenses/LICENSE +21 -0
zoneboost/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""zoneboost -- fully transparent, zone-based gradient boosting.
|
|
2
|
+
|
|
3
|
+
No decision trees, no gradient descent, no neural weights. Every number in
|
|
4
|
+
a prediction traces back to a quantile, a group count, or a group average.
|
|
5
|
+
|
|
6
|
+
>>> from zoneboost import ZoneBoostRegressor
|
|
7
|
+
>>> model = ZoneBoostRegressor().fit(X_train, y_train)
|
|
8
|
+
>>> model.predict(X_test)
|
|
9
|
+
|
|
10
|
+
>>> from zoneboost import ZoneBoostClassifier
|
|
11
|
+
>>> model = ZoneBoostClassifier().fit(X_train, y_train)
|
|
12
|
+
>>> model.predict_proba(X_test)
|
|
13
|
+
|
|
14
|
+
See :class:`ZoneBoostRegressor` / :class:`ZoneBoostClassifier` for the full
|
|
15
|
+
parameter and attribute reference.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from ._bootstrap import BootstrapStability
|
|
19
|
+
from ._conformal import ConformalizedQuantileRegressor
|
|
20
|
+
from ._drift import compare_models
|
|
21
|
+
from ._sql_export import compile_to_sql
|
|
22
|
+
from ._survival import ZoneBoostSurvival
|
|
23
|
+
from ._version import __version__
|
|
24
|
+
from .classifier import ZoneBoostClassifier
|
|
25
|
+
from .regressor import ZoneBoostRegressor
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ZoneBoostRegressor",
|
|
29
|
+
"ZoneBoostClassifier",
|
|
30
|
+
"ConformalizedQuantileRegressor",
|
|
31
|
+
"BootstrapStability",
|
|
32
|
+
"ZoneBoostSurvival",
|
|
33
|
+
"compare_models",
|
|
34
|
+
"compile_to_sql",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
zoneboost/_bootstrap.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Bootstrap stability: refit the whole model on resampled data to answer
|
|
2
|
+
"if I refit on a different sample from the same population, how much would
|
|
3
|
+
this contribution, this term's overall importance, or this prediction
|
|
4
|
+
actually change?" -- genuine resampling-based uncertainty, distinct from
|
|
5
|
+
:mod:`zoneboost._reliability`'s single-fit diagnostics (which report how
|
|
6
|
+
much a *given* fit's own contribution should be trusted, not how much it
|
|
7
|
+
would vary across refits).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
from sklearn.base import BaseEstimator, clone
|
|
15
|
+
from sklearn.utils.validation import check_is_fitted
|
|
16
|
+
|
|
17
|
+
from ._common import ensure_dataframe
|
|
18
|
+
from .regressor import ZoneBoostRegressor
|
|
19
|
+
|
|
20
|
+
__all__ = ["BootstrapStability"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BootstrapStability(BaseEstimator):
|
|
24
|
+
"""Bootstrap-based stability and uncertainty report for any
|
|
25
|
+
``ZoneBoostRegressor``/``ZoneBoostClassifier`` configuration: refits
|
|
26
|
+
``estimator`` on ``n_bootstrap`` independent bootstrap resamples (rows
|
|
27
|
+
drawn with replacement, same size as the original training set -- the
|
|
28
|
+
standard nonparametric bootstrap), then reports how much a term's
|
|
29
|
+
contribution, a term's overall importance, whether a term appears at
|
|
30
|
+
all, or a prediction itself varies across those refits.
|
|
31
|
+
|
|
32
|
+
Real, disclosed cost: ``n_bootstrap`` full model refits -- this is a
|
|
33
|
+
separate wrapper you opt into, not a `ZoneBoostRegressor`/
|
|
34
|
+
`ZoneBoostClassifier` parameter, exactly like
|
|
35
|
+
:class:`zoneboost.ConformalizedQuantileRegressor`.
|
|
36
|
+
|
|
37
|
+
Works for both estimators (unlike ``ConformalizedQuantileRegressor``,
|
|
38
|
+
which is regressor-only because quantile mode is): bootstrapping the
|
|
39
|
+
whole fit procedure has no such restriction. The two point-valued
|
|
40
|
+
methods (:meth:`predict_confidence_interval`, :meth:`predict_diff_interval`)
|
|
41
|
+
support regressors and *binary* classifiers (via
|
|
42
|
+
``predict_proba(X)[:, 1]``) -- a multiclass model has no single scalar
|
|
43
|
+
per row to bootstrap there, so those two methods raise ``ValueError``;
|
|
44
|
+
:meth:`contribution_interval`/:meth:`feature_importance_interval`/
|
|
45
|
+
:meth:`inclusion_frequency` fully support multiclass.
|
|
46
|
+
|
|
47
|
+
**Deferred**: boundary-position uncertainty (how much zone cut points
|
|
48
|
+
themselves move across bootstrap fits) -- different bootstrap fits can
|
|
49
|
+
produce a different *number* of zones for the same column, so there's
|
|
50
|
+
no clean 1:1 alignment to summarize without real additional machinery.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
estimator : ZoneBoostRegressor or ZoneBoostClassifier, default=None
|
|
55
|
+
An unfit template. ``None`` (default) uses a plain
|
|
56
|
+
``ZoneBoostRegressor()``. Cloned and refit once per bootstrap
|
|
57
|
+
resample; only ``random_state`` is overridden per clone (a fresh
|
|
58
|
+
seed per resample) -- every other parameter is respected as-is.
|
|
59
|
+
n_bootstrap : int, default=30
|
|
60
|
+
Number of bootstrap refits. Kept modest by default since this is
|
|
61
|
+
``n_bootstrap`` full model fits, not a free diagnostic -- raise it
|
|
62
|
+
for smoother interval estimates at proportionally higher cost.
|
|
63
|
+
alpha : float, default=0.1
|
|
64
|
+
Default miscoverage rate for every interval method below (e.g.
|
|
65
|
+
``0.1`` reports a 90% bootstrap interval); each method also accepts
|
|
66
|
+
its own ``alpha`` override.
|
|
67
|
+
random_state : int, default=42
|
|
68
|
+
Seed for the bootstrap resampling; each resample's cloned estimator
|
|
69
|
+
gets its own derived seed, so results are fully reproducible.
|
|
70
|
+
|
|
71
|
+
Attributes
|
|
72
|
+
----------
|
|
73
|
+
bootstrap_models_ : list
|
|
74
|
+
The ``n_bootstrap`` fitted clones, in resampling order.
|
|
75
|
+
|
|
76
|
+
Examples
|
|
77
|
+
--------
|
|
78
|
+
>>> import pandas as pd
|
|
79
|
+
>>> from zoneboost import BootstrapStability, ZoneBoostRegressor
|
|
80
|
+
>>> X = pd.DataFrame({"x": [1, 2, 3, 4, 5, 6, 7, 8]})
|
|
81
|
+
>>> y = [1.0, 2.1, 2.9, 4.2, 4.8, 6.1, 6.9, 8.3]
|
|
82
|
+
>>> model = BootstrapStability(ZoneBoostRegressor(n_rounds=20), n_bootstrap=10, random_state=0).fit(X, y)
|
|
83
|
+
>>> model.inclusion_frequency() # doctest: +SKIP
|
|
84
|
+
>>> lower, upper = model.predict_confidence_interval(X)
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
estimator=None,
|
|
90
|
+
n_bootstrap: int = 30,
|
|
91
|
+
alpha: float = 0.1,
|
|
92
|
+
random_state: int = 42,
|
|
93
|
+
):
|
|
94
|
+
self.estimator = estimator
|
|
95
|
+
self.n_bootstrap = n_bootstrap
|
|
96
|
+
self.alpha = alpha
|
|
97
|
+
self.random_state = random_state
|
|
98
|
+
|
|
99
|
+
def fit(self, X, y):
|
|
100
|
+
"""Fit ``n_bootstrap`` independent bootstrap resamples.
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
X : DataFrame or array-like of shape (n_samples, n_features)
|
|
105
|
+
y : array-like of shape (n_samples,)
|
|
106
|
+
|
|
107
|
+
Returns
|
|
108
|
+
-------
|
|
109
|
+
self : BootstrapStability
|
|
110
|
+
"""
|
|
111
|
+
if not 0 < self.alpha < 1:
|
|
112
|
+
raise ValueError(f"alpha must be in (0, 1), got {self.alpha!r}")
|
|
113
|
+
if self.n_bootstrap < 2:
|
|
114
|
+
raise ValueError(f"n_bootstrap must be >= 2, got {self.n_bootstrap!r}")
|
|
115
|
+
|
|
116
|
+
X = ensure_dataframe(X, getattr(self, "feature_names_in_", None))
|
|
117
|
+
y_arr = np.asarray(y).reshape(-1)
|
|
118
|
+
if len(X) != len(y_arr):
|
|
119
|
+
raise ValueError(f"X and y have inconsistent lengths: {len(X)} vs {len(y_arr)}")
|
|
120
|
+
self.feature_names_in_ = np.array(X.columns)
|
|
121
|
+
|
|
122
|
+
base = self.estimator if self.estimator is not None else ZoneBoostRegressor()
|
|
123
|
+
rng = np.random.default_rng(self.random_state)
|
|
124
|
+
n = len(X)
|
|
125
|
+
self.bootstrap_models_ = []
|
|
126
|
+
for _ in range(self.n_bootstrap):
|
|
127
|
+
idx = rng.integers(0, n, size=n)
|
|
128
|
+
seed = int(rng.integers(0, 2**31 - 1))
|
|
129
|
+
model_b = clone(base).set_params(random_state=seed)
|
|
130
|
+
model_b.fit(X.iloc[idx].reset_index(drop=True), y_arr[idx])
|
|
131
|
+
self.bootstrap_models_.append(model_b)
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def _term_set(self, model) -> set:
|
|
135
|
+
"""Every term (main effect, ``"A x B"`` pair, ``"A x B x C"``
|
|
136
|
+
triple) that appears anywhere in ``model``'s fitted rounds --
|
|
137
|
+
flattened across classes for a multiclass classifier, matching
|
|
138
|
+
``feature_importance``'s own averaging-across-classes precedent."""
|
|
139
|
+
if hasattr(model, "multiclass_"):
|
|
140
|
+
if not model.multiclass_:
|
|
141
|
+
rounds = model.booster_.rounds_
|
|
142
|
+
else:
|
|
143
|
+
rounds = [
|
|
144
|
+
round_dict
|
|
145
|
+
for round_tables in model.softmax_booster_.rounds_
|
|
146
|
+
for round_dict in round_tables.values()
|
|
147
|
+
]
|
|
148
|
+
else:
|
|
149
|
+
rounds = model.rounds_
|
|
150
|
+
|
|
151
|
+
terms = set()
|
|
152
|
+
for r in rounds:
|
|
153
|
+
terms.update(r["main_effects"].keys())
|
|
154
|
+
terms.update(" x ".join(sorted(key)) for key in r["interactions"])
|
|
155
|
+
terms.update(" x ".join(sorted(key)) for key in r["triples"])
|
|
156
|
+
return terms
|
|
157
|
+
|
|
158
|
+
def inclusion_frequency(self) -> pd.Series:
|
|
159
|
+
"""Fraction of ``n_bootstrap`` fits in which each term appeared at
|
|
160
|
+
all -- the "does this term show up when the model is refit on
|
|
161
|
+
resampled data" stability signal. Only terms that appeared in at
|
|
162
|
+
least one bootstrap fit are listed (an always-absent term simply
|
|
163
|
+
never surfaces, rather than being reported as `0`).
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
Series
|
|
168
|
+
Indexed by term name, sorted descending.
|
|
169
|
+
"""
|
|
170
|
+
check_is_fitted(self, "bootstrap_models_")
|
|
171
|
+
counts: dict = {}
|
|
172
|
+
for model in self.bootstrap_models_:
|
|
173
|
+
for term in self._term_set(model):
|
|
174
|
+
counts[term] = counts.get(term, 0) + 1
|
|
175
|
+
return pd.Series(counts, dtype=float).sort_values(ascending=False) / len(self.bootstrap_models_)
|
|
176
|
+
|
|
177
|
+
def _term_interval_from_explanations(self, X: pd.DataFrame, explanations: list, alpha: float) -> dict:
|
|
178
|
+
all_terms: set = set()
|
|
179
|
+
for df in explanations:
|
|
180
|
+
all_terms.update(c for c in df.columns if c not in ("baseline", "_softmax_centering"))
|
|
181
|
+
|
|
182
|
+
result = {}
|
|
183
|
+
n = len(X)
|
|
184
|
+
for term in all_terms:
|
|
185
|
+
stacked = np.zeros((len(explanations), n))
|
|
186
|
+
for i, df in enumerate(explanations):
|
|
187
|
+
if term in df.columns:
|
|
188
|
+
stacked[i] = df[term].to_numpy()
|
|
189
|
+
lower = np.percentile(stacked, 100 * alpha / 2, axis=0)
|
|
190
|
+
upper = np.percentile(stacked, 100 * (1 - alpha / 2), axis=0)
|
|
191
|
+
result[term] = pd.DataFrame({"lower": lower, "upper": upper}, index=X.index)
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
def contribution_interval(self, X, alpha: float = None) -> dict:
|
|
195
|
+
"""Per-term, per-row bootstrap contribution interval: refits
|
|
196
|
+
``explain(X)`` on every bootstrap model, takes the union of term
|
|
197
|
+
names across all fits (a term absent from a given fit contributes
|
|
198
|
+
exactly `0` for every row that fit -- it was never selected, not
|
|
199
|
+
merely small), and returns the ``alpha/2``/``1 - alpha/2``
|
|
200
|
+
percentile of that term's bootstrap contribution distribution.
|
|
201
|
+
|
|
202
|
+
Parameters
|
|
203
|
+
----------
|
|
204
|
+
X : DataFrame or array-like of shape (n_samples, n_features)
|
|
205
|
+
alpha : float, default=None
|
|
206
|
+
Overrides the constructor's own ``alpha`` when given.
|
|
207
|
+
|
|
208
|
+
Returns
|
|
209
|
+
-------
|
|
210
|
+
dict of {term: DataFrame} with columns ``lower``/``upper``, or
|
|
211
|
+
{class_label: {term: DataFrame}} for a multiclass classifier
|
|
212
|
+
(mirroring :func:`zoneboost._reliability.explain_reliability`'s own
|
|
213
|
+
per-class nesting).
|
|
214
|
+
"""
|
|
215
|
+
check_is_fitted(self, "bootstrap_models_")
|
|
216
|
+
alpha = alpha if alpha is not None else self.alpha
|
|
217
|
+
X = ensure_dataframe(X, self.feature_names_in_)
|
|
218
|
+
|
|
219
|
+
explanations = [model.explain(X) for model in self.bootstrap_models_]
|
|
220
|
+
if isinstance(explanations[0], dict):
|
|
221
|
+
classes = list(explanations[0].keys())
|
|
222
|
+
return {
|
|
223
|
+
k: self._term_interval_from_explanations(X, [e[k] for e in explanations], alpha) for k in classes
|
|
224
|
+
}
|
|
225
|
+
return self._term_interval_from_explanations(X, explanations, alpha)
|
|
226
|
+
|
|
227
|
+
def feature_importance_interval(self, X, alpha: float = None) -> pd.DataFrame:
|
|
228
|
+
"""Per-term bootstrap interval on ``feature_importance(X)`` --
|
|
229
|
+
always a flat table, even for a multiclass classifier, since
|
|
230
|
+
``feature_importance`` itself already averages across classes.
|
|
231
|
+
|
|
232
|
+
Parameters
|
|
233
|
+
----------
|
|
234
|
+
X : DataFrame or array-like of shape (n_samples, n_features)
|
|
235
|
+
alpha : float, default=None
|
|
236
|
+
Overrides the constructor's own ``alpha`` when given.
|
|
237
|
+
|
|
238
|
+
Returns
|
|
239
|
+
-------
|
|
240
|
+
DataFrame
|
|
241
|
+
Indexed by term name, columns ``lower``/``upper``, sorted by
|
|
242
|
+
``upper`` descending.
|
|
243
|
+
"""
|
|
244
|
+
check_is_fitted(self, "bootstrap_models_")
|
|
245
|
+
alpha = alpha if alpha is not None else self.alpha
|
|
246
|
+
X = ensure_dataframe(X, self.feature_names_in_)
|
|
247
|
+
|
|
248
|
+
importances = [model.feature_importance(X) for model in self.bootstrap_models_]
|
|
249
|
+
all_terms: set = set()
|
|
250
|
+
for s in importances:
|
|
251
|
+
all_terms.update(s.index)
|
|
252
|
+
|
|
253
|
+
rows = []
|
|
254
|
+
for term in all_terms:
|
|
255
|
+
values = np.array([float(s.get(term, 0.0)) for s in importances])
|
|
256
|
+
lower = np.percentile(values, 100 * alpha / 2)
|
|
257
|
+
upper = np.percentile(values, 100 * (1 - alpha / 2))
|
|
258
|
+
rows.append((term, lower, upper))
|
|
259
|
+
result = pd.DataFrame(rows, columns=["term", "lower", "upper"]).set_index("term")
|
|
260
|
+
return result.sort_values("upper", ascending=False)
|
|
261
|
+
|
|
262
|
+
def _point_predict(self, model, X) -> np.ndarray:
|
|
263
|
+
if hasattr(model, "predict_proba"):
|
|
264
|
+
proba = model.predict_proba(X)
|
|
265
|
+
if proba.shape[1] != 2:
|
|
266
|
+
raise ValueError(
|
|
267
|
+
"predict_confidence_interval/predict_diff_interval only support "
|
|
268
|
+
"regressors or binary classifiers (a single scalar per row) -- a "
|
|
269
|
+
"multiclass model has no single value to bootstrap here; use "
|
|
270
|
+
"contribution_interval/feature_importance_interval/inclusion_frequency "
|
|
271
|
+
"instead."
|
|
272
|
+
)
|
|
273
|
+
return proba[:, 1]
|
|
274
|
+
return model.predict(X)
|
|
275
|
+
|
|
276
|
+
def predict_confidence_interval(self, X, alpha: float = None) -> tuple:
|
|
277
|
+
"""Bootstrap confidence interval of the point prediction --
|
|
278
|
+
genuinely different from ``ZoneBoostRegressor.predict_interval``/
|
|
279
|
+
``ConformalizedQuantileRegressor.predict_interval``: those give a
|
|
280
|
+
distribution-free *coverage* guarantee for a future observation of
|
|
281
|
+
`y`; this reports model/estimation uncertainty from resampling
|
|
282
|
+
(how much the fitted function itself would move under a different
|
|
283
|
+
sample) -- not the same statement, and not a substitute for either.
|
|
284
|
+
|
|
285
|
+
Parameters
|
|
286
|
+
----------
|
|
287
|
+
X : DataFrame or array-like of shape (n_samples, n_features)
|
|
288
|
+
alpha : float, default=None
|
|
289
|
+
Overrides the constructor's own ``alpha`` when given.
|
|
290
|
+
|
|
291
|
+
Returns
|
|
292
|
+
-------
|
|
293
|
+
lower, upper : ndarray of shape (n_samples,)
|
|
294
|
+
"""
|
|
295
|
+
check_is_fitted(self, "bootstrap_models_")
|
|
296
|
+
alpha = alpha if alpha is not None else self.alpha
|
|
297
|
+
X = ensure_dataframe(X, self.feature_names_in_)
|
|
298
|
+
preds = np.stack([self._point_predict(model, X) for model in self.bootstrap_models_])
|
|
299
|
+
lower = np.percentile(preds, 100 * alpha / 2, axis=0)
|
|
300
|
+
upper = np.percentile(preds, 100 * (1 - alpha / 2), axis=0)
|
|
301
|
+
return lower, upper
|
|
302
|
+
|
|
303
|
+
def predict_diff_interval(self, X_a, X_b, alpha: float = None) -> tuple:
|
|
304
|
+
"""Bootstrap interval of the row-wise difference in point
|
|
305
|
+
predictions between two (possibly single-row) inputs -- answers
|
|
306
|
+
"is this pair's predicted difference actually distinguishable
|
|
307
|
+
given resampling uncertainty," with whether the interval excludes
|
|
308
|
+
`0` as the natural read.
|
|
309
|
+
|
|
310
|
+
Parameters
|
|
311
|
+
----------
|
|
312
|
+
X_a, X_b : DataFrame or array-like of shape (n_samples, n_features)
|
|
313
|
+
Same number of rows.
|
|
314
|
+
alpha : float, default=None
|
|
315
|
+
Overrides the constructor's own ``alpha`` when given.
|
|
316
|
+
|
|
317
|
+
Returns
|
|
318
|
+
-------
|
|
319
|
+
lower, upper : ndarray of shape (n_samples,)
|
|
320
|
+
"""
|
|
321
|
+
check_is_fitted(self, "bootstrap_models_")
|
|
322
|
+
alpha = alpha if alpha is not None else self.alpha
|
|
323
|
+
X_a = ensure_dataframe(X_a, self.feature_names_in_)
|
|
324
|
+
X_b = ensure_dataframe(X_b, self.feature_names_in_)
|
|
325
|
+
if len(X_a) != len(X_b):
|
|
326
|
+
raise ValueError(f"X_a and X_b must have the same number of rows, got {len(X_a)} and {len(X_b)}")
|
|
327
|
+
|
|
328
|
+
diffs = np.stack(
|
|
329
|
+
[
|
|
330
|
+
self._point_predict(model, X_a) - self._point_predict(model, X_b)
|
|
331
|
+
for model in self.bootstrap_models_
|
|
332
|
+
]
|
|
333
|
+
)
|
|
334
|
+
lower = np.percentile(diffs, 100 * alpha / 2, axis=0)
|
|
335
|
+
upper = np.percentile(diffs, 100 * (1 - alpha / 2), axis=0)
|
|
336
|
+
return lower, upper
|
zoneboost/_common.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Shared input handling used by both ZoneBoostRegressor and
|
|
2
|
+
ZoneBoostClassifier -- kept in one place so the two estimators can't drift
|
|
3
|
+
apart on how they interpret X or auto-detect categorical columns."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from sklearn.metrics import mean_gamma_deviance, mean_poisson_deviance, mean_tweedie_deviance
|
|
10
|
+
from sklearn.utils.validation import check_array
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ensure_dataframe",
|
|
14
|
+
"resolve_categorical_features",
|
|
15
|
+
"resolve_monotonic_constraints",
|
|
16
|
+
"resolve_bounded_effects",
|
|
17
|
+
"resolve_forbidden_interactions",
|
|
18
|
+
"resolve_group_col",
|
|
19
|
+
"_glm_residual",
|
|
20
|
+
"_glm_inverse_link",
|
|
21
|
+
"_glm_baseline",
|
|
22
|
+
"_glm_deviance_score",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
_GLM_POWERS = {"poisson": 1.0, "gamma": 2.0}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ensure_dataframe(X, feature_names=None) -> pd.DataFrame:
|
|
29
|
+
if isinstance(X, pd.DataFrame):
|
|
30
|
+
return X.reset_index(drop=True)
|
|
31
|
+
X = check_array(X, dtype=None, ensure_all_finite=False)
|
|
32
|
+
columns = feature_names if feature_names is not None and len(feature_names) == X.shape[1] else None
|
|
33
|
+
if columns is None:
|
|
34
|
+
columns = [f"x{i}" for i in range(X.shape[1])]
|
|
35
|
+
return pd.DataFrame(X, columns=columns)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_categorical_features(X: pd.DataFrame, declared) -> set:
|
|
39
|
+
# is_numeric_dtype (rather than listing dtype names) also catches
|
|
40
|
+
# pandas' newer arrow-backed / nullable string dtypes, not just legacy
|
|
41
|
+
# numpy object dtype.
|
|
42
|
+
auto_detected = {
|
|
43
|
+
c for c in X.columns if pd.api.types.is_bool_dtype(X[c]) or not pd.api.types.is_numeric_dtype(X[c])
|
|
44
|
+
}
|
|
45
|
+
declared_set = set()
|
|
46
|
+
if declared:
|
|
47
|
+
for f in declared:
|
|
48
|
+
declared_set.add(X.columns[f] if isinstance(f, (int, np.integer)) else f)
|
|
49
|
+
return auto_detected | declared_set
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_monotonic_constraints(X: pd.DataFrame, declared, categorical_features: set) -> dict:
|
|
53
|
+
"""Normalize a user-declared ``{column_name_or_index: +1 or -1}`` dict
|
|
54
|
+
to ``{column_name: direction}``, the same name/index convention
|
|
55
|
+
``resolve_categorical_features`` uses.
|
|
56
|
+
|
|
57
|
+
A constraint declared on a categorical column is silently dropped
|
|
58
|
+
rather than raising: there's no meaningful order to constrain for a
|
|
59
|
+
nominal category, and the rest of the library prefers graceful
|
|
60
|
+
degradation over crashing on this kind of ambiguous input (the same
|
|
61
|
+
treatment an unseen category or a missing value gets elsewhere).
|
|
62
|
+
An invalid direction (anything other than -1 or 1) does raise --
|
|
63
|
+
unlike a stray categorical key, that's simply a usage mistake with no
|
|
64
|
+
sensible silent interpretation.
|
|
65
|
+
|
|
66
|
+
Also reused as-is for ``convexity_constraints`` (``{column: +1 convex,
|
|
67
|
+
-1 concave}``) -- identical shape and validation, this function doesn't
|
|
68
|
+
care what the direction semantically means.
|
|
69
|
+
"""
|
|
70
|
+
if not declared:
|
|
71
|
+
return {}
|
|
72
|
+
resolved = {}
|
|
73
|
+
for f, direction in declared.items():
|
|
74
|
+
if direction not in (-1, 1):
|
|
75
|
+
raise ValueError(f"monotonic_constraints values must be -1 or 1, got {direction!r} for {f!r}")
|
|
76
|
+
name = X.columns[f] if isinstance(f, (int, np.integer)) else f
|
|
77
|
+
if name in categorical_features:
|
|
78
|
+
continue
|
|
79
|
+
resolved[name] = direction
|
|
80
|
+
return resolved
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resolve_bounded_effects(X: pd.DataFrame, declared, categorical_features: set) -> dict:
|
|
84
|
+
"""Normalize a user-declared ``{column_name_or_index: (lower, upper)}``
|
|
85
|
+
dict to ``{column_name: (lower, upper)}``, the same name/index
|
|
86
|
+
convention ``resolve_categorical_features`` uses.
|
|
87
|
+
|
|
88
|
+
A bound declared on a categorical column is silently dropped, same
|
|
89
|
+
precedent as ``resolve_monotonic_constraints``. ``lower > upper``
|
|
90
|
+
raises -- simply invalid, no sensible silent interpretation.
|
|
91
|
+
"""
|
|
92
|
+
if not declared:
|
|
93
|
+
return {}
|
|
94
|
+
resolved = {}
|
|
95
|
+
for f, bounds in declared.items():
|
|
96
|
+
lower, upper = bounds
|
|
97
|
+
if lower > upper:
|
|
98
|
+
raise ValueError(f"bounded_effects lower bound must be <= upper bound, got {bounds!r} for {f!r}")
|
|
99
|
+
name = X.columns[f] if isinstance(f, (int, np.integer)) else f
|
|
100
|
+
if name in categorical_features:
|
|
101
|
+
continue
|
|
102
|
+
resolved[name] = (float(lower), float(upper))
|
|
103
|
+
return resolved
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolve_forbidden_interactions(X: pd.DataFrame, declared) -> set:
|
|
107
|
+
"""Normalize a user-declared list of 2-column name/index pairs to a
|
|
108
|
+
``set`` of 2-element ``frozenset``s of column names -- the same
|
|
109
|
+
name/index convention ``resolve_categorical_features`` uses.
|
|
110
|
+
|
|
111
|
+
An entry that doesn't name exactly 2 distinct columns raises: unlike a
|
|
112
|
+
stray categorical key on ``monotonic_constraints``, this is simply a
|
|
113
|
+
usage mistake with no sensible silent interpretation.
|
|
114
|
+
"""
|
|
115
|
+
if not declared:
|
|
116
|
+
return set()
|
|
117
|
+
resolved = set()
|
|
118
|
+
for pair in declared:
|
|
119
|
+
names = {X.columns[f] if isinstance(f, (int, np.integer)) else f for f in pair}
|
|
120
|
+
if len(names) != 2:
|
|
121
|
+
raise ValueError(f"forbidden_interactions entries must name exactly 2 distinct columns, got {pair!r}")
|
|
122
|
+
resolved.add(frozenset(names))
|
|
123
|
+
return resolved
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def resolve_group_col(X: pd.DataFrame, declared):
|
|
127
|
+
"""Normalize a user-declared column name/index (or ``None``) to a
|
|
128
|
+
column name, the same name/index convention ``resolve_categorical_
|
|
129
|
+
features`` uses.
|
|
130
|
+
|
|
131
|
+
Unlike a stray categorical key on ``monotonic_constraints``, a
|
|
132
|
+
``group_col`` that doesn't name a real column is simply a usage
|
|
133
|
+
mistake -- there's no sensible silent interpretation, so this raises.
|
|
134
|
+
"""
|
|
135
|
+
if declared is None:
|
|
136
|
+
return None
|
|
137
|
+
name = X.columns[declared] if isinstance(declared, (int, np.integer)) else declared
|
|
138
|
+
if name not in X.columns:
|
|
139
|
+
raise ValueError(f"group_col={declared!r} is not a column of X.")
|
|
140
|
+
return name
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _glm_power(loss: str, tweedie_power: float) -> float:
|
|
144
|
+
"""The Tweedie variance power unifying the three GLM losses --
|
|
145
|
+
Poisson (``p=1``) and Gamma (``p=2``) are fixed special cases of the
|
|
146
|
+
same family, ``loss="tweedie"`` exposes ``p`` directly (default
|
|
147
|
+
``1.5``, the usual insurance pure-premium setting)."""
|
|
148
|
+
return _GLM_POWERS.get(loss, tweedie_power)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _glm_inverse_link(eta: np.ndarray) -> np.ndarray:
|
|
152
|
+
"""Log link's inverse: ``mu = exp(eta)``. Clipped before
|
|
153
|
+
exponentiating (a plain numerical-stability guard, not a modeling
|
|
154
|
+
choice) so a large intermediate boosting score can't overflow
|
|
155
|
+
``np.exp`` into ``inf``."""
|
|
156
|
+
return np.exp(np.clip(eta, -30.0, 30.0))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _glm_residual(y: np.ndarray, mu: np.ndarray, power: float) -> np.ndarray:
|
|
160
|
+
"""Negative deviance gradient w.r.t. the link-scale linear predictor
|
|
161
|
+
``eta = log(mu)``, for the Tweedie family at variance power ``power``
|
|
162
|
+
-- what gets boosted, exactly the role ``y - current_pred`` plays for
|
|
163
|
+
``loss="squared_error"`` and ``y - sigmoid(current_score)`` plays for
|
|
164
|
+
:class:`zoneboost.ZoneBoostClassifier`'s log-odds booster.
|
|
165
|
+
|
|
166
|
+
``residual = mu**(1 - power) * (y - mu)`` -- reduces to ``y - mu`` at
|
|
167
|
+
``power=1`` (Poisson) and ``(y - mu) / mu`` at ``power=2`` (Gamma),
|
|
168
|
+
the standard boosting residuals for each family; verified directly
|
|
169
|
+
against both in tests.
|
|
170
|
+
"""
|
|
171
|
+
return mu ** (1.0 - power) * (y - mu)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _glm_baseline(y: np.ndarray, offset: np.ndarray, power: float, sample_weight: np.ndarray = None) -> float:
|
|
175
|
+
"""Best constant link-scale predictor given a (possibly nonzero,
|
|
176
|
+
per-row) fixed ``offset``: ``log(sum(y) / sum(exp(offset)))`` -- the
|
|
177
|
+
exact intercept-only MLE for Poisson with a fixed offset (the score
|
|
178
|
+
equation ``sum(y) = sum(exp(beta0 + offset))`` solved in closed
|
|
179
|
+
form). Reused as-is for Gamma/Tweedie: not their exact MLE, but the
|
|
180
|
+
same mean-matching, disclosed approximation quantile-mode's
|
|
181
|
+
machinery already relies on elsewhere in this codebase.
|
|
182
|
+
|
|
183
|
+
``sample_weight`` (default ``None``, bit-identical to every prior
|
|
184
|
+
release) generalizes the same closed form to
|
|
185
|
+
``log(sum(w*y) / sum(w*exp(offset)))`` -- the weighted score
|
|
186
|
+
equation ``sum(w*y) = sum(w*exp(beta0 + offset))``."""
|
|
187
|
+
if sample_weight is None:
|
|
188
|
+
return float(np.log(np.sum(y) / np.sum(np.exp(offset))))
|
|
189
|
+
return float(np.log(np.sum(sample_weight * y) / np.sum(sample_weight * np.exp(offset))))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _glm_deviance_score(
|
|
193
|
+
y: np.ndarray, mu: np.ndarray, loss: str, tweedie_power: float, sample_weight: np.ndarray = None
|
|
194
|
+
) -> float:
|
|
195
|
+
"""Mean deviance for whichever GLM loss is active -- the ``_score``
|
|
196
|
+
role RMSE/pinball loss play for ``squared_error``/``quantile``.
|
|
197
|
+
Dispatches directly to scikit-learn's own, already-tested
|
|
198
|
+
``mean_poisson_deviance``/``mean_gamma_deviance``/
|
|
199
|
+
``mean_tweedie_deviance``, not a hand-derived formula. ``sample_
|
|
200
|
+
weight`` (default ``None``) is passed straight through -- all three
|
|
201
|
+
accept it natively."""
|
|
202
|
+
if loss == "poisson":
|
|
203
|
+
return float(mean_poisson_deviance(y, mu, sample_weight=sample_weight))
|
|
204
|
+
if loss == "gamma":
|
|
205
|
+
return float(mean_gamma_deviance(y, mu, sample_weight=sample_weight))
|
|
206
|
+
return float(mean_tweedie_deviance(y, mu, power=tweedie_power, sample_weight=sample_weight))
|