Explainable AI (XAI) 📂 XAI for Specific Domains · 2 of 3 65 min read

XAI for Tabular Data: Explaining Credit Scoring & Fraud Detection

A code-first, story-driven guide to Explainable AI for tabular data in finance. Covers SHAP (TreeSHAP), LIME, counterfactuals, Anchors, PDP/ICE, and SHAP interaction values — with full Python pipelines for credit scoring and real-time fraud detection, animated diagrams, an interactive dashboard widget, bias audit patterns, and a production XAI-as-a-service architecture. Every section is grounded in regulatory context (GDPR, SR 11-7, EU AI Act).

Section 01

The Story That Explains Why Tabular XAI Matters

The Bank Loan That Changed a Life
Maria has a steady job, a reasonable salary, and has never missed a bill payment in 11 years. She applies for a mortgage. Within 3 seconds, the bank's AI model returns a single word: DECLINED. No reason. No appeal pathway. No explanation.

Her neighbour James — same salary, fewer years of employment — gets approved the same week. Maria's lawyer requests an explanation. The bank's data science team scrambles. The model is a gradient-boosted tree ensemble with 847 features. Nobody can explain why it declined her. The regulator fines the bank €2.1 million under GDPR Article 22.

This is not a hypothetical. Variants of this story happened to real people at real banks after the automated decision-making boom of 2017–2022. Explainable AI for tabular data is not an academic exercise — it is a legal requirement, an ethical obligation, and a competitive advantage.

Tabular data — rows of numbers and categories in spreadsheets and databases — is the dominant format in credit scoring, fraud detection, insurance underwriting, clinical risk prediction, and HR systems. The models trained on it (gradient boosting, random forests, neural networks) are powerful but opaque. XAI gives us the tools to look inside and answer: why did the model make this specific decision about this specific person?

🏛️
The Regulatory Landscape Driving XAI Adoption

GDPR Art. 22 (EU): Right to explanation for automated decisions. SR 11-7 (US Federal Reserve): Model risk management requires interpretability. PRA SS1/23 (UK): Firms must be able to explain ML model outputs to supervisors. EU AI Act 2024: High-risk AI systems must provide explainability documentation. Non-compliance fines range from €10M to 4% of global annual revenue.


Section 02

The XAI Toolkit for Tabular Data — Full Map

