Skip to content

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 bare ExplanationsConfig() 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_profiler ranks 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_pdp and compute_ale take an importance= ranking keyed by X'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