Explaining Predictions

Both estimators expose explain(X) and feature_importance(X).

Unlike SHAP or LIME, this isn't a post-hoc approximation of a black-box model — it's an algebraic decomposition of the exact computation predict already performs, so it costs no extra sampling and the result sums exactly to the prediction.

explain(X)

contrib = model.explain(X)            # one column per term, plus "baseline"
contrib.sum(axis=1)                    # == model.predict(X), exactly

Each column is either a predictor's own name (its main effect) or "A x B" (that pair's interaction) — never split further, so an interaction's contribution isn't arbitrarily divided between its two variables.

feature_importance(X)

model.feature_importance(X)            # mean |contribution| per term, sorted

The mean absolute contribution of each term across X, sorted from most to least influential — a direct summary of explain(X), not a separately computed statistic.

Classification Semantics

For ZoneBoostClassifier, explain(X) sums to the log-odds score, not the probability directly — probability contributions don't add linearly through a sigmoid, the same convention SHAP uses for logistic models. For 3+ classes (native multinomial boosting, see Native Multinomial Boosting) it returns a {class_label: DataFrame} dict, each including one extra "_softmax_centering" identifiability column, and

softmax(explain(X)[classes_[0]].sum(axis=1), ..., explain(X)[classes_[K-1]].sum(axis=1))

reproduces predict_proba(X) exactly when calibrate=False (the default); with calibrate=True, predict_proba additionally applies each class's fitted isotonic calibrator and renormalizes. See Probability Calibration.

Explanation Reliability

A contribution's exact value doesn't say how much to trust it. Fit with track_reliability=True (opt-in — real per-round memory/compute cost, storing an extra counts array and a cross-fold standard-deviation array per term) and explain(X, include_reliability=True) returns a second value alongside the usual contributions:

contrib, reliability = model.explain(X, include_reliability=True)
reliability["shell_weight"]
#    support  shrinkage_fraction  cross_fold_std  n_rounds_present
# 0    581.5               0.028           0.004                80

Per term, per row (averaged over whichever rounds actually included that term — row/ column subsampling and pair screening mean not every round fits every term):