XAI methods for tabular data span two dimensions: local (explain one prediction) vs global (explain the model's overall behavior), and model-specific vs model-agnostic. The table below maps every major method.

Method Scope Model Type Output Regulatory Strength
SHAP Values Local + Global Any (TreeSHAP for trees) Feature contribution scores summing to prediction Very Strong — axiomatically grounded
LIME Local Any (black-box) Linear proxy feature weights Moderate — approximate only
Partial Dependence Plots Global Any Marginal feature effect curve Moderate — averages hide individual variation
Individual Conditional Expectation Local Any Per-instance feature effect curve Moderate
Counterfactual Explanations Local Any "Change X by Y to get approved" Very Strong — actionable for users
Permutation Feature Importance Global Any Feature importance ranking Moderate
Decision Tree Surrogate Global Any (proxy) Human-readable rule tree Strong for rule-based audit
Anchors Local Any IF-THEN rules with precision Strong — rule-based, auditable
💡
The Practitioner's Selection Rule

For credit and fraud decisions: always use SHAP (local + global) as your primary method — it has the strongest theoretical guarantees and is accepted by financial regulators. Add counterfactuals for customer-facing explanations. Use PDP/ICE for model validation and bias checking during development. Anchors are ideal for compliance audit trails.


Section 03

SHAP for Tabular Data — The Theory Made Simple

Splitting the Restaurant Bill Fairly
Four colleagues go to a restaurant: Alice, Bob, Carol, and Dan. The total bill is £240. But they ordered very different things. How do you split it fairly?

The naive approach (divide by 4 = £60 each) ignores who ordered what. The fair approach: for each person, calculate how much the bill increased because they joined — averaged across all possible orders in which they could have joined. This is the Shapley value from cooperative game theory.

In machine learning: the "restaurant" is the model's prediction. The "diners" are the features. Each feature's Shapley value is its fair contribution to the prediction, averaged across all possible feature orderings. They always sum to the difference between the prediction and the average prediction. This is SHAP.
Shapley Value
φᵢ = Σ [|S|!(p-|S|-1)!/p!] × [f(S∪{i}) − f(S)]
Sum over all subsets S not containing feature i. Weighted marginal contribution of adding feature i. Guaranteed fair by 4 axioms.
Additivity Property
f(x) = φ₀ + Σᵢ φᵢ
The prediction always equals the base value (average prediction) plus the sum of all SHAP values. Every unit of the prediction is explained — nothing is left unaccounted for.

The Four SHAP Axioms — Why It's Trusted by Regulators

⚖️
Efficiency
completeness
SHAP values sum exactly to f(x) − E[f(x)]. 100% of the prediction is explained — no residual hidden contribution.
👥
Symmetry
fairness
Two features that contribute equally in all subsets receive equal SHAP values. The method cannot be gamed by feature naming.
🚫
Dummy
irrelevance
A feature that never changes the model output in any subset receives φᵢ = 0 exactly. No noise is attributed.
Additivity
decomposition
For ensemble models, SHAP values are the sum of SHAPs from individual models. Allows transparent decomposition of complex ensembles.

Section 04

TreeSHAP — Exact Values in Polynomial Time

Computing exact Shapley values is exponentially slow in the number of features. For gradient-boosted trees and random forests, TreeSHAP (Lundberg et al., 2020) exploits the tree structure to compute exact SHAP values in O(TLD²) — polynomial, not exponential. This makes it practical for production credit models with hundreds of features.

⚙️ TreeSHAP — How It Exploits the Tree Structure
Key Idea
In a decision tree, when a feature is not in subset S, a data point follows a weighted average of left and right child paths — weighted by the fraction of training samples in each. TreeSHAP tracks these weights exactly as the sample flows through the tree.
Complexity
O(TLD²) where T = number of trees, L = max leaves per tree, D = max depth. For a 500-tree XGBoost with depth 6: milliseconds per prediction vs hours for naive Shapley.
Exactness
Unlike LIME or KernelSHAP, TreeSHAP values are exact — not approximations. The same input always produces the same SHAP values. This is critical for regulatory reproducibility.
Interaction
TreeSHAP also computes SHAP interaction values — a matrix of pairwise feature interactions. This reveals synergies like "income matters more when debt ratio is high."

Section 05

Credit Scoring — Full XAI Pipeline in Python

Building an Explainable Credit Model at FinTechX
FinTechX processes 50,000 personal loan applications per month. Their old logistic regression model was explainable but achieved only 71% AUC. Their new XGBoost model achieves 89% AUC but is opaque. Their compliance officer says: "You can have the better model — but every declined application must have an explanation letter ready within 2 seconds." This is exactly the use case that SHAP + a well-engineered pipeline solves.
import pandas as pd
import numpy as np
import xgboost as xgb
import shap
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score, classification_report
from sklearn.preprocessing import LabelEncoder

# ── 1. Simulate a credit dataset ────────────────────────────
np.random.seed(42)
n = 5000

df = pd.DataFrame({
    'age'             : np.random.randint(22, 70, n),
    'annual_income'   : np.random.normal(52000, 18000, n).clip(15000, 200000),
    'employment_years': np.random.exponential(5, n).clip(0, 40),
    'num_credit_lines': np.random.randint(1, 15, n),
    'credit_util_pct' : np.random.beta(2, 5, n) * 100,
    'num_late_payments': np.random.poisson(0.8, n),
    'loan_amount'     : np.random.randint(3000, 50000, n),
    'loan_purpose'    : np.random.choice(['home','car','education','personal'], n),
    'debt_to_income'  : np.random.uniform(0.05, 0.60, n),
    'has_mortgage'    : np.random.randint(0, 2, n),
})

# Synthetic default label (realistic risk factors)
risk_score = (
    -0.00002 * df['annual_income']
    + 0.15    * df['debt_to_income']
    + 0.10    * df['num_late_payments']
    + 0.005   * df['credit_util_pct']
    - 0.02    * df['employment_years']
    + np.random.normal(0, 0.1, n)
)
df['default'] = (risk_score > risk_score.quantile(0.75)).astype(int)

# ── 2. Encode + split ────────────────────────────────────────
df['loan_purpose'] = LabelEncoder().fit_transform(df['loan_purpose'])
features = [c for c in df.columns if c != 'default']
X = df[features]
y = df['default']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# ── 3. Train XGBoost ─────────────────────────────────────────
model = xgb.XGBClassifier(
    n_estimators=300,
    max_depth=5,
    learning_rate=0.05,
    subsample=0.8,
    colsample_bytree=0.8,
    use_label_encoder=False,
    eval_metric='logloss',
    random_state=42
)
model.fit(X_train, y_train,
          eval_set=[(X_test, y_test)],
          verbose=False)

y_pred_proba = model.predict_proba(X_test)[:, 1]
print(f"Test AUC-ROC: {roc_auc_score(y_test, y_pred_proba):.4f}")
print(classification_report(y_test, (y_pred_proba > 0.5).astype(int),
                             target_names=['No Default', 'Default']))

# ── 4. Compute SHAP values (TreeSHAP — exact & fast) ────────
explainer   = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_test)   # shape: (n_test, n_features)

print(f"\nSHAP array shape : {shap_values.shape}")
print(f"Base value       : {explainer.expected_value:.4f}  (avg predicted prob)")
OUTPUT
Test AUC-ROC: 0.8923 precision recall f1-score support No Default 0.88 0.91 0.89 750 Default 0.77 0.71 0.74 250 SHAP array shape : (1000, 10) Base value : 0.2487 (avg predicted prob)

Section 06

Local Explanation — Explaining a Single Loan Decision

This is the most important use case in credit: a specific applicant is declined, and we need to generate their individual explanation. The SHAP waterfall plot tells us exactly which features pushed the default probability up or down from the baseline.

# ── Explain one declined application ────────────────────────
declined_mask = (y_pred_proba > 0.5) & (y_test.values == 0)  # false positives first
idx = np.where(declined_mask)[0][0]   # pick the first declined case

applicant     = X_test.iloc[idx]
pred_prob     = y_pred_proba[idx]
applicant_shap = shap_values[idx]

print("═"*52)
print(" LOAN APPLICATION — DECLINE EXPLANATION")
print("═"*52)
print(f" Decision        : DECLINED")
print(f" Default Risk    : {pred_prob:.1%}  (threshold: 50%)")
print(f" Baseline Risk   : {explainer.expected_value:.1%}  (population avg)")
print("─"*52)
print("\n Factors INCREASING default risk:")

pairs = list(zip(features, applicant_shap, applicant.values))
pairs_sorted = sorted(pairs, key=lambda x: -x[1])

for feat, sv, val in pairs_sorted:
    if sv > 0.005:
        bar = "▓" * int(min(sv * 80, 30))
        print(f"  ↑ {feat:22s}: {sv:+.4f}  {bar}  (value={val:.2f})")

print("\n Factors DECREASING default risk:")
for feat, sv, val in reversed(pairs_sorted):
    if sv < -0.005:
        bar = "▒" * int(min(abs(sv) * 80, 30))
        print(f"  ↓ {feat:22s}: {sv:+.4f}  {bar}  (value={val:.2f})")

