Model Explainability¶
ModelExplainer explains a fitted credit model with six methods behind one object: SHAP, Owen value, PDP, ICE, ALE, and
LIME. It lives in Modeling_Tool.Explainability and is also importable from Modeling_Tool.
Use it to answer the three questions a model review asks: which variables drive the score (global importance), why one applicant received a score (local reason codes), and how the score responds when a variable changes (effect curves).
Install the optional dependencies
SHAP, Owen value, and LIME need the explain extra. PDP, ICE, and ALE need nothing beyond SMF's core dependencies.
shap and lime are imported lazily, when the first SHAP, Owen, or LIME call runs. If a package is missing, that call
raises an ImportError that names the extra.
Methods at a glance¶
| Method | Scope | Question it answers | Needs |
|---|---|---|---|
| SHAP | Global and local attribution | Which variables contribute most? Why did this applicant score high? | shap |
| Owen value | Group attribution | How much do business modules such as delinquency, multi-lending, and affordability contribute? | shap |
| PDP | Global average effect | When a variable increases, how does the average predicted risk change? | none |
| ICE | Individual response curves | Do different applicants respond to a variable in the same way? | none |
| ALE | Accumulated local effects | What is the effect of a variable when features are strongly correlated? | none |
| LIME | Local surrogate model | Near one applicant, which variables explain the score? | lime |
Supported models¶
ModelExplainer accepts a GradientBoostingModel, an LRMaster, or a fitted estimator with predict_proba. It picks the
SHAP explainer from the model family:
| Model | SHAP explainer | Unit of SHAP values | background_data |
|---|---|---|---|
| LightGBM, XGBoost | TreeExplainer |
log-odds | optional |
LRMaster, LogisticRegression |
LinearExplainer |
log-odds | required |
| Anything else (CatBoost, random forest, ...) | Model-agnostic shap.Explainer over predict_proba |
probability | required; slow with many features |
PDP, ICE, ALE, and LIME call predict_proba, so their outputs are in probability units for every model.
LRMaster(standardize=True)
The explainer unwraps the scikit-learn model inside LRMaster and skips its scaler, so unscaled inputs produce wrong
explanations. Pass frames scaled with the fitted scaler for both background_data and X, for example
pd.DataFrame(lr.standardizer.transform(df[features]), columns=features). Models trained without standardize need
no such step.
Example data¶
Every snippet on this page runs top to bottom in one Python session. The setup below creates numeric features in three
correlated groups (delinquency, multi-lending, affordability) plus one independent variable, and trains a LightGBM model.
Any numeric feature frame works the same way, for example the train_woe[woe_features] columns from the
Quickstart.
import numpy as np
import pandas as pd
from Modeling_Tool import GradientBoostingModel, ModelExplainer, SampleSplitter, build_coalition_structure
rng = np.random.default_rng(42)
n = 5000
dpd, loans, capacity = rng.normal(size=(3, n)) # three latent risk drivers
def noisy(base):
return base + 0.35 * rng.normal(size=n)
data = pd.DataFrame({
"max_dpd_12m": noisy(dpd), "dpd_cnt_6m": noisy(dpd), "ever_dpd30": noisy(dpd),
"inquiries_3m": noisy(loans), "inquiries_6m": noisy(loans), "active_loans": noisy(loans),
"monthly_income": noisy(capacity), "debt_to_income": noisy(capacity), "monthly_obligation": noisy(capacity),
"age": rng.normal(size=n),
})
logit = -1.6 + 0.9 * dpd + 0.5 * loans - 0.6 * capacity + 0.2 * data["age"]
data["bad_flag"] = rng.binomial(1, 1 / (1 + np.exp(-logit)))
features = [c for c in data.columns if c != "bad_flag"]
train, test = SampleSplitter(test_size=0.3, random_state=42, stratify=True).split_df(data, target="bad_flag")
gbm = GradientBoostingModel("lgb", {
"n_estimators": 200,
"learning_rate": 0.05,
"max_depth": 3,
"early_stopping_rounds": 20,
"eval_metric": "auc",
"verbose": -1,
"n_jobs": 1, # one thread is fastest on a sample this small
})
gbm.fit(train[features], train["bad_flag"], test[features], test["bad_flag"])
1. Create an Explainer¶
ModelExplainer(model, feature_names=None, model_type=None, background_data=None):
| Argument | Meaning |
|---|---|
model |
A GradientBoostingModel, an LRMaster, or a fitted estimator |
feature_names |
Column order the model expects. Inferred when the model records its feature names; pass it for estimators that do not |
model_type |
"lgb", "xgb", "lr", or any other string. Detected automatically; override it only if detection is wrong |
background_data |
A representative sample of the training features; a few hundred rows is enough. Optional for LightGBM and XGBoost SHAP, required for LR, Owen, and the model-agnostic SHAP path. It is also the default X_train for LIME and the default data for the coalition structure |
The frames you pass to later methods may contain extra columns: the explainer selects the model's features by name.
2. SHAP Attribution¶
explainer.explain(test[features]) # computes and caches SHAP values
importance = explainer.feature_importance(normalize=True)
print(importance[["feature", "mean_abs_shap", "importance_pct"]].head(10))
local = explainer.explain_instance(test[features].iloc[[0]])
print(local)
print("base value:", local.attrs["base_value"])
explainer.summary_plot(show=False, save_path="shap_summary.png")
explainer.summary_plot(plot_type="bar", show=False, save_path="shap_bar.png")
explainer.dependence_plot("max_dpd_12m", show=False, save_path="shap_dependence.png")
| Method | Returns |
|---|---|
explain(X) |
A shap.Explanation. It also caches shap_values_, expected_value_, and explanation_ on the explainer |
feature_importance(X=None, normalize=False) |
feature, mean_abs_shap, sorted descending. normalize=True adds importance_pct, each feature's share of the total (the shares sum to 1, not 100). With X it recomputes the SHAP values; without X it reads the cache and raises RuntimeError if there is none |
explain_instance(x_row) |
feature, value, shap_value for one applicant, sorted by absolute SHAP value. x_row is a one-row DataFrame, a Series, or a dict; only the first row is used. .attrs["base_value"] holds the baseline |
summary_plot(X=None, max_display=20, plot_type="dot", show=True, save_path=None, random_state=0) |
A matplotlib figure. plot_type is for example "dot" (beeswarm, the default) or "bar"; random_state seeds the point jitter |
dependence_plot(feature, X=None, interaction_index="auto", show=True, save_path=None) |
A matplotlib figure for one feature |
For tree and linear models the SHAP values are additive in the model's log-odds: base_value + sum(shap_value) equals the
model's raw score for that row. Plot methods write the figure with dpi=150 to save_path, whose folder must already
exist. show=False closes the figure after saving, which is what you want in scripts and jobs.
3. Owen Value: Group Attribution¶
Plain SHAP treats every feature as an independent player. When several variables carry the same risk signal, each one looks less important than the signal really is. The Owen value first places features in coalitions and then splits each coalition's credit among its members, so the result reads at the business-module level: "delinquency pushed the score up by 0.4 log-odds" rather than three small contributions.
3.1 Build the Coalition Structure¶
prior_groups = {
"delinquency": ["max_dpd_12m", "dpd_cnt_6m", "ever_dpd30"],
"multi_lending": ["inquiries_3m", "inquiries_6m", "active_loans"],
"affordability": ["monthly_income", "debt_to_income", "monthly_obligation"],
}
coalitions = build_coalition_structure(
train[features],
prior_groups=prior_groups,
threshold=0.35,
method="complete",
corr_method="spearman",
)
print(coalitions["summary"][["n_features", "mean_abs_corr", "max_abs_corr"]])
build_coalition_structure(X, prior_groups=None, threshold=0.35, method="complete", corr_method="spearman", min_group_size=1, intra_dist=0.01, inter_dist=0.99)
combines your business groups with data-driven clustering:
- Business priors win.
prior_groupsmaps a group name to its features. Names that are not columns ofXare ignored, but a feature that is inXcannot appear in two groups (ValueError). - Features outside every prior group fall back to automatic clustering on the distance
1 - |association|. The leftover features of an automatic cluster form a group namedresidual_auto_cluster_<k>. Without priors, the groups are namedauto_cluster_<k>.
| Argument | Meaning |
|---|---|
X |
Numeric DataFrame of the model features |
prior_groups |
{group: [features]}, or None for fully automatic groups. CREDIT_PRIOR_GROUPS (also exported from Modeling_Tool) is a ready-made example for common credit variable names |
threshold |
Cut height on the distance 1 - \|association\|, between 0 and 1. With method="complete", every pair in an automatic group has \|corr\| >= 1 - threshold (0.65 at the default). Raise it for looser, larger groups; lower it for tighter ones |
method |
Linkage: "complete", "average", or "single" |
corr_method |
"spearman" (default), "pearson", "kendall", or "MIC" (case-insensitive) |
min_group_size |
Automatic groups smaller than this are merged into one group named auto_singleton |
intra_dist, inter_dist |
Distances inside and between groups in the partition tree handed to SHAP. The defaults rarely need changing |
It returns a dictionary:
| Key | Content |
|---|---|
groups |
Final {group: [features]} mapping |
auto_groups |
The automatic clusters before priors were applied |
summary |
A DataFrame indexed by group with n_features, mean_abs_corr, max_abs_corr, and features. Single-feature groups report NaN correlations |
features, threshold, method, corr_method |
The inputs that produced the structure |
corr_lnk, shap_lnk |
SciPy linkage matrices: the correlation dendrogram and the one SHAP uses |
ModelExplainer.build_coalition_structure(X=None, prior_groups=None, ...) takes the same arguments, builds the structure
from X (or background_data when X is omitted), and caches it as explainer.coalition_structure_.
Capturing Nonlinear Association with MIC¶
Spearman is a rank correlation and suits monotonic relationships. If variables are related nonlinearly (for example x
and x²), switch to the Maximal Information Coefficient (MIC):
# check: skip (needs the optional minepy package)
coalitions_mic = build_coalition_structure(train[features], threshold=0.35, corr_method="MIC")
MIC needs minepy (Python 3.10 only)
MIC uses the optional minepy package, which the mic extra installs only on Python 3.10. The extra is skipped on
Python 3.11 and later, where corr_method="MIC" raises an ImportError that points you back to "spearman" unless
you install minepy yourself.
thresholdstill applies to the distance1 - MIC: the higher the MIC, the more likely two features share a group.- In
summary,mean_abs_corrandmax_abs_corrthen hold the within-group mean and maximum MIC. - MIC is computed for every pair of features, so it is much slower than Spearman on wide frames.
3.2 Compute Owen Values with ModelExplainer¶
owen_x = test[features].head(10) # Owen values are slow: explain a small batch
explainer.explain_owen(
owen_x,
coalition_structure=coalitions,
model_output="log_odds",
max_evals=200,
silent=True, # hide the SHAP progress bar
)
# Group-level global importance over the batch
group_importance = explainer.owen_group_importance(normalize=True)
print(group_importance[["group", "n_features", "mean_abs_owen", "importance_pct"]])
# Group-level reason codes for the first row of the batch
reason_codes = explainer.owen_explain_instance()
print(reason_codes[["group", "owen_value", "abs_owen_value", "features"]])
print("base value:", reason_codes.attrs["base_value"])
explain_owen(X, coalition_structure=None, prior_groups=None, threshold=0.35, method="complete", corr_method="spearman", background_data=None, model_output="probability", rebuild=False, **explain_kwargs):
| Argument | Meaning |
|---|---|
coalition_structure |
The dictionary from 3.1. If omitted, the explainer reuses its cached structure or builds one from background_data (or from X when there is none) |
prior_groups, threshold, method, corr_method |
Used only when the structure is built here. Passing prior_groups always rebuilds the structure, even if you also pass coalition_structure |
background_data |
Background sample for building the structure and the SHAP explainer; defaults to the explainer's own. A background whose values differ from those the cached explainer was built with makes it rebuild |
model_output |
"probability" (default) or "log_odds". Use "log_odds" for reason codes: group contributions then add up on the log-odds scale |
rebuild |
Force a new SHAP PartitionExplainer. It is rebuilt automatically when the coalition structure (its features and linkage), the background values, or model_output differ from those it was built with, and reused otherwise, for example when only X changes |
**explain_kwargs |
Passed to the SHAP call, for example max_evals (default 500) and silent |
It returns a shap.Explanation and caches the results as owen_values_, owen_expected_value_, and owen_explanation_.
Owen values are additive: base_value + sum(owen_value) equals the model output (log-odds with model_output="log_odds")
for each row.
| Method | Returns |
|---|---|
owen_group_importance(X=None, normalize=False) |
group, n_features, features, mean_owen, mean_abs_owen, sorted by mean_abs_owen; normalize=True adds importance_pct (a share that sums to 1). Group values are the sum of the members' Owen values per row |
owen_feature_importance(X=None, normalize=False) |
feature, mean_abs_owen (and importance_pct) per feature |
owen_explain_instance(x_row=None, aggregate_groups=True) |
Reason codes for one row: group, n_features, features, owen_value, abs_owen_value, sorted by absolute value, with .attrs["base_value"] and .attrs["model_output"]. aggregate_groups=False returns one row per feature instead (feature, value, owen_value, abs_owen_value). Without x_row it reads the first row of the cached batch |
owen_explain_instance(x_row) replaces the cached batch
Passing x_row re-runs the explainer for that single row with model_output="probability" and overwrites the
cached results, so a later owen_group_importance() describes one row. To get log-odds reason codes for another
applicant, call explainer.explain_owen(row_frame, model_output="log_odds") first, then explainer.owen_explain_instance()
with no argument. Compute batch importance before you do either.
3.3 One-Step Call¶
If you do not need to inspect the grouping separately, pass the prior groups straight into explain_owen(). The
correlation clustering then runs on the explainer's background_data (or on X when there is none):
explainer.explain_owen(
owen_x,
prior_groups=prior_groups,
threshold=0.35,
model_output="log_odds",
max_evals=200,
silent=True,
)
print(explainer.owen_group_importance().head())
print(explainer.coalition_structure_["groups"])
4. PDP: Average Marginal Effect¶
pdp = explainer.partial_dependence(
test[features],
feature="max_dpd_12m",
grid_resolution=50,
sample_size=1000,
random_state=42,
prediction_batch_size=100000,
)
print(pdp.head())
explainer.pdp_plot(test[features], feature="max_dpd_12m", show=False, save_path="pdp_max_dpd.png")
partial_dependence(X, feature, grid_resolution=50, percentiles=(0.05, 0.95), sample_size=None, random_state=None, prediction_batch_size=100000)
sets the feature to each grid value for every row, predicts, and averages. The grid runs from the 5th to the 95th
percentile of the feature (percentiles). It returns feature, grid_value, and average_prediction.
5. ICE: Individual Response Curves¶
ice = explainer.ice(
test[features],
feature="max_dpd_12m",
grid_resolution=50,
sample_size=200,
random_state=42,
prediction_batch_size=100000,
)
print(ice.head())
explainer.ice_plot(test[features], feature="max_dpd_12m", centered=True, show=False, save_path="ice_max_dpd.png")
ice(X, feature, grid_resolution=50, percentiles=(0.05, 0.95), sample_size=200, random_state=None, centered=False, prediction_batch_size=100000)
returns one curve per sampled row: feature, sample_index (the row label in X), grid_value, prediction. With
centered=True, each curve is shifted to start at 0 at the first grid point, which makes the shapes easy to compare.
ice_plot has the same arguments plus show and save_path, and its sample_size defaults to 100.
6. ALE: Accumulated Local Effects¶
ale = explainer.ale(
test[features],
feature="inquiries_3m",
bins=20,
prediction_batch_size=100000,
)
print(ale.head())
explainer.ale_plot(test[features], feature="inquiries_3m", bins=20, show=False, save_path="ale_inquiries.png")
ale(X, feature, bins=20, sample_size=None, random_state=None, prediction_batch_size=100000) cuts the feature into bins
equal-count intervals, measures the average change in prediction across each interval using only rows inside it, and
accumulates those changes. The values are centered so that their count-weighted mean is 0. It returns feature,
bin_left, bin_right, bin_center, ale_value, and n (rows per bin). ale_plot takes the same arguments plus
show and save_path.
PDP, ICE, and ALE support one numeric feature at a time, and feature must be one of the model's input columns (a
name, or an integer position). To study a categorical variable, explain its WOE-encoded numeric column.
7. LIME: Local Surrogate Explanation¶
lime_one = explainer.lime_explain_instance(
x_row=test[features].iloc[0],
X_train=train[features],
num_features=10,
num_samples=5000,
random_state=42,
)
print(lime_one)
print(lime_one.attrs["score"]) # R² of the local linear surrogate
lime_global = explainer.lime_global_importance(
X=test[features].sample(20, random_state=42),
X_train=train[features],
num_features=10,
num_samples=500,
random_state=42,
)
print(lime_global)
lime_explain_instance(x_row, X_train=None, num_features=10, num_samples=5000, random_state=None, missing_strategy="median", **lime_kwargs)
returns feature, feature_rule, weight, abs_weight, sorted by abs_weight. x_row is a Series, a dict, or a
one-row frame. X_train defaults to background_data. attrs["intercept"] (a dict keyed by class label) and
attrs["score"] describe the local linear model. Extra keyword arguments go to LimeTabularExplainer, for example
discretize_continuous=False to fit the surrogate on continuous features instead of binned ones.
lime_global_importance(X, X_train=None, num_features=10, num_samples=2000, sample_size=100, random_state=None, missing_strategy="median", **lime_kwargs)
explains up to sample_size sampled rows of X and returns feature, mean_abs_lime_weight, and frequency (in how many
of the explained rows the feature was among the top num_features). The mean is taken over those rows only.
missing_strategy handles NaN in X_train and x_row: "median" fills with training medians, "drop" removes the rows;
both issue a UserWarning.
Performance
Owen, PDP, ICE, ALE, and LIME all call the model many times. PDP, ICE, and ALE stack the grid rows and predict in
batches of prediction_batch_size (default 100000, None for a single call); lower it to cap the memory of each predict
call (the stacked grid itself is still built in memory; sample_size shrinks it). On large
data, use sample_size, a small background_data sample, and a modest max_evals. Owen values are the slowest
method: in our tests, explaining one row took about a second with 100 background rows and max_evals=500.
The one-click CreditModelPipeline can run these explainers for you; see its explain_models, owen_enabled, and
business_prior_groups options in Top-Level Pipelines.
FAQ¶
How are Owen value and SHAP related?
The Owen value is a Shapley value computed with a coalition structure. SMF uses shap.PartitionExplainer, which
respects the grouping first and then splits credit within each group.
When should I use the Owen value?
Use it when variables are highly correlated, or when the business needs module-level reason codes. With several variables from one source (delinquency, multi-lending, debt capacity, device fraud), single-feature SHAP dilutes the attribution across them.
What does threshold=0.35 mean?
Automatic clustering cuts the dendrogram at distance 1 - |association|. With the default
corr_method="spearman" and method="complete", a threshold of 0.35 puts two features in one automatic group only if
every pair in the group has |corr| >= 0.65. With corr_method="MIC", the same threshold applies to 1 - MIC. Use
0.20 to 0.35 to group only strongly correlated variables, or about 0.50 to merge more loosely related ones.
When should I use corr_method='MIC'?
When variables have a clear nonlinear association that Spearman misses. MIC is slower and needs minepy, which the
mic extra installs on Python 3.10 only. Elsewhere, keep "spearman".
How do I choose between PDP and ALE?
With weakly correlated features, PDP is intuitive and easy to explain. With strongly correlated features, prefer ALE: PDP averages predictions at feature combinations that never occur in the data, while ALE changes the feature only inside local intervals.
Is LIME a global explanation?
No. LIME explains one applicant at a time. lime_global_importance() aggregates many local explanations, which gives
only an approximate global ranking.
ImportError for shap or lime
Install the extra: pip install 'supermodelingfactory[explain]'.
My Owen reason codes are in probability although I asked for log-odds
You passed x_row to owen_explain_instance(). It recomputes that row with model_output="probability". See the
warning in 3.2.
SHAP values for my CatBoost or random forest model are in probability units and slow to compute
Only LightGBM, XGBoost, and logistic regression get a dedicated SHAP explainer. Every other model goes through the
model-agnostic explainer on predict_proba, which works in probability units and evaluates the model many times per
row. Use a small background_data sample and explain a subset of rows.
XGBoost with SHAP raises could not convert string to float: '[5E-1]'
This is a known incompatibility between XGBoost 3.1 and later and older SHAP releases. XGBoost writes the base_score
of a single-target model as a one-element array string such as "[5E-1]", which the older SHAP parser reads as a
scalar.
When ModelExplainer builds the XGBoost TreeExplainer, SMF patches SHAP in memory: it normalizes a single-element
base_score while SHAP decodes the model, and it retries once through XGBTreeModelLoader. The patch applies only to
the tree path for model_type "xgb" or "xgboost"; LightGBM, LR, Owen, PDP, ICE, ALE, and LIME are unaffected.
If SHAP still rejects the model, SMF raises a ValueError that lists the detected versions. Then, in this order:
- Upgrade SMF to the latest release.
- On Python 3.11 or later, upgrade to
shap>=0.50.0. - Only if multi-class or multi-target models still fail, pin
xgboost<3.1as a last resort.
SMF never collapses a multi-element base_score vector to its first element, because that would misattribute
multi-target explanations.