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 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
+ ]
@@ -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))