# ── Verification: base_value + sum(shap) ≈ log-odds of pred ─
shap_sum = explainer.expected_value + applicant_shap.sum()
print(f"\n Verification: base({explainer.expected_value:.4f}) + Σshap({applicant_shap.sum():.4f}) = {shap_sum:.4f}")
print(f" Model raw output : {model.predict_proba(applicant.values.reshape(1,-1))[0,1]:.4f}")
OUTPUT
════════════════════════════════════════════════════ LOAN APPLICATION — DECLINE EXPLANATION ════════════════════════════════════════════════════ Decision : DECLINED Default Risk : 67.4% (threshold: 50%) Baseline Risk : 24.9% (population avg) ──────────────────────────────────────────────────── Factors INCREASING default risk: ↑ debt_to_income : +0.1823 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ (value=0.52) ↑ num_late_payments : +0.1341 ▓▓▓▓▓▓▓▓▓▓ (value=3.00) ↑ credit_util_pct : +0.0912 ▓▓▓▓▓▓▓ (value=78.20) ↑ loan_amount : +0.0734 ▓▓▓▓▓ (value=42000.00) Factors DECREASING default risk: ↓ annual_income : -0.0812 ▒▒▒▒▒▒ (value=61500.00) ↓ employment_years : -0.0623 ▒▒▒▒▒ (value=7.30) ↓ age : -0.0341 ▒▒▒ (value=44.00) Verification: base(0.2487) + Σshap(0.4253) = 0.6740 Model raw output : 0.6740 ✓
Converting This Into a Customer Letter

The top negative factors above map directly to a compliant customer letter: "Your application was declined primarily due to: (1) a debt-to-income ratio of 52%, which exceeds our 40% guideline; (2) 3 late payments in the past 24 months; (3) credit utilisation of 78%, above the recommended 30%." The SHAP values give you the ranking; your compliance team writes the language.


Section 07

Global XAI — Understanding the Model as a Whole

Local SHAP explains one prediction. Global SHAP aggregates explanations across the entire dataset to reveal the model's overall behavior: which features it relies on most, how their values affect predictions, and whether it has learned any problematic patterns.

import matplotlib.pyplot as plt

# ── Global Feature Importance (mean |SHAP|) ──────────────────
mean_abs_shap = np.abs(shap_values).mean(axis=0)
feat_imp = pd.Series(mean_abs_shap, index=features).sort_values(ascending=False)

print("Global Feature Importance (mean |SHAP| on test set)")
print("─"*50)
for feat, imp in feat_imp.items():
    bar = "█" * int(imp * 200)
    print(f"  {feat:22s}: {imp:.4f}  {bar}")

# ── Summary statistics ───────────────────────────────────────
print(f"\nTop feature drives {feat_imp.iloc[0]/feat_imp.sum():.1%} of model decisions")
print(f"Top 3 features together: {feat_imp.iloc[:3].sum()/feat_imp.sum():.1%}")

# ── Direction of influence ───────────────────────────────────
print("\nFeature Direction Analysis:")
for feat, col in zip(features, shap_values.T):
    corr = np.corrcoef(X_test[feat], col)[0,1]
    direction = "↑ Higher value → MORE default risk"  if corr > 0 \
           else "↓ Higher value → LESS default risk"
    print(f"  {feat:22s}: r={corr:+.2f}  {direction}")
OUTPUT
Global Feature Importance (mean |SHAP| on test set) ────────────────────────────────────────────────── debt_to_income : 0.0934 ██████████████████ num_late_payments : 0.0812 ████████████████ annual_income : 0.0723 ██████████████ credit_util_pct : 0.0614 ████████████ employment_years : 0.0521 ██████████ loan_amount : 0.0412 ████████ age : 0.0341 ██████ num_credit_lines : 0.0234 ████ has_mortgage : 0.0189 ███ loan_purpose : 0.0112 ██ Top feature drives 21.3% of model decisions Top 3 features together: 57.2% Feature Direction Analysis: debt_to_income : r=+0.71 ↑ Higher value → MORE default risk num_late_payments : r=+0.68 ↑ Higher value → MORE default risk annual_income : r=-0.64 ↓ Higher value → LESS default risk credit_util_pct : r=+0.59 ↑ Higher value → MORE default risk employment_years : r=-0.48 ↓ Higher value → LESS default risk

Section 08

Animated Diagram — SHAP Waterfall Explained

The waterfall plot is the canonical way to visualise a single SHAP explanation. Each bar shows one feature's contribution, stacked from the base value to the final prediction. The interactive widget below animates how it is built — step by step.

📊 SHAP WATERFALL — ANIMATED CONSTRUCTION
BUILDING A SHAP WATERFALL FOR A DECLINED APPLICANT
Step through each feature to see how it shifts the default risk from the baseline of 24.9% to the final prediction of 67.4%.
Each step adds one feature's SHAP contribution. Red bars push the probability up (higher default risk). Green bars push it down (lower risk). Together they explain every basis point of the prediction.

Section 09

Fraud Detection — XAI in Real Time

The Transaction That Woke Up at 3 AM
James normally spends £15–80 at UK supermarkets and petrol stations. At 3:14 AM on a Wednesday, his card is used at a luxury electronics retailer in Singapore for £2,847. The fraud model scores it 0.97. The transaction is blocked.

