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/_conformal.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Conformalized Quantile Regression (CQR): a distribution-free prediction
|
|
2
|
+
interval whose width can vary with X, built from two quantile-mode
|
|
3
|
+
:class:`zoneboost.ZoneBoostRegressor` fits -- unlike
|
|
4
|
+
:meth:`zoneboost.ZoneBoostRegressor.predict_interval`'s split-conformal
|
|
5
|
+
margin, which is a single fixed width added uniformly to every row.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from sklearn.base import BaseEstimator, clone
|
|
12
|
+
from sklearn.utils.validation import check_is_fitted
|
|
13
|
+
|
|
14
|
+
from ._common import ensure_dataframe
|
|
15
|
+
from .regressor import ZoneBoostRegressor
|
|
16
|
+
|
|
17
|
+
__all__ = ["ConformalizedQuantileRegressor"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _rearrange(lo: np.ndarray, hi: np.ndarray) -> tuple:
|
|
21
|
+
"""Chernozhukov, Fernandez-Val & Galichon (2010) rearrangement for
|
|
22
|
+
exactly two quantile levels: sorting two numbers per row is just an
|
|
23
|
+
elementwise ``min``/``max`` swap. Fixes "crossing"
|
|
24
|
+
(``lo[i] > hi[i]``) -- possible whenever ``lo``/``hi`` come from two
|
|
25
|
+
independently-fit models with no shared constraint between them --
|
|
26
|
+
without changing anything already ordered (``min``/``max`` of an
|
|
27
|
+
already-sorted pair is a no-op), so this never changes output on
|
|
28
|
+
data where crossing didn't occur."""
|
|
29
|
+
return np.minimum(lo, hi), np.maximum(lo, hi)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConformalizedQuantileRegressor(BaseEstimator):
|
|
33
|
+
"""Locally-adaptive prediction intervals via Conformalized Quantile
|
|
34
|
+
Regression (Romano, Patterson & Candes, 2019).
|
|
35
|
+
|
|
36
|
+
Fits two quantile-mode :class:`zoneboost.ZoneBoostRegressor` instances
|
|
37
|
+
at levels ``alpha/2`` and ``1 - alpha/2`` (the raw quantile band), then
|
|
38
|
+
conformalizes their gap on a genuinely held-out calibration split: the
|
|
39
|
+
nonconformity score ``E_i = max(q_lo(X_i) - y_i, y_i - q_hi(X_i))`` is
|
|
40
|
+
computed for every calibration row, and the same fixed additive margin
|
|
41
|
+
(the finite-sample-corrected quantile of these scores) is added to both
|
|
42
|
+
quantile predictions. The result still gives ``ZoneBoostRegressor.
|
|
43
|
+
predict_interval``'s exact distribution-free marginal coverage guarantee
|
|
44
|
+
(``P(y in interval) >= 1 - alpha``, under exchangeability) -- but
|
|
45
|
+
because the *quantile* predictions themselves already vary with ``X``,
|
|
46
|
+
the total interval width does too, unlike a plain split-conformal band's
|
|
47
|
+
single constant-width margin.
|
|
48
|
+
|
|
49
|
+
``lo_``/``hi_`` are two **independently**-fit models, so nothing
|
|
50
|
+
guarantees ``lo_.predict(x) <= hi_.predict(x)`` for every row
|
|
51
|
+
("crossing"). Both are rearranged (Chernozhukov, Fernandez-Val &
|
|
52
|
+
Galichon, 2010) -- for exactly two levels, an elementwise ``min``/``max``
|
|
53
|
+
swap -- before computing calibration scores and before returning an
|
|
54
|
+
interval, unconditionally (not behind a parameter): rearrangement never
|
|
55
|
+
increases estimation risk and is a no-op wherever a row was already
|
|
56
|
+
ordered, so this never changes output on data where crossing didn't
|
|
57
|
+
occur.
|
|
58
|
+
|
|
59
|
+
Not a general-purpose point regressor (there is no meaningful single
|
|
60
|
+
``predict``): use :meth:`predict_interval` for the band, or fit
|
|
61
|
+
``estimator`` directly (with ``loss="squared_error"``) alongside this
|
|
62
|
+
class if a point prediction is also needed.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
estimator : ZoneBoostRegressor, default=None
|
|
67
|
+
An unfit template supplying every tuning knob *other* than
|
|
68
|
+
``loss``/``quantile``/``calibration_fraction`` (e.g. ``n_rounds``,
|
|
69
|
+
``max_zones``, ``shrinkage_m``, ``monotonic_constraints``,
|
|
70
|
+
``adaptive_boundary_smoothing``) -- cloned twice internally, once
|
|
71
|
+
per quantile level. ``None`` (default) uses a plain
|
|
72
|
+
``ZoneBoostRegressor()``. Any ``loss``/``quantile``/
|
|
73
|
+
``calibration_fraction``/``random_state`` set on the template are
|
|
74
|
+
always overridden (this class manages them itself) -- every other
|
|
75
|
+
parameter is respected as-is. Quantile-mode fitting uses
|
|
76
|
+
``QuantileRegressor``'s linear-programming solver internally, which
|
|
77
|
+
is substantially more expensive per round than the default loss
|
|
78
|
+
(see :class:`zoneboost.ZoneBoostRegressor`'s ``loss`` parameter) --
|
|
79
|
+
two such models are fit here, so consider a smaller ``n_rounds`` or
|
|
80
|
+
``n_iter_no_change`` on the template for large datasets.
|
|
81
|
+
alpha : float, default=0.1
|
|
82
|
+
Miscoverage rate -- e.g. ``0.1`` targets 90% coverage. The two
|
|
83
|
+
internal quantile levels are ``alpha / 2`` and ``1 - alpha / 2``.
|
|
84
|
+
calibration_fraction : float, default=0.2
|
|
85
|
+
Fraction of training rows held out (once, at the start of `fit`)
|
|
86
|
+
purely for CQR calibration -- genuinely separate from, and never
|
|
87
|
+
reused by, either quantile model's own internal
|
|
88
|
+
``validation_fraction`` split (which only drives their own early
|
|
89
|
+
stopping).
|
|
90
|
+
random_state : int, default=42
|
|
91
|
+
Seed for the calibration split and (via the two cloned estimators)
|
|
92
|
+
their own internal splits/subsampling.
|
|
93
|
+
|
|
94
|
+
Attributes
|
|
95
|
+
----------
|
|
96
|
+
lo_ : ZoneBoostRegressor
|
|
97
|
+
Fitted quantile model at level ``alpha / 2``.
|
|
98
|
+
hi_ : ZoneBoostRegressor
|
|
99
|
+
Fitted quantile model at level ``1 - alpha / 2``.
|
|
100
|
+
cqr_scores_ : ndarray
|
|
101
|
+
Sorted CQR nonconformity scores on the calibration split -- the
|
|
102
|
+
margin :meth:`predict_interval` draws from.
|
|
103
|
+
|
|
104
|
+
Examples
|
|
105
|
+
--------
|
|
106
|
+
>>> import pandas as pd
|
|
107
|
+
>>> from zoneboost import ConformalizedQuantileRegressor
|
|
108
|
+
>>> X = pd.DataFrame({"x": [1, 2, 3, 4, 5, 6, 7, 8]})
|
|
109
|
+
>>> y = [1.0, 2.1, 2.9, 4.2, 4.8, 6.1, 6.9, 8.3]
|
|
110
|
+
>>> model = ConformalizedQuantileRegressor(alpha=0.2, random_state=0).fit(X, y)
|
|
111
|
+
>>> lower, upper = model.predict_interval(X)
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
estimator: ZoneBoostRegressor = None,
|
|
117
|
+
alpha: float = 0.1,
|
|
118
|
+
calibration_fraction: float = 0.2,
|
|
119
|
+
random_state: int = 42,
|
|
120
|
+
):
|
|
121
|
+
self.estimator = estimator
|
|
122
|
+
self.alpha = alpha
|
|
123
|
+
self.calibration_fraction = calibration_fraction
|
|
124
|
+
self.random_state = random_state
|
|
125
|
+
|
|
126
|
+
def fit(self, X, y):
|
|
127
|
+
"""Fit the two internal quantile models and calibrate the CQR margin.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
X : DataFrame or array-like of shape (n_samples, n_features)
|
|
132
|
+
y : array-like of shape (n_samples,)
|
|
133
|
+
|
|
134
|
+
Returns
|
|
135
|
+
-------
|
|
136
|
+
self : ConformalizedQuantileRegressor
|
|
137
|
+
"""
|
|
138
|
+
if not 0 < self.alpha < 1:
|
|
139
|
+
raise ValueError(f"alpha must be in (0, 1), got {self.alpha!r}")
|
|
140
|
+
if not 0 < self.calibration_fraction < 1:
|
|
141
|
+
raise ValueError(f"calibration_fraction must be in (0, 1), got {self.calibration_fraction!r}")
|
|
142
|
+
|
|
143
|
+
X = ensure_dataframe(X, getattr(self, "feature_names_in_", None))
|
|
144
|
+
y_arr = np.asarray(y, dtype=float).reshape(-1)
|
|
145
|
+
if len(X) != len(y_arr):
|
|
146
|
+
raise ValueError(f"X and y have inconsistent lengths: {len(X)} vs {len(y_arr)}")
|
|
147
|
+
self.feature_names_in_ = np.array(X.columns)
|
|
148
|
+
|
|
149
|
+
base = self.estimator if self.estimator is not None else ZoneBoostRegressor()
|
|
150
|
+
|
|
151
|
+
rng = np.random.default_rng(self.random_state)
|
|
152
|
+
n_total = len(X)
|
|
153
|
+
perm = rng.permutation(n_total)
|
|
154
|
+
n_cal = max(1, int(n_total * self.calibration_fraction))
|
|
155
|
+
if n_cal >= n_total:
|
|
156
|
+
raise ValueError("calibration_fraction leaves no rows for the training split.")
|
|
157
|
+
cal_idx, train_idx = perm[:n_cal], perm[n_cal:]
|
|
158
|
+
X_cal = X.iloc[cal_idx].reset_index(drop=True)
|
|
159
|
+
y_cal = y_arr[cal_idx]
|
|
160
|
+
X_train = X.iloc[train_idx].reset_index(drop=True)
|
|
161
|
+
y_train = y_arr[train_idx]
|
|
162
|
+
|
|
163
|
+
self.lo_ = clone(base).set_params(
|
|
164
|
+
loss="quantile", quantile=self.alpha / 2, calibration_fraction=0.0, random_state=self.random_state
|
|
165
|
+
)
|
|
166
|
+
self.hi_ = clone(base).set_params(
|
|
167
|
+
loss="quantile", quantile=1 - self.alpha / 2, calibration_fraction=0.0, random_state=self.random_state
|
|
168
|
+
)
|
|
169
|
+
self.lo_.fit(X_train, y_train)
|
|
170
|
+
self.hi_.fit(X_train, y_train)
|
|
171
|
+
|
|
172
|
+
lo_cal_pred = self.lo_.predict(X_cal)
|
|
173
|
+
hi_cal_pred = self.hi_.predict(X_cal)
|
|
174
|
+
lo_cal_pred, hi_cal_pred = _rearrange(lo_cal_pred, hi_cal_pred)
|
|
175
|
+
scores = np.maximum(lo_cal_pred - y_cal, y_cal - hi_cal_pred)
|
|
176
|
+
self.cqr_scores_ = np.sort(scores)
|
|
177
|
+
return self
|
|
178
|
+
|
|
179
|
+
def predict_interval(self, X) -> tuple:
|
|
180
|
+
"""Locally-adaptive prediction interval.
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
X : DataFrame or array-like of shape (n_samples, n_features)
|
|
185
|
+
|
|
186
|
+
Returns
|
|
187
|
+
-------
|
|
188
|
+
lower, upper : ndarray of shape (n_samples,)
|
|
189
|
+
"""
|
|
190
|
+
check_is_fitted(self, "lo_")
|
|
191
|
+
X = ensure_dataframe(X, self.feature_names_in_)
|
|
192
|
+
q_lo = self.lo_.predict(X)
|
|
193
|
+
q_hi = self.hi_.predict(X)
|
|
194
|
+
q_lo, q_hi = _rearrange(q_lo, q_hi)
|
|
195
|
+
n = len(self.cqr_scores_)
|
|
196
|
+
k = min(int(np.ceil((n + 1) * (1 - self.alpha))), n)
|
|
197
|
+
margin = self.cqr_scores_[k - 1]
|
|
198
|
+
return q_lo - margin, q_hi + margin
|
zoneboost/_drift.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Time-based / drift-aware reporting: a **stateless comparison** between
|
|
2
|
+
two already-fitted `ZoneBoostRegressor` models -- e.g. last quarter's
|
|
3
|
+
model and this quarter's -- not a new fit-time behavior. zoneboost itself
|
|
4
|
+
doesn't monitor anything in real time or retain a history of past fits;
|
|
5
|
+
the user retrains a new model each period and calls :func:`compare_models`
|
|
6
|
+
to compare it against the previous one, on a shared evaluation dataset
|
|
7
|
+
(zoneboost retains no training data after `fit`, so there is no other way
|
|
8
|
+
to compare "the same rows" across two models).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
from ._weak_learner import _column_zone_index
|
|
17
|
+
|
|
18
|
+
__all__ = ["compare_models"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _observed_range(model, feature: str) -> tuple:
|
|
22
|
+
"""Min/max of ``feature``'s own zone centers across every round
|
|
23
|
+
``model`` fit it in as a continuous main effect -- what the model
|
|
24
|
+
actually has information about, not the column's full theoretical
|
|
25
|
+
range. Shared by :meth:`zoneboost.ZoneBoostRegressor.counterfactual`
|
|
26
|
+
(via a thin wrapper) and :func:`compare_models` -- one implementation,
|
|
27
|
+
not two."""
|
|
28
|
+
mins, maxs = [], []
|
|
29
|
+
for round_ in model.rounds_:
|
|
30
|
+
if feature not in round_["main_effects"]:
|
|
31
|
+
continue
|
|
32
|
+
zone_info_col = round_["zone_info"][feature]
|
|
33
|
+
if zone_info_col[0] != "continuous":
|
|
34
|
+
continue
|
|
35
|
+
centers = zone_info_col[2]
|
|
36
|
+
if len(centers) > 0:
|
|
37
|
+
mins.append(float(centers.min()))
|
|
38
|
+
maxs.append(float(centers.max()))
|
|
39
|
+
if not mins:
|
|
40
|
+
raise ValueError(f"{feature!r} never appeared as a continuous main effect in any round.")
|
|
41
|
+
return min(mins), max(maxs)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _shared_continuous_columns(model_old, model_new) -> list:
|
|
45
|
+
old_cols = model_old._continuous_main_effect_columns()
|
|
46
|
+
new_cols = model_new._continuous_main_effect_columns()
|
|
47
|
+
return sorted(old_cols & new_cols)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _last_continuous_zone_info(model, feature):
|
|
51
|
+
"""The most recent round's own zone_info for ``feature``, or ``None``
|
|
52
|
+
if it never appeared as a continuous main effect -- a representative
|
|
53
|
+
snapshot, not a claim about every round's own boundaries (zoneboost's
|
|
54
|
+
zones are re-derived fresh every round; see the module docstring)."""
|
|
55
|
+
for round_ in reversed(model.rounds_):
|
|
56
|
+
if feature in round_["main_effects"] and round_["zone_info"][feature][0] == "continuous":
|
|
57
|
+
return round_["zone_info"][feature]
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def compare_models(model_old, model_new, X_eval, y_eval=None) -> dict:
|
|
62
|
+
"""Compares two already-fitted :class:`zoneboost.ZoneBoostRegressor`
|
|
63
|
+
models -- e.g. a model refit on a later time period -- on a shared
|
|
64
|
+
evaluation dataset. Reuses existing, already-exact machinery
|
|
65
|
+
throughout (``feature_importance``, ``predict``) rather than
|
|
66
|
+
inventing parallel comparison logic.
|
|
67
|
+
|
|
68
|
+
**Scope**: regressor only (classifier support deferred -- multiclass's
|
|
69
|
+
per-class nested ``rounds_`` would meaningfully complicate every
|
|
70
|
+
comparison below); compares exactly two snapshots (call this function
|
|
71
|
+
pairwise across more periods for a longer trend); reports an aggregate
|
|
72
|
+
boundary/zone summary, not each model's full per-round boundary
|
|
73
|
+
provenance.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
model_old, model_new : ZoneBoostRegressor
|
|
78
|
+
Both already fitted.
|
|
79
|
+
X_eval : DataFrame or array-like of shape (n_samples, n_features)
|
|
80
|
+
A dataset both models can score -- typically the newer period's
|
|
81
|
+
data, or a fixed holdout common to both fits.
|
|
82
|
+
y_eval : array-like, default=None
|
|
83
|
+
If given, also reports ``performance_change``.
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
dict with keys:
|
|
88
|
+
``feature_importance_change`` -- DataFrame indexed by term name,
|
|
89
|
+
columns ``old``/``new``/``change`` (a term absent from one model
|
|
90
|
+
contributes `0`), sorted by ``|change|`` descending.
|
|
91
|
+
``new_terms``/``disappeared_terms`` -- term names present in only
|
|
92
|
+
one model's ``feature_importance`` index.
|
|
93
|
+
``boundary_shift`` -- ``{feature: {"old_range": (lo, hi),
|
|
94
|
+
"new_range": (lo, hi), "center_shift": float}}`` for every
|
|
95
|
+
continuous main-effect column present in both models --
|
|
96
|
+
``center_shift`` is the shift in each range's own midpoint.
|
|
97
|
+
``population_migration`` -- ``{feature: fraction}`` for the same
|
|
98
|
+
shared columns: using each model's own *last* fitted round's zone
|
|
99
|
+
boundaries (a representative snapshot) to assign every row of
|
|
100
|
+
``X_eval`` a hard zone index under both models, the fraction of
|
|
101
|
+
rows whose zone assignment differs.
|
|
102
|
+
``performance_change`` -- ``{"rmse_old": ..., "rmse_new": ...}``,
|
|
103
|
+
or ``None`` if ``y_eval`` not given.
|
|
104
|
+
``prediction_shift`` -- ``{"mean": ..., "std": ...}`` of
|
|
105
|
+
``predict_new(X_eval) - predict_old(X_eval)``.
|
|
106
|
+
"""
|
|
107
|
+
X_eval = model_old._ensure_dataframe(X_eval)
|
|
108
|
+
|
|
109
|
+
importance_old = model_old.feature_importance(X_eval)
|
|
110
|
+
importance_new = model_new.feature_importance(X_eval)
|
|
111
|
+
all_terms = sorted(set(importance_old.index) | set(importance_new.index))
|
|
112
|
+
old_vals = np.array([float(importance_old.get(t, 0.0)) for t in all_terms])
|
|
113
|
+
new_vals = np.array([float(importance_new.get(t, 0.0)) for t in all_terms])
|
|
114
|
+
feature_importance_change = pd.DataFrame(
|
|
115
|
+
{"old": old_vals, "new": new_vals, "change": new_vals - old_vals}, index=all_terms
|
|
116
|
+
)
|
|
117
|
+
feature_importance_change = feature_importance_change.reindex(
|
|
118
|
+
feature_importance_change["change"].abs().sort_values(ascending=False).index
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
new_terms = sorted(set(importance_new.index) - set(importance_old.index))
|
|
122
|
+
disappeared_terms = sorted(set(importance_old.index) - set(importance_new.index))
|
|
123
|
+
|
|
124
|
+
shared_cols = _shared_continuous_columns(model_old, model_new)
|
|
125
|
+
boundary_shift = {}
|
|
126
|
+
population_migration = {}
|
|
127
|
+
for feature in shared_cols:
|
|
128
|
+
old_range = _observed_range(model_old, feature)
|
|
129
|
+
new_range = _observed_range(model_new, feature)
|
|
130
|
+
old_center = (old_range[0] + old_range[1]) / 2.0
|
|
131
|
+
new_center = (new_range[0] + new_range[1]) / 2.0
|
|
132
|
+
boundary_shift[feature] = {
|
|
133
|
+
"old_range": old_range,
|
|
134
|
+
"new_range": new_range,
|
|
135
|
+
"center_shift": new_center - old_center,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
old_zone_info = _last_continuous_zone_info(model_old, feature)
|
|
139
|
+
new_zone_info = _last_continuous_zone_info(model_new, feature)
|
|
140
|
+
if old_zone_info is not None and new_zone_info is not None:
|
|
141
|
+
old_zones = _column_zone_index(X_eval[feature], old_zone_info)
|
|
142
|
+
new_zones = _column_zone_index(X_eval[feature], new_zone_info)
|
|
143
|
+
population_migration[feature] = float(np.mean(old_zones != new_zones))
|
|
144
|
+
|
|
145
|
+
performance_change = None
|
|
146
|
+
if y_eval is not None:
|
|
147
|
+
y_arr = np.asarray(y_eval, dtype=float).reshape(-1)
|
|
148
|
+
pred_old = model_old.predict(X_eval)
|
|
149
|
+
pred_new = model_new.predict(X_eval)
|
|
150
|
+
performance_change = {
|
|
151
|
+
"rmse_old": float(np.sqrt(np.mean((y_arr - pred_old) ** 2))),
|
|
152
|
+
"rmse_new": float(np.sqrt(np.mean((y_arr - pred_new) ** 2))),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
pred_old = model_old.predict(X_eval)
|
|
156
|
+
pred_new = model_new.predict(X_eval)
|
|
157
|
+
diff = pred_new - pred_old
|
|
158
|
+
prediction_shift = {"mean": float(diff.mean()), "std": float(diff.std())}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
"feature_importance_change": feature_importance_change,
|
|
162
|
+
"new_terms": new_terms,
|
|
163
|
+
"disappeared_terms": disappeared_terms,
|
|
164
|
+
"boundary_shift": boundary_shift,
|
|
165
|
+
"population_migration": population_migration,
|
|
166
|
+
"performance_change": performance_change,
|
|
167
|
+
"prediction_shift": prediction_shift,
|
|
168
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Model evidence cards: a compact, JSON-serializable snapshot of a fitted
|
|
2
|
+
`ZoneBoostRegressor` -- zones/boundaries, per-term support/shrinkage,
|
|
3
|
+
constraint declarations, calibration/conformal coverage, unsupported
|
|
4
|
+
regions, and reproducibility info -- assembled entirely from attributes
|
|
5
|
+
the model already exposes after `fit`. Pure aggregation, no new modeling
|
|
6
|
+
math: every number here is read off `rounds_`, `get_params()`, or a
|
|
7
|
+
method (`feature_importance`, `_observed_range`) this session's earlier
|
|
8
|
+
items already added.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
from ._drift import _observed_range
|
|
19
|
+
from ._reliability import _find_term_key
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
|
|
22
|
+
__all__ = ["evidence_card"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _jsonable(obj):
|
|
26
|
+
"""Recursively coerce numpy/pandas scalars and non-JSON containers
|
|
27
|
+
(``set``/``frozenset``, tuple dict keys) into plain Python types --
|
|
28
|
+
the whole point of a *machine-readable* card is that ``json.dumps``
|
|
29
|
+
never trips over it."""
|
|
30
|
+
if isinstance(obj, dict):
|
|
31
|
+
return {str(k): _jsonable(v) for k, v in obj.items()}
|
|
32
|
+
if isinstance(obj, (list, tuple, set, frozenset)):
|
|
33
|
+
return [_jsonable(v) for v in obj]
|
|
34
|
+
if isinstance(obj, (np.floating,)):
|
|
35
|
+
return float(obj)
|
|
36
|
+
if isinstance(obj, (np.integer,)):
|
|
37
|
+
return int(obj)
|
|
38
|
+
if isinstance(obj, (np.bool_, bool)):
|
|
39
|
+
return bool(obj)
|
|
40
|
+
if isinstance(obj, np.ndarray):
|
|
41
|
+
return [_jsonable(v) for v in obj.tolist()]
|
|
42
|
+
return obj
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _dataset_fingerprint(X: pd.DataFrame) -> dict:
|
|
46
|
+
row_hash = pd.util.hash_pandas_object(X, index=False)
|
|
47
|
+
digest = hashlib.sha256(row_hash.to_numpy().tobytes()).hexdigest()
|
|
48
|
+
return {
|
|
49
|
+
"n_rows": int(len(X)),
|
|
50
|
+
"n_columns": int(X.shape[1]),
|
|
51
|
+
"columns": list(X.columns),
|
|
52
|
+
"dtypes": {col: str(dt) for col, dt in X.dtypes.items()},
|
|
53
|
+
"hash": digest,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _term_diagnostics_summary(rounds: list, kind: str, key, shrinkage_m: float):
|
|
58
|
+
"""Aggregates a term's own per-zone/cell ``counts`` array (already
|
|
59
|
+
stored per round when ``track_reliability=True``) across every round
|
|
60
|
+
it appeared in -- ``None`` if the model wasn't fit with
|
|
61
|
+
``track_reliability=True`` (``diagnostics`` is ``None`` on every
|
|
62
|
+
round in that case). No ``X`` needed: unlike
|
|
63
|
+
:func:`zoneboost._reliability.explain_reliability`, this doesn't look
|
|
64
|
+
up any specific row's own zone, just the raw stored counts."""
|
|
65
|
+
term_id = frozenset(key) if isinstance(key, tuple) else frozenset([key])
|
|
66
|
+
all_counts = []
|
|
67
|
+
n_present = 0
|
|
68
|
+
for round_ in rounds:
|
|
69
|
+
if round_["diagnostics"] is None:
|
|
70
|
+
return None
|
|
71
|
+
term_dict = round_[kind]
|
|
72
|
+
dkey = key if key in term_dict else _find_term_key(term_dict, term_id)
|
|
73
|
+
if dkey is None:
|
|
74
|
+
continue
|
|
75
|
+
n_present += 1
|
|
76
|
+
all_counts.append(round_["diagnostics"][kind][dkey]["counts"].ravel())
|
|
77
|
+
if not all_counts:
|
|
78
|
+
return None
|
|
79
|
+
counts = np.concatenate(all_counts)
|
|
80
|
+
return {
|
|
81
|
+
"mean_support_per_zone": float(counts.mean()),
|
|
82
|
+
"mean_shrinkage_fraction": float((shrinkage_m / (counts + shrinkage_m)).mean()),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _term_name(key) -> str:
|
|
87
|
+
return key if isinstance(key, str) else " x ".join(sorted(key))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _conformal_summary(scores) -> dict:
|
|
91
|
+
return {
|
|
92
|
+
"n": int(len(scores)),
|
|
93
|
+
"median": float(np.median(scores)),
|
|
94
|
+
"p90": float(np.quantile(scores, 0.9)),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def evidence_card(model, X: pd.DataFrame = None) -> dict:
|
|
99
|
+
"""See :meth:`zoneboost.ZoneBoostRegressor.evidence_card` -- the
|
|
100
|
+
method is a thin wrapper around this standalone function."""
|
|
101
|
+
rounds = model.rounds_
|
|
102
|
+
shrinkage_m = model.shrinkage_m
|
|
103
|
+
|
|
104
|
+
# zones: each column's own *last* round as a main effect -- a
|
|
105
|
+
# representative snapshot, not a full per-round boundary history
|
|
106
|
+
# (same disclosed precedent as items 6/7's own drift/hierarchical
|
|
107
|
+
# reporting).
|
|
108
|
+
zones = {}
|
|
109
|
+
for col in model.predictor_names_:
|
|
110
|
+
last_round = None
|
|
111
|
+
n_present = 0
|
|
112
|
+
for round_ in rounds:
|
|
113
|
+
if col in round_["main_effects"]:
|
|
114
|
+
n_present += 1
|
|
115
|
+
last_round = round_
|
|
116
|
+
if last_round is None:
|
|
117
|
+
zones[col] = {"kind": None, "n_rounds_present": 0}
|
|
118
|
+
continue
|
|
119
|
+
zone_info_col = last_round["zone_info"][col]
|
|
120
|
+
kind = zone_info_col[0]
|
|
121
|
+
entry = {"kind": kind, "n_rounds_present": n_present}
|
|
122
|
+
if kind == "continuous":
|
|
123
|
+
entry["observed_range"] = _observed_range(model, col)
|
|
124
|
+
else:
|
|
125
|
+
entry["categories_seen"] = sorted(zone_info_col[1].keys())
|
|
126
|
+
zones[col] = entry
|
|
127
|
+
|
|
128
|
+
# terms: union of every main effect / interaction / triple across all
|
|
129
|
+
# rounds, keyed by the same "A x B" name explain()/feature_importance()
|
|
130
|
+
# already use.
|
|
131
|
+
term_keys = {"main_effects": {}, "interactions": {}, "triples": {}}
|
|
132
|
+
for round_ in rounds:
|
|
133
|
+
for col in round_["main_effects"]:
|
|
134
|
+
term_keys["main_effects"].setdefault(col, col)
|
|
135
|
+
for key in round_["interactions"]:
|
|
136
|
+
term_keys["interactions"].setdefault(frozenset(key), key)
|
|
137
|
+
for key in round_["triples"]:
|
|
138
|
+
term_keys["triples"].setdefault(frozenset(key), key)
|
|
139
|
+
|
|
140
|
+
importance = model.feature_importance(X) if X is not None else None
|
|
141
|
+
|
|
142
|
+
terms = {}
|
|
143
|
+
n_rounds_total = len(rounds)
|
|
144
|
+
for kind, keys in term_keys.items():
|
|
145
|
+
for term_id, key in keys.items():
|
|
146
|
+
name = _term_name(key)
|
|
147
|
+
if kind == "main_effects":
|
|
148
|
+
n_present = sum(1 for round_ in rounds if key in round_[kind])
|
|
149
|
+
else:
|
|
150
|
+
n_present = sum(1 for round_ in rounds if _find_term_key(round_[kind], term_id) is not None)
|
|
151
|
+
diag = _term_diagnostics_summary(rounds, kind, key, shrinkage_m)
|
|
152
|
+
terms[name] = {
|
|
153
|
+
"kind": kind.rstrip("s") if kind != "main_effects" else "main_effect",
|
|
154
|
+
"n_rounds_present": n_present,
|
|
155
|
+
"n_rounds_total": n_rounds_total,
|
|
156
|
+
"mean_abs_contribution": float(importance[name]) if importance is not None and name in importance.index else None,
|
|
157
|
+
"mean_support_per_zone": diag["mean_support_per_zone"] if diag else None,
|
|
158
|
+
"mean_shrinkage_fraction": diag["mean_shrinkage_fraction"] if diag else None,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
continuous_cols = model._continuous_main_effect_columns()
|
|
162
|
+
unsupported_regions = {col: _observed_range(model, col) for col in sorted(continuous_cols)}
|
|
163
|
+
|
|
164
|
+
conformal_summary = _conformal_summary(model.conformal_scores_) if model.conformal_scores_ is not None else None
|
|
165
|
+
|
|
166
|
+
card = {
|
|
167
|
+
"zoneboost_version": __version__,
|
|
168
|
+
"model_class": type(model).__name__,
|
|
169
|
+
"reproducibility": {
|
|
170
|
+
"params": model.get_params(),
|
|
171
|
+
"random_state": model.random_state,
|
|
172
|
+
},
|
|
173
|
+
"dataset_fingerprint": _dataset_fingerprint(X) if X is not None else None,
|
|
174
|
+
"fit_summary": {
|
|
175
|
+
"n_rounds_fit": len(rounds),
|
|
176
|
+
"best_n_rounds": model.best_n_rounds_,
|
|
177
|
+
"baseline": model.baseline_,
|
|
178
|
+
"final_train_rmse": model.train_rmse_[model.best_n_rounds_ - 1] if model.train_rmse_ else None,
|
|
179
|
+
"final_val_rmse": model.val_rmse_[model.best_n_rounds_ - 1] if model.val_rmse_ else None,
|
|
180
|
+
},
|
|
181
|
+
"zones": zones,
|
|
182
|
+
"terms": terms,
|
|
183
|
+
"shrinkage": {
|
|
184
|
+
"shrinkage_m": model.shrinkage_m,
|
|
185
|
+
"boundary_shrinkage_m": model.boundary_shrinkage_m if model.adaptive_boundary_smoothing else None,
|
|
186
|
+
"track_reliability_enabled": model.track_reliability,
|
|
187
|
+
},
|
|
188
|
+
"constraints": {
|
|
189
|
+
"monotonic_constraints": model.monotonic_constraints_,
|
|
190
|
+
"convexity_constraints": model.convexity_constraints_,
|
|
191
|
+
"bounded_effects": model.bounded_effects_,
|
|
192
|
+
"forbidden_interactions": sorted(sorted(pair) for pair in model.forbidden_interactions_),
|
|
193
|
+
"group_col": model.group_col_,
|
|
194
|
+
"n_effect_overrides": len(model.effect_overrides_),
|
|
195
|
+
},
|
|
196
|
+
"calibration": {
|
|
197
|
+
"loss": model.loss,
|
|
198
|
+
"quantile": model.quantile if model.loss == "quantile" else None,
|
|
199
|
+
"conformal_scores_available": model.conformal_scores_ is not None,
|
|
200
|
+
"conformal_scores_summary": conformal_summary,
|
|
201
|
+
},
|
|
202
|
+
"unsupported_regions": unsupported_regions,
|
|
203
|
+
}
|
|
204
|
+
return _jsonable(card)
|
zoneboost/_explain.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Exact, non-approximate prediction attribution.
|
|
2
|
+
|
|
3
|
+
Unlike SHAP or LIME, this isn't a post-hoc approximation of a black-box
|
|
4
|
+
model -- it's an algebraic decomposition of what the boosting loop already
|
|
5
|
+
computed. Each round's contribution to the running score is
|
|
6
|
+
|
|
7
|
+
intercept + contributions @ weights
|
|
8
|
+
|
|
9
|
+
where ``contributions`` holds one column per term (main effects +
|
|
10
|
+
interactions + any adaptively-selected 3-way interactions) and
|
|
11
|
+
``(intercept, weights)`` is that round's own Lasso fit of the residual on
|
|
12
|
+
those per-term contributions (see ``_weak_learner._fit_lasso_weights``).
|
|
13
|
+
Because the dot product is already a per-term sum, this expands exactly
|
|
14
|
+
into
|
|
15
|
+
|
|
16
|
+
round_baseline + sum_i( weight_i * term_i )
|
|
17
|
+
|
|
18
|
+
with round_baseline = intercept, a fixed per-round constant. Summing that
|
|
19
|
+
across rounds gives, for every row, a set of per-term contributions that
|
|
20
|
+
add up EXACTLY to the model's prediction (or, for the classifier, to the
|
|
21
|
+
pre-sigmoid log-odds score) -- not an estimate of it.
|
|
22
|
+
|
|
23
|
+
Each ``term_i`` itself is a soft, interpolated lookup for continuous
|
|
24
|
+
columns (see ``_weak_learner._column_soft_zone_index`` and
|
|
25
|
+
``_blend_1d``/``_blend_2d``/``_blend_3d``) rather than a hard single-zone
|
|
26
|
+
value -- this module must use the exact same blend `predict` does, or the
|
|
27
|
+
row-sum-equals-prediction invariant above would break.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
import pandas as pd
|
|
34
|
+
|
|
35
|
+
from ._weak_learner import _blend_1d, _blend_2d, _blend_3d, _column_soft_zone_index
|
|
36
|
+
|
|
37
|
+
__all__ = ["explain_rounds"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def explain_rounds(X: pd.DataFrame, rounds: list, baseline: float, learning_rate: float) -> pd.DataFrame:
|
|
41
|
+
"""Per-row, per-term contribution breakdown across boosting rounds.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
X : DataFrame
|
|
46
|
+
rounds : list
|
|
47
|
+
A (possibly truncated) ``rounds_`` list, as stored on
|
|
48
|
+
ZoneBoostRegressor or one of ZoneBoostClassifier's internal
|
|
49
|
+
boosters.
|
|
50
|
+
baseline : float
|
|
51
|
+
The model's starting value before any round is applied.
|
|
52
|
+
learning_rate : float
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
DataFrame of shape (len(X), n_terms + 1)
|
|
57
|
+
One column per term that appeared in any round -- each predictor's
|
|
58
|
+
own name for its main effect, ``"A x B"`` for an interaction pair,
|
|
59
|
+
``"A x B x C"`` for a 3-way interaction -- plus a ``"baseline"``
|
|
60
|
+
column. Row sums equal the model's raw prediction (regression) or
|
|
61
|
+
log-odds score (classification) exactly.
|
|
62
|
+
"""
|
|
63
|
+
n = len(X)
|
|
64
|
+
baseline_total = float(baseline)
|
|
65
|
+
term_totals: dict[str, np.ndarray] = {}
|
|
66
|
+
|
|
67
|
+
for round_ in rounds:
|
|
68
|
+
zone_info = round_["zone_info"]
|
|
69
|
+
main_effects = round_["main_effects"]
|
|
70
|
+
interactions = round_["interactions"]
|
|
71
|
+
triples = round_["triples"]
|
|
72
|
+
weights = round_["weights"]
|
|
73
|
+
|
|
74
|
+
n_terms = len(main_effects) + len(interactions) + len(triples)
|
|
75
|
+
if n_terms == 0:
|
|
76
|
+
continue
|
|
77
|
+
baseline_total += learning_rate * round_["intercept"]
|
|
78
|
+
|
|
79
|
+
# weights is aligned to main_effects -> interactions -> triples, in
|
|
80
|
+
# each dict's own (Python-guaranteed) insertion order -- the exact
|
|
81
|
+
# same order weak_learner_contributions builds its columns in, and
|
|
82
|
+
# the order the round's weights were fit against.
|
|
83
|
+
i = 0
|
|
84
|
+
for col, deviation in main_effects.items():
|
|
85
|
+
z_lo, z_hi, w = _column_soft_zone_index(X[col], zone_info[col])
|
|
86
|
+
share = learning_rate * weights[i] * _blend_1d(deviation, z_lo, z_hi, w)
|
|
87
|
+
term_totals.setdefault(col, np.zeros(n))
|
|
88
|
+
term_totals[col] += share
|
|
89
|
+
i += 1
|
|
90
|
+
|
|
91
|
+
for (a, b), deviation in interactions.items():
|
|
92
|
+
za_lo, za_hi, wa = _column_soft_zone_index(X[a], zone_info[a])
|
|
93
|
+
zb_lo, zb_hi, wb = _column_soft_zone_index(X[b], zone_info[b])
|
|
94
|
+
share = learning_rate * weights[i] * _blend_2d(deviation, za_lo, za_hi, wa, zb_lo, zb_hi, wb)
|
|
95
|
+
# Canonicalize: a pair's fit order varies round to round (each
|
|
96
|
+
# round samples/orders columns independently), so without
|
|
97
|
+
# sorting, "A x B" and "B x A" would fragment into separate
|
|
98
|
+
# columns instead of accumulating as the same term.
|
|
99
|
+
key = " x ".join(sorted((a, b)))
|
|
100
|
+
term_totals.setdefault(key, np.zeros(n))
|
|
101
|
+
term_totals[key] += share
|
|
102
|
+
i += 1
|
|
103
|
+
|
|
104
|
+
for (a, b, c), deviation in triples.items():
|
|
105
|
+
za_lo, za_hi, wa = _column_soft_zone_index(X[a], zone_info[a])
|
|
106
|
+
zb_lo, zb_hi, wb = _column_soft_zone_index(X[b], zone_info[b])
|
|
107
|
+
zc_lo, zc_hi, wc = _column_soft_zone_index(X[c], zone_info[c])
|
|
108
|
+
share = learning_rate * weights[i] * _blend_3d(
|
|
109
|
+
deviation, za_lo, za_hi, wa, zb_lo, zb_hi, wb, zc_lo, zc_hi, wc
|
|
110
|
+
)
|
|
111
|
+
key = " x ".join(sorted((a, b, c)))
|
|
112
|
+
term_totals.setdefault(key, np.zeros(n))
|
|
113
|
+
term_totals[key] += share
|
|
114
|
+
i += 1
|
|
115
|
+
|
|
116
|
+
return pd.DataFrame({"baseline": np.full(n, baseline_total), **term_totals}, index=X.index)
|