Global explanations¶
parleml_mli.explanations computes the global model-interpretability artifacts —
partial dependence, accumulated local effects, SHAP interactions, a decision-tree
surrogate, feature importance, drift, compacity, fairness, and an error tree. The
canonical entry point is explain, which returns one
ExplanationsResult containing the requested artifacts that could be computed.
In short
- Each artifact runs when you give its section, such as
pdp=PdpConfig(). A bareExplanationsConfig()runs PDP, permutation importance and a surrogate. - PDP and ALE draw curves for the model's top drivers, never by column order, and record why any column was skipped.
- If one artifact fails, it warns and stays
None; the others still compute. - The profile's PSI measures how well the target rows separate from the rest. It is not drift: pass a reference-versus-current PSI to grade drift.
- Fairness reports AIR, mean effect and SMD per group, against the highest-rate eligible group.
Two ways in¶
As part of profiling — set ProfilingConfig(explanations=ExplanationsConfig(...))
and read result.explanations_:
from parleml_mli import (
ProfilingConfig, ExplanationsConfig, PdpConfig,
PermutationImportanceConfig, PsiConfig, fit_profiler,
)
config = ProfilingConfig(
explanations=ExplanationsConfig(
pdp=PdpConfig(),
permutation_importance=PermutationImportanceConfig(),
psi=PsiConfig(),
),
)
result = fit_profiler(
model=clf, X=X_train, y=y_train, target_mask=(y_train == 1),
config=config,
)
expl = result.explanations_
Or lazily on an existing result:
from parleml_mli.result import build_explanations_result
from parleml_mli.config import ExplanationsConfig, PdpConfig, FairnessConfig
result.explanations_ = build_explanations_result(
result, X=X_train,
config=ExplanationsConfig(
pdp=PdpConfig(),
fairness=FairnessConfig(sensitive_features=X_train[["gender"]]),
),
)
Standalone — call explain on any fitted model + frame:
from parleml_mli import (
explain, ExplanationsConfig, PdpConfig, PermutationImportanceConfig,
PsiConfig, CompacityConfig, FairnessConfig,
)
expl = explain(
model=clf,
X=X_test,
y=y_test,
config=ExplanationsConfig(
pdp=PdpConfig(),
permutation_importance=PermutationImportanceConfig(),
psi=PsiConfig(),
compacity=CompacityConfig(),
fairness=FairnessConfig(),
),
)
Artifact sections¶
With ProfilingConfig(explanations=None), the explanations stage is skipped.
A bare ExplanationsConfig() enables PDP, permutation importance and a
surrogate at consumption time. ExplanationsConfig.default() makes that trio
explicit. Once you specify at least one artifact sub-section, only the supplied
sections run; the remaining None sections are skipped:
| Section | Config class | Produces |
|---|---|---|
pdp |
PdpConfig |
Partial dependence curves (PDPResult) |
ale |
AleConfig |
Accumulated local effects |
shap_interactions |
ShapInteractionsConfig |
Pairwise SHAP interaction strengths |
surrogate |
SurrogateConfig |
Decision-tree surrogate of the predictions (SurrogateResult) |
surrogate_residual |
ResidualSurrogateConfig |
Surrogate of the residuals (where the model errs) |
permutation_importance |
PermutationImportanceConfig |
Permutation importance (PermutationImportanceResult) |
psi |
PsiConfig |
Class divergence — PSI / Information Value (PSIResult) |
compacity |
CompacityConfig |
How few features explain each prediction (CompacityResult) |
fairness |
FairnessConfig |
Group fairness metrics via fairlearn (FairnessResult) |
error_tree |
ErrorTreeConfig |
Decision tree over the model's errors |
from parleml_mli import explain, ExplanationsConfig, PdpConfig, PsiConfig
expl = explain(
model=clf, X=X_test, y=y_test,
config=ExplanationsConfig(
pdp=PdpConfig(features=["amount", "age"], grid_resolution=40),
psi=PsiConfig(),
),
)
expl.pdp # dict[str, PDPResult] (empty when not requested)
expl.permutation_importance # PermutationImportanceResult | None
expl.psi # PSIResult | None
expl.fairness # dict[str, FairnessResult], one per sensitive feature
Which features get a curve¶
Partial dependence and ALE re-score the model for every point of every curve,
so each draws curves for a budget of n_features features: the model's top
drivers. Frame column order never decides which ones.
fit_profilerranks them by the profile's own importance: native importance, or mean |SHAP| when the model has none. That is the ranking the importance plots show.explain(...),compute_pdpandcompute_aletake animportance=ranking keyed byX's columns. With no ranking they use the model's prediction sensitivity: each candidate column is shuffled over a 1,000-row sample and scored by the mean absolute change in the response the curve plots.
A column without positive importance is not a driver, so a run can return fewer
curves than n_features.
Some columns never get a curve because there is no axis to draw one along:
- a datetime column, or any column that is neither numeric nor categorical;
- an all-missing or constant column;
- a categorical with more than 50 levels (PDP), or any categorical (ALE; use PDP for those).
Identifier columns are not chosen automatically either. A column counts as an
identifier when it is near-unique and also has an ID-like name, ID-shaped
values, or the auto-increment signature of an integer key
(parleml_mli.core.identifiers, the same rule as the EDA leakage scan). Every column
without a curve is recorded with the reason:
expl.pdp_selection.basis # "importance" | "prediction_sensitivity" | "features"
expl.pdp_selection.features # the features with a curve, most important first
expl.pdp_selection.skipped # {"customer_id": "identifier column — …",
# "signup_date": "datetime column — …"}
expl.ale_selection # the same record for ALE
An explicit PdpConfig(features=[...]) is drawn as given, identifiers
included. A listed column that cannot get a curve is skipped with a
UserWarning and recorded the same way. compute_pdp and compute_ale return
a FeatureEffects: the curves keyed by feature, plus that selection.
Details: the PDP grid, archetype curves and the ICE band
The PDP grid is built from the observed values. A categorical gets its levels.
A numeric feature gets its distinct values, or grid_resolution points across
its 5th–95th percentiles when it has more distinct values than that. A row with
a missing value still counts toward every point's average, because the curve
sets the feature on every row. A categorical with missing values also gets a
last (missing) level: the mean response with the feature missing on every
row, NaN in the grid (parleml_mli.explanations.pdp.is_missing_level) and null in
report.json. It is computed on its own, so a model that cannot score a
missing value loses that level and keeps the curve. A numeric grid never holds
a missing value. An integer feature's grid holds integers only: a model trained
on integers never sees an in-between value, and a pipeline would truncate one
(an ml4 pipeline scores 2.75 exactly as 2).
A caller can supply a feature's grid instead:
compute_pdp(..., grids={feature: grid}) uses it as given, a (missing)
level in it included, and appends none. Only the column's type can then rule
the feature out, so a constant or all-missing column still gets the curve. The
per-archetype overlays work this way. Each cluster's curve
is compute_pdp re-run on that cluster's rows with the population curve's
grid, class and response. expl.pdp[feature].per_cluster_average[cluster]
therefore holds one value per point of expl.pdp[feature].grid, including
levels the cluster lacks and features it holds a single value of. Only
clusters of at least 50 rows get a curve. Each samples at most as many of its
rows as the population curve did (its n_samples_used), and at most 2,000.
build_explanations_result attaches the overlays for a profiled run. A caller
attaching them to curves of its own pairs each archetype label with its row
first. A profile's labels_ cover its target cohort, not the whole frame:
from parleml_mli.archetypes.profiling.overlays import attach_pdp_overlay, overlay_rows
rows = overlay_rows(X, labels=result.labels_, target_mask=result.target_mask_)
if rows is not None: # None: the labels do not describe X's rows
attach_pdp_overlay(curves, X=rows, labels=result.labels_, model=model)
The PDP's shaded band is the average ± 1σ of the per-row ICE curves, each
centred at the first grid point (PDPResult.ice_std). It starts at zero
width and widens only where rows disagree about the feature's effect, which
is a sign of interactions. Rows that merely start at different levels do not
widen it: for x1 + 3·x2 the band for x1 stays at zero. ALE stays on the
data the same way. Each bin edge is a value the feature takes (the
inverted-CDF quantile, as in Apley & Zhu's ALEPlot), and an integer or
boolean feature is scored only at its own values, in its own dtype.
Thresholds¶
explain resolves the binary decision threshold once, with
parleml_mli.core.threshold.resolve_threshold, and threads that one cut into the
surrogate, error tree and fairness. decision_threshold= accepts a float (used
as given), "f1" / "auto" / "f<beta>" (tuned on the model's positive-class
probabilities against y), or None — the default — which keeps the model's
own decision (predict, i.e. 0.5 on a probability) and never re-tunes.
served_threshold= (a deployed model's published cut) wins over it. A spec that
cannot be tuned (one class, or fewer than 10 rows of the rarer class) warns and
records source="degenerate" rather than passing a fallback off as measured.
The cut is ignored for multiclass (argmax) and regression, and the positive
class it applies to is the model's classes_[1] (or positive_class=). When
profiling, the same knob lives on
ProfilingConfig(evaluation=EvaluationConfig(decision_threshold=...)), and the
resolved record is result.threshold_.
Targets and labels¶
The positive class is read off the model's class order, not off y, so the
y-free artifacts (PDP, ALE, SHAP interactions, the prediction surrogate, PSI,
compacity) never depend on the labels. y is read by permutation importance,
the residual surrogate, fairness and the error tree, and by an F-beta
decision_threshold — labels one of those artifacts cannot use (a missing
value, a label outside the model's classes) fail that artifact alone with a
UserWarning, while an F-beta cut that cannot be tuned on y raises up front.
target_class= on any artifact is a class label as it appears in
model.classes_; a label the model does not have — a column index included —
raises ValueError.
Per-artifact failure isolation¶
explain(...) is the only path that produces a fully-populated
ExplanationsResult with one consistent config round-trip and per-artifact
failure isolation: if one artifact raises, it emits a single UserWarning and
leaves that field at None — the rest still compute. This matters for
production runs over many models.
Compute cost & progress¶
The model-agnostic artifacts are dominated by predict count: partial
dependence re-scores the model once per (feature × grid point), and permutation
importance once per (feature × repeat). On a slow model this can run for many
minutes, so each of these logs an up-front scale estimate and per-feature
progress under the parleml_mli.explanations logger — e.g.
PDP: 6 features x 30 grid = ~180 predicts on 2000 rows.
PDP 1/6: tenure
permutation importance: 8 features x 10 repeats = ~80 predicts on 2000 rows.
so a run that looks hung is legible at a glance (raise parleml_mli.explanations to
INFO).
Details: models that are expensive to score
Expensive-predict models. Some models (e.g. a tabular foundation /
in-context learner) re-encode their whole support set on every predict, which
makes those predict-bound artifacts orders of magnitude slower. The platform
compute layer detects this generically via the model's predict_is_expensive
flag and soft-caps the agnostic depth — drops the quadratic pairwise
SHAP-interactions pass and caps the PDP grid, permutation repeats and sample
sizes (ALE stays on — it is cheap and correlation-robust) — so Explain stays
responsive. The reduction is recorded as a transparent note on the result. It is
a soft default: an explicit full-depth (Deep) request runs at full resolution
anyway.
Fairness¶
compute_fairness reports AIR, mean effect and SMD per group of each sensitive
feature. The default reference is the highest-rate group among the groups with
at least min_group_size rows (default 30), so every eligible AIR is at most 1
and the EEOC four-fifths rule is the one-sided air_thresholds=(0.8, None);
pass an upper bound (e.g. (0.8, 1.25)) together with an explicit
reference_group for a two-sided review. The parity frame carries
air_low_pass, air_high_pass, me_pass, smd_pass and eligible (the group
has min_group_size rows); only eligible groups are ever flagged, and the
result records min_group_size so a viewer can say why a failing small group
is not.
Error tree¶
compute_error_tree fits a small tree on the model's per-row error indicator.
Its cohort_summary has one row per leaf, sorted by the lower 95 % Wilson bound
of the leaf's error rate, with the leaf's exact root→leaf condition chain in
rule_text: every cut is printed digit for digit (for XGBoost, the float64 cut
that reproduces the booster's float32 comparison), and (… OR NaN) marks the
branch that holds a column's missing values, so the text re-selects exactly the
rows its leaf holds.
Persistence¶
Every artifact result pickles with a schema_version and refuses a pickle of
another one: loading an artifact written under another schema
raises parleml_mli.StaleArtifactError (a ValueError) asking you to
re-fit the run, instead of loading fields that are missing or mean something
else.
Drift, fairness & trust¶
The fairness and leakage artifacts feed directly into the
Trust Summary: compute_trust_summary(result) reads
result.explanations_.fairness and eda_.leakage_report to grade the
fairness and leakage tiles.
The PSI artifact is different. result.explanations_.psi compares the
target class against the reference class on the same dataset — that
is Information Value (class-separability, where large values are good),
tagged comparison="class_vs_class" on the PSIResult. It is not
production drift, so the Trust drift tile does not grade it (it renders
an explanatory yellow instead — a highly discriminative model would
otherwise be flagged as "drifting"). To grade the drift tile, compute a
genuine train-vs-current PSI and pass it explicitly:
from parleml_mli.explanations.drift import compute_psi
drift = compute_psi(
X_current, X_train,
binning_strategy="reference",
comparison="monitoring", # ← marks it as a real drift report
)
trust = compute_trust_summary(result, drift_report=drift)
Next¶
- Interactive visualizations: plot every artifact.
- Evaluation, trust & model cards: grade the model on the evidence.