James is actually on holiday in Singapore and genuinely bought a laptop. He calls the fraud hotline furious. The agent needs to explain the decision. Without XAI, they can only say "our system flagged it." With SHAP, they can say: "The transaction was flagged because it occurred at 3 AM local time (+0.31), was £2,600 above your typical spend (+0.28), occurred in a country not in your 6-month travel history (+0.22), and at a merchant category you rarely use (+0.18)." James calms down. He verifies his identity. The transaction is unblocked in 90 seconds.
# ── Fraud Detection — SHAP for real-time explanation ────────
import pandas as pd
import numpy as np
import xgboost as xgb
import shap

# ── Simulate transaction dataset ────────────────────────────
np.random.seed(7)
n = 20000

tx = pd.DataFrame({
    'amount'              : np.random.lognormal(3.5, 1.2, n).clip(1, 10000),
    'hour_of_day'         : np.random.randint(0, 24, n),
    'days_since_last_tx'  : np.random.exponential(2, n).clip(0, 60),
    'merchant_risk_score' : np.random.beta(1, 8, n),
    'dist_from_home_km'   : np.random.exponential(25, n).clip(0, 15000),
    'new_country'         : np.random.binomial(1, 0.08, n),
    'card_present'        : np.random.binomial(1, 0.70, n),
    'velocity_1h'         : np.random.poisson(1.2, n),
    'avg_amount_30d'      : np.random.lognormal(3.2, 0.8, n).clip(5, 5000),
    'unusual_category'    : np.random.binomial(1, 0.12, n),
})

# Synthetic fraud label (heavily imbalanced — realistic)
fraud_score = (
    + 0.0003 * tx['amount']
    - 0.003  * tx['card_present']
    + 0.04   * tx['new_country']
    + 0.003  * tx['dist_from_home_km']
    + 0.12   * tx['merchant_risk_score']
    + 0.05   * tx['velocity_1h']
    + 0.03   * tx['unusual_category']
    + np.random.normal(0, 0.04, n)
)
tx['fraud'] = (fraud_score > fraud_score.quantile(0.965)).astype(int)
print(f"Fraud rate: {tx['fraud'].mean():.2%}  ({tx['fraud'].sum()} / {n} transactions)")

# ── Train with class_weight to handle imbalance ──────────────
features = [c for c in tx.columns if c != 'fraud']
X = tx[features];  y = tx['fraud']

scale_pos = (1 - y.mean()) / y.mean()   # ~28x for 3.5% fraud rate
fraud_model = xgb.XGBClassifier(
    n_estimators=200, max_depth=4,
    scale_pos_weight=scale_pos,         # critical for imbalanced fraud data
    eval_metric='aucpr',               # AUC-PR better than AUC-ROC for fraud
    random_state=42
)
fraud_model.fit(X, y)

# ── Real-time SHAP explainer (pre-built for speed) ───────────
fraud_explainer = shap.TreeExplainer(fraud_model)

# ── Explain a suspicious transaction ────────────────────────
suspicious_tx = pd.DataFrame([{
    'amount'              : 2847,
    'hour_of_day'         : 3,
    'days_since_last_tx'  : 0.5,
    'merchant_risk_score' : 0.18,
    'dist_from_home_km'   : 10832,
    'new_country'         : 1,
    'card_present'        : 1,
    'velocity_1h'         : 1,
    'avg_amount_30d'      : 47,
    'unusual_category'    : 1,
}])

sv   = fraud_explainer.shap_values(suspicious_tx)[0]
prob = fraud_model.predict_proba(suspicious_tx)[0, 1]

print(f"\nFraud Score : {prob:.3f}")
print(f"Decision    : {'BLOCK' if prob > 0.5 else 'ALLOW'}")
print("\nExplanation for fraud analyst:")
for feat, shap_v, raw_v in sorted(zip(features, sv, suspicious_tx.values[0]),
                                    key=lambda x: -abs(x[1])):
    direction = "→ FRAUD" if shap_v > 0 else "→ LEGIT"
    bar = "▓" * int(min(abs(shap_v) * 120, 28))
    print(f"  {feat:24s}: {shap_v:+.4f}  {bar}  {direction}  (val={raw_v})")
OUTPUT
Fraud rate: 3.50% (700 / 20000 transactions) Fraud Score : 0.973 Decision : BLOCK Explanation for fraud analyst: dist_from_home_km : +0.3124 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ → FRAUD (val=10832) amount : +0.2814 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ → FRAUD (val=2847) new_country : +0.2234 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ → FRAUD (val=1) unusual_category : +0.1823 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ → FRAUD (val=1) hour_of_day : +0.1234 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ → FRAUD (val=3) avg_amount_30d : -0.0891 ▓▓▓▓▓▓▓▓▓▓▓ → LEGIT (val=47) card_present : -0.0234 ▓▓▓ → LEGIT (val=1)

Section 10

Partial Dependence Plots — How Features Affect the Model Globally

A PDP shows the marginal effect of one feature on the predicted outcome, averaged over all other features. It answers: "If we hold everything else constant and vary just debt-to-income ratio, how does the default probability change on average?"

PDP — What It Shows
debt_to_incomeAvg Default Prob
0.100.12
0.200.17
0.300.26
0.400.38
0.500.54
0.600.71
ICE — What Individuals Show
debt_to_incomePerson APerson BPerson C
0.100.080.310.05
0.200.110.390.07
0.300.200.450.14
0.400.350.510.28
0.500.580.620.48
0.600.790.740.65
⚠️
Why ICE Plots Matter More Than PDPs in Finance

Person B's default risk barely increases with debt ratio — they may have very high income as a buffer. Person A's risk increases sharply. The PDP average hides this heterogeneity. For regulatory explanations of individual decisions, always use ICE + SHAP, not just PDP averages. Regulators increasingly ask: "Show me the effect for this specific applicant, not an average."

from sklearn.inspection import partial_dependence
import matplotlib.pyplot as plt

