Case Study 1: Meridian Financial — Adverse Action Explanations with SHAP and Counterfactual Recourse
Context
Meridian Financial's credit scoring model — the XGBoost ensemble with 500 trees and 200 features, encountered in Chapters 24, 28, 31, and 34 — processes 15,000 applications per day. Applications scoring above 0.35 on the probability-of-default scale are auto-declined. Applications between 0.12 and 0.35 are routed to human underwriters. In each case, ECOA Regulation B requires that the applicant receive a statement of specific reasons for the adverse action.
Before this project, Meridian's adverse action notices were generated by a legacy rule-based system that mapped score ranges to pre-written reason codes. An applicant denied with a score of 0.42 received the same four reasons regardless of their individual circumstances: (1) "Insufficient credit history," (2) "High revolving balance," (3) "Too many recent inquiries," (4) "Low income relative to requested amount." These reasons were drawn from the OCC's sample adverse action reasons (Model Form C-1) and applied uniformly to all denials.
The problem was not that the reasons were wrong in aggregate — they captured the globally most important features. The problem was that they were wrong for individual applicants. An applicant denied primarily because of a single derogatory mark on a 15-year credit history received the same boilerplate as an applicant denied primarily because of a debt-to-income ratio of 68%. The explanations were globally accurate and individually misleading.
The CFPB's 2022 Circular (2022-03) made this untenable. The circular clarified that creditors using complex models must provide reasons that "accurately identify the factors that actually drove the credit decision for the individual consumer." Boilerplate reason codes that reflect global feature importance rather than individual-level attribution do not satisfy this requirement.
The Implementation
Phase 1: TreeSHAP Integration
The ML platform team integrated TreeSHAP into the credit scoring pipeline. For every auto-declined or underwriter-routed application, the system computes TreeSHAP values for all 200 features, selects the top-4 features by absolute SHAP value, and maps each to a regulatory reason code.
from dataclasses import dataclass, field
from typing import List, Dict, Tuple, Optional
import numpy as np
import shap
@dataclass
class AdverseActionNotice:
"""ECOA-compliant adverse action notice for a credit decision.
Contains the top factors driving an individual denial, mapped
to regulatory reason codes, with SHAP values for audit purposes.
"""
application_id: str
model_version: str
prediction: float
decision: str # "auto_decline", "underwriter_review"
reasons: List[Dict[str, str]] # [{code, description, shap_value}]
counterfactual: Optional[Dict[str, str]] # Actionable recourse
timestamp: str
explanation_hash: str
# Feature-to-reason-code mapping (subset of 200 features)
FEATURE_TO_REASON = {
"debt_to_income_ratio": {
"code": "R01",
"description": "Amount owed on accounts is too high relative to income",
},
"credit_history_months": {
"code": "R02",
"description": "Length of credit history is insufficient",
},
"num_derogatory_marks": {
"code": "R03",
"description": "Derogatory public record or collection filed",
},
"num_credit_inquiries_6mo": {
"code": "R04",
"description": "Too many inquiries in the last 6 months",
},
"revolving_utilization": {
"code": "R05",
"description": "Proportion of revolving balances to credit limits is too high",
},
"months_since_last_delinquency": {
"code": "R06",
"description": "Too few months since last delinquency",
},
"total_open_accounts": {
"code": "R07",
"description": "Number of established accounts is insufficient",
},
"annual_income": {
"code": "R08",
"description": "Income insufficient for amount of credit requested",
},
"employment_length_months": {
"code": "R09",
"description": "Length of employment is insufficient",
},
"available_credit": {
"code": "R10",
"description": "Available credit on existing accounts is too low",
},
}
def generate_adverse_action(
model: "xgb.Booster",
explainer: shap.TreeExplainer,
application: Dict[str, float],
application_id: str,
model_version: str,
n_reasons: int = 4,
) -> AdverseActionNotice:
"""Generate an ECOA-compliant adverse action notice.
Computes TreeSHAP values, selects the top factors driving the
denial, and maps them to regulatory reason codes.
Args:
model: Trained XGBoost credit scoring model.
explainer: Pre-initialized TreeSHAP explainer.
application: Dictionary of feature name to feature value.
application_id: Unique application identifier.
model_version: Model version string for audit trail.
n_reasons: Number of adverse action reasons (typically 2-4).
Returns:
AdverseActionNotice with individualized reasons.
"""
import pandas as pd
from datetime import datetime, timezone
import hashlib
import json
# Convert to DataFrame for SHAP
features = pd.DataFrame([application])
shap_values = explainer(features, check_additivity=True)
prediction = float(shap_values.base_values[0] + shap_values.values[0].sum())
# Select top factors pushing TOWARD denial (positive SHAP = higher default risk)
feature_shap = list(zip(features.columns, shap_values.values[0]))
# Sort by SHAP value descending — we want features that most increase risk
denial_factors = sorted(feature_shap, key=lambda x: x[1], reverse=True)
reasons = []
for feature_name, shap_value in denial_factors:
if len(reasons) >= n_reasons:
break
if shap_value <= 0:
break # Only report factors that contribute to denial
if feature_name in FEATURE_TO_REASON:
reason_info = FEATURE_TO_REASON[feature_name]
reasons.append({
"code": reason_info["code"],
"description": reason_info["description"],
"feature": feature_name,
"shap_value": f"{shap_value:.4f}",
"feature_value": str(application.get(feature_name, "N/A")),
})
# Determine decision
decision = "auto_decline" if prediction > 0.35 else "underwriter_review"
timestamp = datetime.now(timezone.utc).isoformat()
explanation_hash = hashlib.sha256(
json.dumps(reasons, sort_keys=True).encode()
).hexdigest()
return AdverseActionNotice(
application_id=application_id,
model_version=model_version,
prediction=prediction,
decision=decision,
reasons=reasons,
counterfactual=None, # Added in Phase 2
timestamp=timestamp,
explanation_hash=explanation_hash,
)
Phase 2: Counterfactual Recourse
The second phase added counterfactual explanations: for each denied applicant, the system identifies the smallest feasible changes that would move the prediction below the 0.35 auto-decline threshold (or below 0.12 for full approval).
@dataclass
class CreditCounterfactualConfig:
"""Configuration for credit scoring counterfactual generation.
Encodes domain constraints: immutable features, causal
dependencies, and actionable ranges.
"""
# Features that cannot be changed
immutable: List[str] = field(default_factory=lambda: [
"age", "months_since_oldest_account",
"num_derogatory_marks", # Cannot undo past derogatory marks
])
# Causal constraints: changing a parent propagates to children
causal_edges: Dict[str, List[str]] = field(default_factory=lambda: {
"annual_income": ["debt_to_income_ratio"],
"total_revolving_balance": [
"revolving_utilization", "debt_to_income_ratio"
],
})
# Actionable ranges: what is realistically achievable in 12 months
actionable_ranges: Dict[str, Tuple[float, float]] = field(
default_factory=lambda: {
"debt_to_income_ratio": (0.0, 1.0),
"revolving_utilization": (0.0, 1.0),
"num_credit_inquiries_6mo": (0, 10),
"total_revolving_balance": (0, 500000),
"annual_income": (0, 1000000),
}
)
# Target: move prediction below this threshold
target_threshold: float = 0.35
# Ideal: move prediction below the auto-approve threshold
ideal_threshold: float = 0.12
def generate_credit_counterfactual(
model,
application: Dict[str, float],
config: CreditCounterfactualConfig,
feature_names: List[str],
) -> Dict[str, str]:
"""Generate a causally consistent counterfactual for a credit denial.
Finds the smallest set of actionable changes that would move
the prediction below the denial threshold, respecting causal
constraints and immutability.
Returns:
Dictionary with actionable recommendations, e.g.:
{"recommendation_1": "Reduce revolving utilization from 82% to 45%",
"recommendation_2": "Avoid new credit inquiries for 6 months"}
"""
import pandas as pd
original_pred = model.predict(
pd.DataFrame([application])
)[0]
# Greedy approach: try changing one actionable feature at a time,
# ranked by SHAP importance, until prediction crosses threshold
explainer = shap.TreeExplainer(model)
shap_values = explainer(pd.DataFrame([application]))
feature_shap = list(zip(feature_names, shap_values.values[0]))
# Sort by SHAP value descending (most harmful features first)
sorted_features = sorted(feature_shap, key=lambda x: x[1], reverse=True)
recommendations = {}
modified = dict(application)
rec_count = 0
for feature_name, shap_val in sorted_features:
if shap_val <= 0:
break # Remaining features help the applicant
if feature_name in config.immutable:
continue
if feature_name not in config.actionable_ranges:
continue
# Determine the direction of improvement
lo, hi = config.actionable_ranges[feature_name]
current_val = modified[feature_name]
# For risk-increasing features, try reducing toward the
# population median (or a target that reduces SHAP contribution)
if shap_val > 0:
# Binary search for the minimum change that helps
target_val = lo + (current_val - lo) * 0.5 # 50% reduction
modified[feature_name] = target_val
# Propagate causal effects
if feature_name in config.causal_edges:
for child in config.causal_edges[feature_name]:
if child == "debt_to_income_ratio" and feature_name == "annual_income":
total_debt = application.get("total_debt", 0)
modified[child] = total_debt / max(target_val, 1)
elif child == "revolving_utilization":
credit_limit = application.get("total_credit_limit", 1)
modified[child] = target_val / max(credit_limit, 1)
new_pred = model.predict(pd.DataFrame([modified]))[0]
rec_count += 1
change_desc = (
f"Reduce {feature_name.replace('_', ' ')} from "
f"{current_val:.1f} to {target_val:.1f}"
)
recommendations[f"recommendation_{rec_count}"] = change_desc
if new_pred < config.target_threshold:
recommendations["projected_outcome"] = (
f"Projected score: {new_pred:.3f} "
f"(below {config.target_threshold} threshold)"
)
break
if not recommendations:
recommendations["note"] = (
"No actionable changes identified within feasible ranges."
)
return recommendations
Validation Results
Explanation Accuracy Audit
The model risk management (MRM) team audited 500 denied applications, comparing the TreeSHAP-based reasons to the old boilerplate reasons.
| Metric | Boilerplate Reasons | TreeSHAP Reasons |
|---|---|---|
| Top-1 reason matches true top driver | 38.2% | 94.6% |
| Top-4 reasons capture >80% of SHAP mass | 41.0% | 97.8% |
| Reason ordering matches SHAP ranking | 22.4% | 100% (by construction) |
| Applicant comprehension (survey, n=200) | 3.1 / 5.0 | 4.2 / 5.0 |
The boilerplate system's top-1 accuracy of 38.2% meant that for 61.8% of denied applicants, the most prominently stated reason was not actually the most important factor in their individual denial. The TreeSHAP system's 94.6% top-1 accuracy (the remaining 5.4% resulted from SHAP values that were close in magnitude, where the second-ranked feature was within 0.005 of the first) represents a qualitative improvement in the accuracy of consumer-facing explanations.
Counterfactual Quality
For the 500 audited denials, the team generated counterfactual explanations and evaluated their quality:
| Metric | Value |
|---|---|
| Counterfactuals generated successfully | 472 / 500 (94.4%) |
| Mean features changed | 2.3 |
| Median features changed | 2 |
| All counterfactuals within actionable ranges | 100% (by construction) |
| Causally consistent (verified by domain expert) | 89.4% |
| Mean projected score improvement | 0.18 (from 0.42 to 0.24 average) |
The 28 applications without successful counterfactuals (5.6%) had multiple severe risk factors (e.g., recent bankruptcy + derogatory marks + extremely high utilization) where no actionable changes within the 12-month horizon could move the prediction below the threshold. For these applicants, the system reported: "Your application was affected by multiple factors. We recommend consulting with a credit counselor for a personalized improvement plan."
Regulatory Response
The OCC examiner who had flagged the boilerplate system reviewed the TreeSHAP-based system during the next examination cycle. Key findings from the examination report:
- The individualized explanation methodology was "consistent with the requirements of Regulation B and the guidance in CFPB Circular 2022-03"
- The counterfactual explanations were noted as "a best practice that goes beyond minimum requirements"
- The audit trail — with hash chain integrity, model version tracking, and 25-month retention — was "adequate for examination purposes"
- One finding: the examiner requested that the system log not only the top-4 reasons but the complete SHAP vector for the top 20 features, to enable post-hoc analysis of explanation patterns
Lessons Learned
-
Individual-level explanations are a regulatory requirement, not a nice-to-have. The CFPB's 2022 guidance makes clear that boilerplate reason codes do not satisfy ECOA when the model uses complex features. TreeSHAP — exact, deterministic, fast — is the current gold standard for tree-based credit models.
-
Counterfactuals add genuine value but require domain constraints. Unconstrained counterfactual optimization produces mathematically valid but practically useless recommendations. Immutability constraints, causal consistency, and actionable ranges transform counterfactuals from a mathematical exercise into genuinely useful applicant guidance.
-
The explanation infrastructure is as important as the explanation method. The TreeSHAP computation takes 3ms per application. Building the audit trail, reason-code mapping, counterfactual generation, NL formatting, and regulatory documentation took 4 months of engineering effort. The computation is trivial; the infrastructure is substantial.
-
Explanation monitoring catches novel problems. Two months after deployment, the explanation monitoring system flagged a shift:
zip_code_cluster— a derived feature representing geographic risk — moved from the 8th most common top-4 factor to the 2nd. Investigation revealed a feature engineering change that increased the feature's predictive power but also increased its correlation with race. The fairness review board (Chapter 31) convened an emergency review. Without explanation monitoring, this shift would have been invisible in aggregate metrics.