Shapash - Counterfactual Business Scenarios¶
This notebook shows how to compare business action scenarios for an at-risk customer and identify the best lever using local explanations.
[ ]:
import numpy as np
import pandas as pd
from category_encoders import one_hot
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
from sklearn.model_selection import train_test_split
from shapash import SmartExplainer
1. Build a synthetic default target¶
We generate a synthetic consumer credit portfolio to illustrate default-risk scoring and counterfactual business scenarios. - 1: borrower likely to default - 0: borrower unlikely to default
[ ]:
rng = np.random.default_rng(42)
n_samples = 1800
df = pd.DataFrame(
{
"Age": rng.integers(21, 71, n_samples),
"Income": rng.normal(55000, 18000, n_samples).clip(18000, 160000).round(0),
"LoanAmount": rng.normal(25000, 12000, n_samples).clip(2000, 90000).round(0),
"CreditScore": rng.normal(680, 55, n_samples).clip(480, 840).round(0),
"EmploymentYears": rng.integers(0, 31, n_samples),
"NumLatePayments": rng.poisson(0.9, n_samples).clip(0, 8),
"DTI": rng.normal(0.32, 0.12, n_samples).clip(0.05, 0.8).round(3),
"HomeOwner": rng.choice(["Yes", "No"], n_samples, p=[0.62, 0.38]),
"MaritalStatus": rng.choice(["Single", "Married", "Divorced"], n_samples, p=[0.36, 0.52, 0.12]),
"LoanPurpose": rng.choice(
["Debt consolidation", "Car purchase", "Home improvement", "Medical", "Education"],
n_samples,
p=[0.34, 0.19, 0.21, 0.12, 0.14],
),
}
)
homeowner_flag = (df["HomeOwner"] == "Yes").astype(float)
purpose_risk = df["LoanPurpose"].map(
{
"Debt consolidation": 0.40,
"Car purchase": 0.10,
"Home improvement": 0.08,
"Medical": 0.22,
"Education": 0.06,
}
)
logit = (
-4.0
+ 3.0 * (df["LoanAmount"] / df["Income"]).astype(float)
+ 3.6 * df["DTI"].astype(float)
+ 0.018 * (700 - df["CreditScore"]).astype(float)
+ 0.65 * df["NumLatePayments"].astype(float)
- 0.10 * df["EmploymentYears"].astype(float)
- 0.45 * homeowner_flag
+ purpose_risk.astype(float)
+ rng.normal(0, 0.5, n_samples)
)
default_probability = 1 / (1 + np.exp(-logit))
df["Default"] = rng.binomial(1, default_probability)
features = [
"Age",
"Income",
"LoanAmount",
"CreditScore",
"EmploymentYears",
"NumLatePayments",
"DTI",
"HomeOwner",
"MaritalStatus",
"LoanPurpose",
]
target_name = "Default"
X_raw = df[features]
y = df[[target_name]]
X_train_raw, X_test_raw, y_train, y_test = train_test_split(
X_raw, y, test_size=0.25, random_state=42, stratify=y
)
encoder = one_hot.OneHotEncoder(cols=["HomeOwner", "MaritalStatus", "LoanPurpose"])
X_train = encoder.fit_transform(X_train_raw)
X_test = encoder.transform(X_test_raw)
clf = RandomForestClassifier(n_estimators=300, random_state=42, n_jobs=-1)
clf.fit(X_train, y_train.iloc[:, 0])
RandomForestClassifier(n_estimators=300, n_jobs=-1, random_state=42)In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
[3]:
def cls_metrics(y_true, y_pred):
return {
"accuracy": accuracy_score(y_true, y_pred),
"precision": precision_score(y_true, y_pred, zero_division=0),
"recall": recall_score(y_true, y_pred, zero_division=0),
"f1": f1_score(y_true, y_pred, zero_division=0),
}
pred_test = clf.predict(X_test)
metrics = pd.DataFrame([cls_metrics(y_test.iloc[:, 0], pred_test)], index=["test"])
metrics
[3]:
| accuracy | precision | recall | f1 | |
|---|---|---|---|---|
| test | 0.833333 | 0.734375 | 0.447619 | 0.556213 |
2. Explain baseline model¶
[4]:
feature_dict = {
"Age": "Age",
"Income": "Annual income",
"LoanAmount": "Loan amount",
"CreditScore": "Credit score",
"EmploymentYears": "Employment history",
"NumLatePayments": "Late payments",
"DTI": "Debt-to-income ratio",
"HomeOwner": "Homeowner status",
"MaritalStatus": "Marital status",
"LoanPurpose": "Loan purpose",
}
xpl = SmartExplainer(
model=clf,
preprocessing=encoder,
features_dict=feature_dict,
label_dict={0: "Low default risk", 1: "High default risk"},
title_story="Default risk scenario analysis",
)
y_pred_test_df = pd.DataFrame(clf.predict(X_test), index=X_test.index, columns=[target_name])
xpl.compile(
x=X_test,
y_pred=y_pred_test_df,
y_target=y_test,
additional_data=df.loc[X_test.index, [
"Age",
"Income",
"LoanAmount",
"CreditScore",
"EmploymentYears",
"NumLatePayments",
"DTI",
"HomeOwner",
"MaritalStatus",
"LoanPurpose",
]],
)
xpl.plot.features_importance()
INFO: Shap explainer type - <shap.explainers._tree.TreeExplainer object at 0x108dde000>
3. Pick one high-risk borrower¶
[5]:
proba_test = pd.Series(clf.predict_proba(X_test)[:, 1], index=X_test_raw.index)
customer_idx = proba_test.sort_values(ascending=False).index[0]
base_customer = X_test_raw.loc[[customer_idx]].copy()
base_risk = float(clf.predict_proba(encoder.transform(base_customer))[:, 1][0])
base_customer.assign(default_probability=base_risk).T
[5]:
| 1705 | |
|---|---|
| Age | 63 |
| Income | 58292.0 |
| LoanAmount | 46258.0 |
| CreditScore | 614.0 |
| EmploymentYears | 2 |
| NumLatePayments | 1 |
| DTI | 0.248 |
| HomeOwner | Yes |
| MaritalStatus | Single |
| LoanPurpose | Car purchase |
| default_probability | 0.843333 |
4. Define business scenarios (counterfactuals)¶
Simplified action rules for the demo: - loan_reduction: reduce LoanAmount by 20% - credit_repair: improve CreditScore and reduce recent late payments - income_growth: increase Income and lower DTI - full_restructure: combine all three actions
[6]:
def apply_scenario(base_row, scenario_name):
row = base_row.copy()
if scenario_name == "loan_reduction":
row.loc[:, "LoanAmount"] = np.maximum(1000.0, row["LoanAmount"].fillna(25000).iloc[0] * 0.8)
row.loc[:, "DTI"] = np.maximum(0.05, row["DTI"].fillna(0.3).iloc[0] * 0.9)
elif scenario_name == "credit_repair":
row.loc[:, "CreditScore"] = min(850.0, row["CreditScore"].fillna(680).iloc[0] + 40)
row.loc[:, "NumLatePayments"] = max(0, int(row["NumLatePayments"].fillna(0).iloc[0]) - 1)
elif scenario_name == "income_growth":
row.loc[:, "Income"] = row["Income"].fillna(55000).iloc[0] * 1.2
row.loc[:, "DTI"] = np.maximum(0.05, row["DTI"].fillna(0.3).iloc[0] * 0.85)
elif scenario_name == "full_restructure":
row.loc[:, "LoanAmount"] = np.maximum(1000.0, row["LoanAmount"].fillna(25000).iloc[0] * 0.8)
row.loc[:, "CreditScore"] = min(850.0, row["CreditScore"].fillna(680).iloc[0] + 40)
row.loc[:, "NumLatePayments"] = max(0, int(row["NumLatePayments"].fillna(0).iloc[0]) - 1)
row.loc[:, "Income"] = row["Income"].fillna(55000).iloc[0] * 1.2
row.loc[:, "DTI"] = np.maximum(0.05, row["DTI"].fillna(0.3).iloc[0] * 0.8)
else:
raise ValueError(f"Unknown scenario: {scenario_name}")
return row
scenario_names = ["loan_reduction", "credit_repair", "income_growth", "full_restructure"]
scenario_rows = []
for name in scenario_names:
row = apply_scenario(base_customer, name)
row.index = [name]
scenario_rows.append(row)
scenario_df = pd.concat(scenario_rows, axis=0)
scenario_proba = clf.predict_proba(encoder.transform(scenario_df))[:, 1]
result = pd.DataFrame(
{
"scenario": scenario_names,
"baseline_risk": [base_risk] * len(scenario_names),
"scenario_risk": scenario_proba,
"risk_delta": scenario_proba - base_risk,
}
)
result.sort_values("risk_delta")
[6]:
| scenario | baseline_risk | scenario_risk | risk_delta | |
|---|---|---|---|---|
| 3 | full_restructure | 0.843333 | 0.436667 | -0.406667 |
| 0 | loan_reduction | 0.843333 | 0.700000 | -0.143333 |
| 2 | income_growth | 0.843333 | 0.713333 | -0.130000 |
| 1 | credit_repair | 0.843333 | 0.740000 | -0.103333 |
5. Explain baseline vs best scenario¶
[7]:
best_scenario_name = result.sort_values("risk_delta").iloc[0]["scenario"]
best_row = scenario_df.loc[[best_scenario_name]].copy()
compare_rows = pd.concat([base_customer, best_row], axis=0)
compare_rows.index = ["baseline", "best_scenario"]
compare_encoded = encoder.transform(compare_rows)
compare_pred_df = pd.DataFrame(
clf.predict(compare_encoded),
index=compare_rows.index,
columns=[target_name],
)
xpl_compare = SmartExplainer(
model=clf,
preprocessing=encoder,
features_dict=feature_dict,
label_dict={0: "Low default risk", 1: "High default risk"},
title_story="Counterfactual comparison",
)
xpl_compare.compile(x=compare_encoded, y_pred=compare_pred_df, additional_data=compare_rows)
xpl_compare.plot.local_plot(index="baseline")
INFO: Shap explainer type - <shap.explainers._tree.TreeExplainer object at 0x11ea85dc0>
[8]:
xpl_compare.plot.local_plot(index="best_scenario")
6. Decision checklist¶
Rank scenarios by impact and business cost.
Exclude non-actionable or sensitive variables.
Verify the plausibility of the proposed counterfactuals.
Monitor the effects actually observed after deployment.