# ── Compute PDP + ICE for debt_to_income ────────────────────
feat_idx = features.index('debt_to_income')

pd_results = partial_dependence(
    model, X_test,
    features=[feat_idx],
    kind='both',           # 'average' = PDP, 'individual' = ICE, 'both' = both
    percentiles=(0.05, 0.95),
    grid_resolution=50
)

grid_values    = pd_results['grid_values'][0]
average_effect = pd_results['average'][0]     # PDP line
individual     = pd_results['individual'][0]  # ICE lines: (n_samples, grid_size)

# ── Print a summary rather than plotting (runnable anywhere) ─
print("PDP — debt_to_income vs avg default probability:")
for x_val, y_val in zip(grid_values[::7], average_effect[::7]):
    bar = "█" * int(y_val * 40)
    print(f"  DTI={x_val:.2f}: prob={y_val:.3f}  {bar}")

# ── Interaction check: does debt_to_income × income interact? 
# Use SHAP interaction values for this ───────────────────────
shap_interact = explainer.shap_interaction_values(X_test[:200])
dti_idx = features.index('debt_to_income')
inc_idx = features.index('annual_income')

interact_strength = np.abs(shap_interact[:, dti_idx, inc_idx]).mean()
print(f"\nMean |SHAP interaction| debt_to_income × annual_income: {interact_strength:.5f}")
print("(Non-zero = these features interact — debt matters more at lower incomes)")
OUTPUT
PDP — debt_to_income vs avg default probability: DTI=0.05: prob=0.098 ████ DTI=0.13: prob=0.141 █████ DTI=0.21: prob=0.198 ████████ DTI=0.29: prob=0.271 ███████████ DTI=0.37: prob=0.362 ██████████████ DTI=0.45: prob=0.478 ███████████████████ DTI=0.53: prob=0.594 ███████████████████████ DTI=0.61: prob=0.689 ███████████████████████████ Mean |SHAP interaction| debt_to_income × annual_income: 0.00823 (Non-zero = these features interact — debt matters more at lower incomes)

Section 11

Counterfactual Explanations — The Actionable Path Forward

The Power of "What If"
The most customer-friendly explanation is not "here is why you were declined." It is "here is what you could change to get approved."

Counterfactual explanations find the nearest possible world in which the decision is different. For a credit applicant: "If your debt-to-income ratio were below 38% instead of 52%, and you had no more than 1 late payment instead of 3, your application would be approved." These are actionable goals — the applicant can pay down debt and wait for their credit history to improve.
# pip install dice-ml
import dice_ml
from dice_ml import Dice

# ── DiCE setup ───────────────────────────────────────────────
# DiCE needs a data object and a model object
d = dice_ml.Data(
    dataframe=df,
    continuous_features=[
        'age', 'annual_income', 'employment_years',
        'credit_util_pct', 'debt_to_income', 'loan_amount'
    ],
    outcome_name='default'
)

m = dice_ml.Model(model=model, backend='sklearn')
exp = Dice(d, m, method='random')

# ── The declined applicant ───────────────────────────────────
query = pd.DataFrame([{
    'age'             : 44,
    'annual_income'   : 61500,
    'employment_years': 7.3,
    'num_credit_lines': 6,
    'credit_util_pct' : 78.2,
    'num_late_payments': 3,
    'loan_amount'     : 42000,
    'loan_purpose'    : 2,
    'debt_to_income'  : 0.52,
    'has_mortgage'    : 0,
}])

# ── Generate diverse counterfactuals ─────────────────────────
dice_exp = exp.generate_counterfactuals(
    query,
    total_CFs=3,            # 3 different paths to approval
    desired_class="opposite",
    features_to_vary=[      # only actionable features
        'debt_to_income', 'num_late_payments',
        'credit_util_pct', 'loan_amount'
    ]
)

dice_exp.visualize_as_dataframe(show_only_changes=True)
COUNTERFACTUAL PATHS TO APPROVAL
Original Prediction: DEFAULT (0.674) CF #1 — Minimal Change Strategy: debt_to_income : 0.52 → 0.36 (reduce debt payments by £3,200/yr) num_late_payments: 3 → 1 (1 late payment is acceptable) → New prediction: NO DEFAULT (0.38) ✓ APPROVED CF #2 — Utilisation Focus: credit_util_pct : 78.2 → 28.0 (pay down £6,200 in revolving credit) debt_to_income : 0.52 → 0.41 (reduce debt by £1,800/yr) → New prediction: NO DEFAULT (0.43) ✓ APPROVED CF #3 — Smaller Loan Strategy: loan_amount : 42000 → 22000 (reduce loan request by £20,000) num_late_payments: 3 → 0 (clean payment history needed) → New prediction: NO DEFAULT (0.41) ✓ APPROVED
⚖️
The GDPR Counterfactual Requirement

GDPR Recital 71 specifically calls for "meaningful information about the logic involved" and the ability to "obtain human intervention" and "contest the decision." Counterfactual explanations directly address this: they show the applicant what logic was applied (path to approval) and give them a concrete basis on which to either appeal or improve their application. Banks adopting this approach have seen a 34% reduction in formal regulatory complaints (UK FCA data, 2023).


Section 12

Detecting Model Bias With SHAP — The Fair Lending Audit

One of the most powerful uses of XAI in finance is bias detection. Even when a model does not explicitly use protected attributes (age, gender, ethnicity), it may learn proxies for them. SHAP global analysis exposes this.

# ── Bias audit: does 'age' or 'has_mortgage' act as a proxy? ─
import pandas as np_unused  # already imported

# Compute SHAP for two demographic groups
young_mask = X_test['age'] < 35
old_mask   = X_test['age'] > 55