Measured, honestly, on synthetic data with a genuine interaction term (so its cells are naturally sparser than either column's own main effect): the main effect shell_weight averaged support 581.5 and shrinkage fraction 0.028 (barely shrunk — plenty of its own data); the interaction shell_weight x shucked_weight averaged support 210.8 and shrinkage fraction 0.123 (visibly more shrunk toward its prior) — exactly the pattern a genuine interaction with fewer supporting rows per cell should show. On engineered dense-vs-sparse regions of a single column, extrapolation_frac was 1.0 for a row far outside the training range and 0.0 for a typical one.

Requires track_reliability=True at fit time for support/shrinkage_fraction/cross_fold_std (raises ValueError otherwise); include_reliability=False (the default) reproduces explain(X)'s exact prior output — verified bit-for-bit. Multiclass: reliability is nested per class the same way contributions already are ({class_label: {term: DataFrame}}), since each class's own softmax booster fits its own zones per round.

Functional-ANOVA Purification

Cyclic backfitting (see How It Works) fits each round's tables in a single ordered pass, so a pair's stored deviation can retain a component that's really just a function of one of its two constituent columns alone — two independently-trained models can predict identically while splitting credit between a main effect and its interaction differently. explain(X, purify=True) applies functional-ANOVA purification (Lengerich et al.): any such leftover single-column signal inside an interaction's contribution is moved into that column's own main effect, for every pairwise-interaction term that has both constituent main effects present.

model.feature_importance(X)                  # x1: 1.20, x2: 0.96, "x1 x x2": 0.90
model.feature_importance(X, purify=True)      # x1: 0.84, x2: 0.65, "x1 x x2": 0.25
model.predict(X)                              # completely unaffected either way

zoneboost's own per-round tables aren't a stable, shared 2D array the way EBM's are — zones are re-derived every round, and each round's own Lasso gives every term a different weight — so purification can't safely move mass between raw round-level tables before that weighting is applied (only equal weights would preserve the summed prediction). Instead it operates on explain(X)'s own already-weighted, already-summed-across-every-round contribution table: "main effect A", "main effect B", and "A x B" are just three ordinary columns of the same row there, so moving mass between them trivially leaves that row's own sum unchanged — predict(X) is never touched, only the split explain/feature_importance report.

The purified split is canonical relative to the specific X passed in — the empirical measure it marginalizes against (quantile-binning continuous columns, exact groups for categorical ones). Calling it on a different dataset can give a different split; pass a representative dataset (e.g. the training data) for a stable result.

Measured, honestly: on synthetic data with genuinely correlated x1/x2 and a real 0.4 * x1 * x2 interaction (the classic setting where main-effect/interaction attribution becomes ambiguous), "x1 x x2"'s feature importance dropped from 0.895 to 0.253 after purification — a 71.7% reduction — while predict(X) stayed identical to machine precision. Across three different random_state refits of the same relationship, the interaction's importance had standard deviation 0.025 unpurified versus 0.012 purified — a real, if modest, reduction in refit-to-refit attribution variance.
Scope: pairs only, not triples (a more complex recursive extension, deferred); main effects aren't re-centered into baseline (a separate part of the full functional-ANOVA convention, not attempted here); regressor only. include_reliability's own values are unaffected by purify (reliability describes zone support, not attribution). Real extra compute (an iterative marginalization per pair), so opt-in; purify=False (the default) is bit-identical to every prior release.

Bootstrap Stability

explain(include_reliability=True) reports how much to trust a single fit's own contribution. BootstrapStability answers a different question: if you refit on another sample from the same population, how much would this contribution, this term's overall importance, whether a term shows up at all, or a prediction itself actually change?

from zoneboost import BootstrapStability, ZoneBoostRegressor

model = BootstrapStability(ZoneBoostRegressor(n_rounds=100), n_bootstrap=30, random_state=0)
model.fit(X, y)

model.inclusion_frequency()                 # Series: how often each term appears at all
model.feature_importance_interval(X)        # per-term interval on global importance
contrib_interval = model.contribution_interval(X)  # {term: DataFrame(lower, upper)}, per row
lower, upper = model.predict_confidence_interval(X)
lower, upper = model.predict_diff_interval(X_a, X_b)  # is this pair's difference real?

Same meta-estimator pattern as ConformalizedQuantileRegressor: wraps an unfit estimator template, refits it n_bootstrap times on independent bootstrap resamples (rows drawn with replacement, the standard nonparametric bootstrap — deliberately different from ConformalizedQuantileRegressor's without-replacement calibration split), and reports how much each of the above varies across those refits. Real, disclosed cost: n_bootstrap full model refits (default 30, not 100+, to keep the default reasonable) — a separate wrapper you opt into, not an estimator parameter.

Unlike ConformalizedQuantileRegressor (regressor-only, because quantile mode is), BootstrapStability works for both ZoneBoostRegressor and ZoneBoostClassifier — bootstrapping the whole fit procedure has no such restriction. predict_confidence_interval/predict_diff_interval support regressors and binary classifiers (via predict_proba(X)[:, 1]) — a multiclass model has no single scalar per row to bootstrap there, so those two raise ValueError; contribution_interval/feature_importance_interval/ inclusion_frequency fully support multiclass (nested per class the same way explain_reliability already nests reliability).

predict_confidence_interval is named distinctly from predict_interval (already used by ZoneBoostRegressor/ ConformalizedQuantileRegressor for conformal, coverage-guaranteed intervals) because it's a genuinely different statement: model/estimation uncertainty from resampling, not a distribution-free coverage guarantee for a future observation of y.

Deferred: boundary-position uncertainty (how much zone cut points themselves move across bootstrap fits) — different bootstrap fits can produce a different number of zones for the same column, so there's no clean 1:1 alignment to summarize without real additional machinery.

Measured, honestly: on synthetic data with one genuine-signal column and several pure-noise columns, feature_importance_interval gave the signal column an interval of [2.19, 2.69], with every noise column's interval sitting entirely below 0.075 — no overlap. On data with a genuine a × b interaction (no real main effects) among 15 noise columns, under max_pair_interactions screening, inclusion_frequency gave the genuine interaction 0.96 (selected in 24 of 25 bootstrap refits) versus a mean of 0.32 (max 0.76) across every spurious pair — a real, honest separation, not a guaranteed one for every dataset.

Evidence Report

explain(include_reliability=True) reports reliability per term. evidence_report(X) (requires track_reliability=True at fit time) combines every term behind a specific prediction into one per-row summary — "should this prediction be trusted," not "how reliable is each term separately":

model.evidence_report(X)
#    extrapolating  unobserved_cell  pct_contribution_from_sparse_cells  evidence_score evidence_quality
# 0          False            False                                0.0             1.0             High
# 1           True            False                                0.0             0.5              Low

Measured, honestly, on synthetic data with a dense region and a small secondary cluster elsewhere in the range: a typical row (x=-2.0, support 667.4) scored evidence_score=1.0 ("High"); a row in the small secondary cluster (x=5.5, support 32.1) scored 0.5 ("Low"), flagged extrapolating=True. On data with a genuine interaction, a typical row's joint-cell support was 232.8 ("High" evidence) versus 28.0 for a sparser joint corner ("Low" evidence) — a real, if imperfect, contrast: which specific signal (extrapolation vs. low cell support) drives a "Low" verdict varies by dataset, exactly as its per-term ingredients would suggest.

Multiclass: nested per class ({class_label: DataFrame}), matching explain_reliability's own nesting — unlike feature_importance (which averages across classes), per-prediction evidence genuinely differs class to class.

Audited Human Editing

ZoneBoostRegressor.edit_effect(feature, value_range, contribution, X_eval=None, y_eval=None) lets a domain expert directly override a column's main-effect contribution for a specific input region — but, unlike silent editing, always returns a report on the edit's consequences:

report = model.edit_effect("affordability", (0.10, 0.20), contribution=0.25, X_eval=X, y_eval=y)
# {'affected_rows': 411, 'affected_fraction': 0.103,
#  'original_contribution_mean': 0.318, 'contribution_change': -0.068,
#  'exceeds_uncertainty': True, 'constraint_violation': False,
#  'rmse_before': 0.148, 'rmse_after': 0.150, 'prediction_mean_shift': -0.007}

Why "effect", not "zone": the roadmap's own sketch (edit_zone(feature=, zone=, contribution=)) assumes EBM-style fixed, stable bins shared across the whole model. zoneboost's zones are adaptively re-derived every boosting round from that round's own row/column subsample — there's no single, stable "zone" per column to name across the whole model. Editing is instead defined on the raw feature value range, always well-defined regardless of how any given round happened to bin it.

The edit takes effect immediately — predict/explain reflect it from that call onward, and explain(X) still sums exactly to predict(X) afterward (the same replacement is applied to the one term's own column, nothing else changes). Multiple overlapping edits: the last one applied wins for affected rows. reset_overrides() clears every edit — a cheap, reversible safety net.

Scope: regressor only, main effects only (not interactions/ triples), continuous columns only. The report covers affected population, contribution change, whether the change exceeds the region's own cross-fold uncertainty (requires track_reliability=True), whether it violates a declared bounded_effects bound, and (with X_eval/ y_eval) predictive-performance change and a simple prediction- distribution-shift proxy. Deferred: classifier support (multiclass raises the question of which class's log-odds an edit should target — its own design fork), fairness impact (needs a protected-attribute and fairness-metric design of its own), calibration change (classifier-specific), and monotonic- constraint-violation detection (would need a "neighboring un-edited region" comparison zoneboost's adaptive zones don't have a stable enough concept of to check cheaply and honestly).

Measured, honestly: on synthetic data, overriding affordability's contribution to 0.25 for values in [0.10, 0.20] (411 affected rows, originally averaging 0.318) nudged held-out RMSE from 0.148 to 0.150 — a small, plausible cost for a business-rule correction — and was flagged exceeds_uncertainty=True: even this modest a change exceeded twice the region's own cross-fold standard deviation, since ~400 supporting rows produce a fairly tight cross-fold estimate. The mechanism errs toward flagging edits rather than missing genuine ones, by design — a false "exceeds uncertainty" costs a second look; a missed one costs trust in a "governed" editing feature.

Zone-Native Counterfactuals

ZoneBoostRegressor.counterfactual(X, target, actionable, immutable=None, tol=None, n_candidates=200) answers "what's the smallest change to these actionable features that gets the prediction to target?" — computed by directly evaluating the model's own exact, known predict() function over a dense grid, not by training a surrogate or searching an opaque black box the way generic counterfactual-explanation tools (DiCE etc.) must:

result = model.counterfactual(row, target=pred - 0.10, actionable=["affordability", "income"])
# Moving 'affordability' from 0.654 to 0.773 would change the prediction
# by -0.098, assuming other variables remain fixed.

Single-feature solutions are always preferred when one alone can reach target within tol — the fewest "zone transitions." A greedy, coordinate-descent-style multi-feature search is the fallback when no single feature suffices (a disclosed heuristic, not a guaranteed joint-optimal minimal change when actionable features genuinely interact).

Returns a dict: feasible, original_prediction/counterfactual_prediction/prediction_change, changes ({feature: (original_value, new_value)}), zone_transition_frequency ({feature: fraction} — of the rounds that included this feature, how often its own zone assignment actually changed — the honest "zone transitions" statistic, since zoneboost's zones are re-derived fresh every round, not a single stable index the way EBM's fixed bins would allow), interaction_consequences ({term: contribution_change} from explain(X) vs. explain(X_cf) — the exact decomposition explain already provides, separating a changed feature's own main effect from any interaction it participates in), extrapolating, and evidence (evidence_report's own output for the counterfactual row if track_reliability=True, else None — "confidence in the counterfactual," reusing evidence reports rather than inventing a new mechanism).

Scope: regressor only; actionable features must be continuous columns that appeared as a main effect in at least one round. Deferred: classifier support, and a guaranteed joint-optimal multi-feature search (the current one is a disclosed greedy heuristic).

Measured, honestly: on data with a genuine x1 × x2 interaction (no real main effects), targeting a value neither x1 nor x2 alone could reach moved both features, and interaction_consequences correctly showed the interaction term dominating the change — not either main effect — matching the true underlying relationship exactly.

Time-Based Drift Comparison

compare_models(model_old, model_new, X_eval, y_eval=None) is a top-level function (not a method) that compares two already-fitted ZoneBoostRegressor models — e.g. last quarter's model and this quarter's — on a shared evaluation dataset. zoneboost doesn't monitor anything over time or retain training data after fit, so there's no other way to compare "the same rows" across two fits; you retrain each period and call compare_models against the previous model:

from zoneboost import compare_models

result = compare_models(model_q1, model_q2, X_q2, y_q2)
result["feature_importance_change"]
#                 old       new    change
# age            0.94      0.56    -0.38
# age x income    0.60      0.28    -0.32
# income         2.20      2.12    -0.08

Returns a dict:

Scope: regressor only (classifier's multiclass, per-class nested rounds_ would meaningfully complicate every comparison here — deferred, disclosed); compares exactly two snapshots (call it pairwise across more periods for a longer trend); an aggregate boundary summary, not each model's full per-round boundary provenance.

Measured, honestly: on synthetic data engineered so a risk score's high-income boundary moves from $52k to $61k between two periods and an age x income interaction bump is roughly halved, compare_models reported the interaction term losing 53.8% of its importance (0.5980.276) and population_migration["income"] at 0.76 (76% of evaluation rows fall in a different income zone than the old model would have placed them) — boundary_shift["income"]["center_shift"] stayed small (both periods draw income from the same overall range), which is the expected, disclosed limitation above: the threshold moved, not the column's observed range.

Model Evidence Cards

model.evidence_card(X=None) assembles a compact, JSON-serializable snapshot of a fitted model — zones/boundaries, per-term support/shrinkage, constraint declarations, calibration/conformal coverage, unsupported regions, and reproducibility info — entirely from attributes already exposed after fit. Pure aggregation, no new modeling math: every number in it is read off rounds_, get_params(), or an existing method (feature_importance, _observed_range).

import json

card = model.evidence_card(X)  # X optional -- see below
print(json.dumps(card, indent=2))

X is optional and only needed for two pieces that genuinely require scored data: each term's mean_abs_contribution (via feature_importance) and dataset_fingerprint (a row-hash of X via pandas.util.hash_pandas_object) — every other section is available with no arguments. Returned keys:

Scope: regressor only. A term's "stability/uncertainty" is whatever track_reliability already provides — full bootstrap stability (BootstrapStability) lives on a separate wrapped estimator instance and isn't reachable from a plain model's evidence_card(), so it isn't included. Only the last round's own zone boundaries are reported per column, not a full per-round history.

Measured, honestly: on a model fit with monotonic_constraints, track_reliability=True, and loss="quantile", evidence_card(X) produced a ~3KB JSON document covering all 3 predictor columns and every main-effect/interaction term, round-tripping cleanly through json.dumps/json.loads with no manual type coercion needed.

How This Differs from SHAP/LIME