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.
@@ -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)