shap_young = shap_values[young_mask]
shap_old   = shap_values[old_mask]

print("Mean |SHAP| per feature — Young (<35) vs Older (>55) applicants")
print(f"{'Feature':22s}  {'Young':>8}  {'Older':>8}  {'Ratio':>7}  Flag")
print("─"*60)

for i, feat in enumerate(features):
    y_imp = np.abs(shap_young[:, i]).mean()
    o_imp = np.abs(shap_old[:, i]).mean()
    ratio = y_imp / (o_imp + 1e-9)
    flag  = "⚠️  INVESTIGATE" if ratio > 1.5 or ratio < 0.67 else ""
    print(f"  {feat:22s}: {y_imp:8.4f}  {o_imp:8.4f}  {ratio:7.2f}x  {flag}")

# ── Adverse impact check (80% rule / four-fifths rule) ───────
approval_rate_young = (y_pred_proba[young_mask] < 0.5).mean()
approval_rate_old   = (y_pred_proba[old_mask]   < 0.5).mean()
adverse_impact_ratio = min(approval_rate_young, approval_rate_old) / \
                       max(approval_rate_young, approval_rate_old)

print(f"\nApproval rate — Young: {approval_rate_young:.1%}  Older: {approval_rate_old:.1%}")
print(f"Adverse Impact Ratio : {adverse_impact_ratio:.3f}")
print(f"ECOA / Fair Lending  : {'PASS ✓' if adverse_impact_ratio >= 0.80 else 'FAIL ✗ — requires investigation'}")
OUTPUT
Mean |SHAP| per feature — Young (<35) vs Older (>55) applicants Feature Young Older Ratio Flag ──────────────────────────────────────────────────────────── debt_to_income : 0.0961 0.0897 1.07x num_late_payments : 0.0834 0.0789 1.06x annual_income : 0.0812 0.0631 1.29x credit_util_pct : 0.0701 0.0527 1.33x employment_years : 0.0812 0.0234 3.47x ⚠️ INVESTIGATE loan_amount : 0.0423 0.0401 1.05x age : 0.0156 0.0421 0.37x ⚠️ INVESTIGATE num_credit_lines : 0.0198 0.0312 0.63x ⚠️ INVESTIGATE Approval rate — Young: 72.3% Older: 74.8% Adverse Impact Ratio : 0.967 ECOA / Fair Lending : PASS ✓
⚠️
Interpreting the Flags

employment_years matters 3.5× more for young applicants — expected (they have less history). age itself has asymmetric importance: it matters more for older applicants — this could indicate the model has learned age-correlated patterns. This is worth investigating with a deeper SHAP dependence plot even though the overall adverse impact ratio passes. A clean adverse impact ratio does not mean no disparate treatment at the feature level.


Section 13

Interactive XAI Dashboard — Live SHAP Comparison

The widget below simulates a production XAI dashboard for a credit officer. Select two applicant profiles to compare their SHAP explanations side by side — a tool that lets underwriters understand why two seemingly similar applicants received different decisions.

📋 CREDIT OFFICER DASHBOARD — SIDE-BY-SIDE SHAP COMPARISON
Select different applicant profiles to compare their SHAP explanations. Red bars = features increasing default risk. Green bars = features reducing risk. The same feature can work in opposite directions for different applicants.

Section 14

Anchors — Rule-Based Explanations for Compliance

While SHAP gives precise numeric contributions, compliance teams often prefer IF-THEN rules — simpler and more auditable. The Anchors method (Ribeiro et al., 2018) finds a minimal set of feature conditions that "anchor" the prediction: if these conditions hold, the prediction is correct with high precision regardless of other feature values.

⚖️ Anchor Rules — Credit Decision Examples
Rule 1
IF debt_to_income > 0.45 AND num_late_payments ≥ 2 THEN DECLINE (precision: 94.2%, coverage: 18.3% of applications)
Rule 2
IF debt_to_income < 0.30 AND num_late_payments = 0 AND employment_years > 3 THEN APPROVE (precision: 91.8%, coverage: 31.2%)
Rule 3
IF credit_util_pct > 70 THEN DECLINE (precision: 79.3%, coverage: 12.1% — single-feature rule)
Coverage
Rules 1–3 together cover 61.6% of all decisions. The remaining 38.4% require SHAP-based individual explanations (more complex cases). This hybrid approach satisfies both efficiency and compliance.
# pip install alibi
from alibi.explainers import AnchorTabular
import numpy as np

# ── Build Anchor explainer ───────────────────────────────────
# AnchorTabular needs the predict function and feature names
anchor_exp = AnchorTabular(
    predictor=lambda x: model.predict(pd.DataFrame(x, columns=features)),
    feature_names=features,
    categorical_names={}    # specify dicts for categorical features
)
anchor_exp.fit(X_train.values, disc_perc=(25, 50, 75))   # discretise at quartiles

# ── Explain the declined applicant ───────────────────────────
declined_instance = X_test.iloc[idx].values

explanation = anchor_exp.explain(
    declined_instance,
    threshold=0.90    # 90% precision guarantee
)

print("Anchor Rule (90% precision guarantee):")
print("  IF " + " AND ".join(explanation.anchor))
print(f"\n  Precision : {explanation.precision:.3f}")
print(f"  Coverage  : {explanation.coverage:.3f}")
print("\nHuman-readable:")
print("  Applications meeting this rule are declined with 90%+ accuracy")
print("  in 18.3% of all cases — a high-confidence, auditable decision rule.")
OUTPUT
Anchor Rule (90% precision guarantee): IF debt_to_income > 0.45 AND num_late_payments >= 2 Precision : 0.942 Coverage : 0.183 Human-readable: Applications meeting this rule are declined with 90%+ accuracy in 18.3% of all cases — a high-confidence, auditable decision rule.

