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):
support— the row's own zone/cell row count, backing the shrunk value that produced its contribution.shrinkage_fraction— the weight the empirical-Bayes prior carried in that shrunk value (shrinkage_m / (count + shrinkage_m)) — near0when a zone has plenty of its own data, near1when it was mostly pulled toward its prior.cross_fold_std— how much that zone's shrunk value varied across thecross_fit_foldscross-fitting folds; reuses the same fold loop already built for honest training, rather than adding a new one.n_rounds_present— how many of the model's rounds actually fit this term at all.boundary_weight/extrapolation_frac(continuous main effects only, notrack_reliabilityrequired) — how close the row sits to a zone boundary, and what fraction of contributing rounds saw this row's raw value fall outside the zones that round actually fitted.
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.
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.
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.
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
extrapolating—Trueif any continuous main effect's raw value fell outside the zones some contributing round actually fitted (reusesexplain_reliability's ownextrapolation_fracdirectly).unobserved_cell—Trueif any term's averagesupportis below1.0— essentially no training rows ever backed that zone/cell.pct_contribution_from_sparse_cells— of this row's totalsum(|contribution|), the fraction contributed by terms whose averagesupportis belowsparse_threshold(default:shrinkage_mitself, the empirical-Bayes half-trust point) — directly the "43% of the contribution comes from..." style statistic.evidence_score/evidence_quality—1 - pct_contribution_from_sparse_cells, halved again if extrapolating, binned intoLow/Medium/High— an honestly disclosed heuristic combination into one number, not a calibrated statistical score.
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.
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).
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:
feature_importance_change— a DataFrame indexed by term name (old/new/changecolumns, outer-joined so a term absent from one model contributes0), sorted by|change|descending — directly the "the interaction lost X% of its importance" statistic.new_terms/disappeared_terms— term names present in only one model'sfeature_importance.boundary_shift—{feature: {"old_range", "new_range", "center_shift"}}for every continuous main-effect column present in both models, comparing each model's own observed range (min/max of that column's zone centers across its rounds — the same rangecounterfactualuses). This tracks how the column's observed range itself moved, not the location of any particular decision threshold — a threshold sitting anywhere inside a stable range won't show up here; look atpopulation_migrationandfeature_importance_changefor that instead.population_migration—{feature: fraction}: using each model's own last fitted round's zone boundaries (a representative snapshot, not a full per-round history), the fraction ofX_evalrows whose hard zone assignment differs between the old and new model — the honest signal for "a boundary moved enough to reclassify rows."performance_change—{"rmse_old", "rmse_new"}ify_evalgiven, elseNone.prediction_shift—{"mean", "std"}ofpredict_new(X_eval) - predict_old(X_eval), always available (noy_evalneeded).
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.598 → 0.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:
zoneboost_version,model_classreproducibility—get_params()plusrandom_statedataset_fingerprint—NonewithoutX; otherwise row/column counts, dtypes, and a hashfit_summary— round counts, baseline, final train/validation RMSEzones— per predictor column: kind, observed range (continuous) or categories seen (categorical), read from that column's own last round as a main effect — a representative snapshot, not a full per-round boundary history (same disclosed precedent as Time-Based Drift Comparison and Hierarchical Zones)terms— per term across every round:mean_abs_contribution(NonewithoutX),mean_support_per_zone/mean_shrinkage_fractionaggregated directly from stored per-round diagnostics (Noneunlesstrack_reliability=Trueat fit time)shrinkage,constraints,calibration— the resolved fitted attributes already documented in the API Referenceunsupported_regions— each continuous main-effect column's observed range, the same rangeevidence_report's ownextrapolatingflag is built on
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
- No sampling. SHAP and LIME approximate a black-box model by
perturbing inputs and fitting a local surrogate;
explain(X)reads the terms the fitted model already stores inrounds_— no extra model evaluations, no approximation error. - Exact, not approximate.
contrib.sum(axis=1)equalsmodel.predict(X)to the last floating-point digit, because it's the same arithmetic, not an estimate of it. - Interactions stay whole. An
"A x B"term is reported as itself, rather than being credited fractionally back toAandBindividually the way Shapley-value attribution does.