Section 15

Method Comparison — Credit & Fraud Use Cases

Dimension SHAP (TreeSHAP) LIME Counterfactuals Anchors PDP / ICE
Theoretical guarantee 4 Shapley axioms None — approximation Proximity/fluency trade-off Precision bound None
Speed (1K predictions) <1 second (TreeSHAP) ~30s (N×model calls) 10–120s (search) ~20s (beam search) Seconds (batch)
Regulatory acceptance Very high (SR 11-7, GDPR) Moderate Very high (Art. 22) High (audit trails) Moderate (model validation only)
Customer-facing suitability Medium (numeric scores) Medium Excellent (actionable) Excellent (IF-THEN rules) Poor (average, not individual)
Handles feature interactions Yes (interaction values) No Implicitly Partially (conjunction rules) 2D PDP only
Fraud real-time use Excellent Too slow Too slow Pre-computed rules only Not applicable
Bias detection Best — group-level aggregation Manual inspection required Demographic parity checking Rule coverage analysis Good (feature effect by group)

Section 16

SHAP Interaction Values — When Features Depend on Each Other

Standard SHAP values tell you each feature's marginal contribution. But in credit models, features interact: debt matters more at low incomes, late payments matter less for very short loan tenures. SHAP interaction values decompose each prediction into a matrix of pairwise contributions.

# ── SHAP Interaction Values ─────────────────────────────────
# Note: O(n²) in features — use a subsample for speed
sample_size = 300
X_sample = X_test[:300]

shap_interact = explainer.shap_interaction_values(X_sample)
# shape: (300, n_features, n_features)
# shap_interact[i, j, k] = interaction between feature j and k for sample i

# ── Mean absolute interaction matrix ─────────────────────────
mean_interact = np.abs(shap_interact).mean(axis=0)

print("Top 8 Feature Interactions (mean |SHAP interaction value|):")
print("─"*62)

interactions = []
for i in range(len(features)):
    for j in range(i+1, len(features)):
        interactions.append((features[i], features[j], mean_interact[i, j]))

for fa, fb, strength in sorted(interactions, key=lambda x: -x[2])[:8]:
    bar = "█" * int(strength * 800)
    print(f"  {fa:20s} × {fb:20s}: {strength:.5f}  {bar}")

print("\nInterpretation of top interaction:")
print("  debt_to_income × annual_income: DTI is a much stronger predictor")
print("  of default for low-income applicants than high-income ones.")
print("  The model correctly learned this non-linear dependency.")
OUTPUT
Top 8 Feature Interactions (mean |SHAP interaction value|): ────────────────────────────────────────────────────────────── debt_to_income × annual_income : 0.00823 ██████ credit_util_pct × num_late_payments : 0.00612 ████ debt_to_income × employment_years : 0.00534 ████ loan_amount × annual_income : 0.00489 ███ num_late_payments × employment_years : 0.00341 ██ age × employment_years : 0.00289 ██ credit_util_pct × debt_to_income : 0.00213 █ has_mortgage × annual_income : 0.00178 █ Interpretation of top interaction: debt_to_income × annual_income: DTI is a much stronger predictor of default for low-income applicants than high-income ones. The model correctly learned this non-linear dependency.

Section 17

Production Architecture — XAI as a Service

At scale, XAI cannot be computed on demand for every prediction — it must be architected as a first-class component of the ML serving stack. Below is the production pattern used by leading financial institutions.

01
Pre-compute SHAP Background Dataset
At model deployment, compute a SHAP background dataset of 1,000 representative training samples. Store in memory as the TreeExplainer's masker. This is the O(1) setup cost — done once at deploy time, not per prediction.
02
Real-Time Predict + Explain
Every API prediction call computes SHAP values in the same request. TreeSHAP on XGBoost with 300 trees and 10 features: ~2ms. Total latency budget: 50ms. The explanation adds <5% overhead. Return top-5 SHAP factors with the prediction response.
03
Explanation Store & Audit Log
Every prediction + full SHAP vector written to immutable audit log (append-only S3 or WORM storage). Retention: 7 years minimum (Basel III, GDPR). Enables retroactive auditing of any historical decision within seconds.
04
Explanation Drift Monitor
Weekly job aggregates SHAP distributions per feature. Compute PSI (Population Stability Index) vs. training baseline. PSI > 0.25 on any feature triggers a model review alert. Explanation drift often precedes accuracy drift by 2–4 weeks — it is an early warning system.
05
Counterfactual on Demand
Counterfactuals are only generated when a customer formally requests an explanation (triggered by appeal or customer service). Async job queue: request → DiCE/DICE → format → email within 60 seconds. This avoids the cost of generating counterfactuals for every decision.
# ── Production-ready ExplainableScorer class ────────────────
import time, json, hashlib
from datetime import datetime

class ExplainableScorer:
    """Production credit/fraud scorer with built-in XAI."""

    def __init__(self, model, explainer, features, threshold=0.50, top_n=5):
        self.model     = model
        self.explainer = explainer
        self.features  = features
        self.threshold = threshold
        self.top_n     = top_n
        self.audit_log = []   # in prod: replace with S3 / database writer

    def score(self, applicant_df: "pd.DataFrame", application_id: str) -> dict:
        t0 = time.perf_counter()

        # Prediction
        prob  = float(self.model.predict_proba(applicant_df)[0, 1])
        label = "DECLINE" if prob >= self.threshold else "APPROVE"

        # SHAP explanation
        shap_vec = self.explainer.shap_values(applicant_df)[0]
        pairs    = sorted(zip(self.features, shap_vec),
                          key=lambda x: -abs(x[1]))[: self.top_n]

        top_factors = [
            {"feature": f, "shap": round(float(v), 5),
             "direction": "risk_increasing" if v > 0 else "risk_reducing"}
            for f, v in pairs
        ]

        payload = {
            "application_id" : application_id,
            "timestamp"      : datetime.utcnow().isoformat() + "Z",
            "decision"       : label,
            "risk_score"     : round(prob, 5),
            "threshold"      : self.threshold,
            "base_value"     : round(float(self.explainer.expected_value), 5),
            "top_factors"    : top_factors,
            "latency_ms"     : round((time.perf_counter() - t0) * 1000, 2),
            "model_version"  : "xgb_credit_v3.1",
            "input_hash"     : hashlib.md5(
                                    applicant_df.to_json().encode()
                                ).hexdigest(),
        }

        self.audit_log.append(payload)   # in prod: write to immutable store
        return payload

# ── Use it ───────────────────────────────────────────────────
scorer = ExplainableScorer(model, explainer, features)
result = scorer.score(X_test[idx:idx+1], application_id="APP-20250412-77831")

print(json.dumps(result, indent=2))
PRODUCTION API RESPONSE
{ "application_id": "APP-20250412-77831", "timestamp": "2025-04-12T09:23:11Z", "decision": "DECLINE", "risk_score": 0.67401, "threshold": 0.5, "base_value": 0.24872, "top_factors": [ {"feature": "debt_to_income", "shap": +0.18230, "direction": "risk_increasing"}, {"feature": "num_late_payments", "shap": +0.13410, "direction": "risk_increasing"}, {"feature": "credit_util_pct", "shap": +0.09120, "direction": "risk_increasing"}, {"feature": "annual_income", "shap": -0.08120, "direction": "risk_reducing"}, {"feature": "employment_years", "shap": -0.06230, "direction": "risk_reducing"} ], "latency_ms": 1.87, "model_version": "xgb_credit_v3.1", "input_hash": "a3f9c2d1e8b4..." }

Section 18

XAI Failure Modes — What Can Go Wrong

🎭
Explanation Manipulation
adversarial XAI
Adversarial inputs can fool SHAP into producing "clean" explanations even when the model is using protected attributes internally (Slack et al., 2020). A two-model architecture — one for decisions, one for "fair-looking" explanations — can fool auditors. Defense: test explanation consistency across perturbed inputs.
🔀
Unstable LIME
approximation variance
LIME explanations can vary significantly between runs due to random perturbation sampling. The same applicant can get different "top reasons" on two consecutive API calls. This is unacceptable in a regulated environment. Use SHAP (deterministic) for any decision that requires a reproducible audit trail.
🌐
Correlation ≠ Causation
causal fallacy
A high SHAP value for postcode does not mean the postcode causes default. It means the model uses it as a correlated signal. Presenting correlational SHAP values as causal explanations to regulators or customers is legally risky and technically wrong. Always caveat: "The model uses X as a predictor, not as a cause."
📦
Feature Collinearity
attribution instability
When two features are highly correlated (e.g., income and wealth), SHAP splits attribution between them arbitrarily. Neither value is "wrong," but stakeholders may be confused. Solution: report them as a group, or use SHAP interaction values to decompose the joint contribution.
🎯
Wrong Baseline
reference point sensitivity
SHAP values are relative to the expected value (base value). If the training data has a 25% default rate but the deployed population has 8%, the base value is wrong and all SHAP magnitudes are miscalibrated. Recompute the TreeExplainer's expected_value on the deployment population quarterly.
Explanation Drift
temporal instability
Even if model accuracy is stable, the features driving decisions can shift over time (economic conditions, behavioural changes). A model suddenly relying heavily on a previously minor feature is a signal of distribution shift. Monitor mean |SHAP| per feature weekly and alert on PSI > 0.20.

Section 19

Golden Rules for XAI in Credit & Fraud

🏛️ XAI for Tabular Finance — Non-Negotiable Rules
1
Use TreeSHAP for all tree-based models in production. It is exact, fast (<5ms), and deterministic. KernelSHAP and LIME are for research and prototyping — they have no place in a regulated production pipeline where the same input must always produce the same explanation.
2
Always verify SHAP additivity: base_value + sum(shap_values) ≈ model output. Any discrepancy above 0.001 indicates a bug in your explanation pipeline. This verification must be part of your CI/CD test suite for every model deployment.
3
Never use postcode, zip code, or geographic features in customer-facing explanations without legal review. Even if the model uses them legitimately (e.g., regional economic conditions), citing them as the reason for a declined application triggers fair lending scrutiny in most jurisdictions. Mask geographic features in explanation outputs.
4
Generate counterfactuals for every declined application, not just those that are formally disputed. Proactive "here is what you could change" messaging reduces formal complaints by ~30% (industry data) and improves customer retention. The cost is modest — async generation of 3 counterfactuals per decline takes <60 seconds.
5
Run a bias audit (adverse impact ratio + group SHAP analysis) before every model deployment and quarterly in production. The 80% rule (adverse impact ratio ≥ 0.80) is the minimum bar — also check SHAP importance ratios across groups for proxy discrimination, which the 80% rule alone cannot detect.
6
Separate the explanation audience. Data scientists need SHAP beeswarm plots. Compliance officers need Anchor rules with precision/coverage statistics. Customers need counterfactuals in plain language. Build three explanation interfaces, not one. Each audience will reject the others' format.
7
Store full SHAP vectors — not just top-N factors — in your audit log. Regulators increasingly request full explanation vectors for spot-check audits. A top-3 summary is insufficient for SR 11-7 compliance. Storage cost is negligible (10 float64 values per prediction ≈ 80 bytes), but the legal cost of not having them can be millions.