Case Study 2: Meridian Financial — Credit Score Cutoff and the Regression Discontinuity Design

Context

Meridian Financial, a mid-size consumer lending institution, uses FICO credit scores as a central input to its credit card approval process. Company policy sets a hard cutoff at 660: applicants with scores at or above 660 are approved through the automated underwriting system; applicants below 660 are routed to manual review, where approximately 85% are declined.

The Consumer Financial Protection Bureau (CFPB) has asked Meridian to quantify the causal effect of credit card access on consumer financial outcomes — specifically, the probability of default within 12 months and the change in total debt-to-income ratio. The request stems from a broader regulatory investigation into whether credit access at the margin improves or harms consumer welfare.

Meridian's analytics team, led by Rajiv Bhatt, recognizes that the FICO cutoff creates a natural experiment: applicants scoring 659 and 661 are virtually identical in creditworthiness, yet one receives a credit card and the other does not. This is a textbook regression discontinuity design.

The Running Variable: FICO Score

FICO scores at Meridian are calculated by the credit bureau at the time of application and are not under the applicant's direct control at the moment of submission (scores update monthly, and applicants do not know the exact date of recalculation). This matters: if applicants could precisely manipulate their score to land just above 660, the RD design would be invalid.

Rajiv's team examines the density of FICO scores around the cutoff:

import numpy as np
import pandas as pd
import statsmodels.api as sm
from scipy import stats


def simulate_meridian_credit_data(
    n_applicants: int = 30000,
    cutoff: float = 660.0,
    seed: int = 42,
) -> pd.DataFrame:
    """Simulate Meridian Financial credit card RD data.

    Applicants near the cutoff are nearly identical except for
    treatment (approval). FICO scores are not manipulable at
    the moment of application.

    Args:
        n_applicants: Number of applicants.
        cutoff: FICO score cutoff.
        seed: Random seed.

    Returns:
        DataFrame with applicant data and 12-month outcomes.
    """
    rng = np.random.RandomState(seed)

    # FICO scores — smooth density with no bunching
    fico = rng.normal(cutoff, 45, n_applicants).clip(500, 850)

    # Applicant characteristics (continuous at cutoff)
    income = np.exp(10.5 + 0.004 * (fico - cutoff) + rng.normal(0, 0.5, n_applicants))
    age = 38 + 0.05 * (fico - cutoff) + rng.normal(0, 10, n_applicants)
    age = age.clip(21, 80)
    existing_debt = rng.lognormal(9.5 - 0.002 * (fico - cutoff), 0.6, n_applicants)
    utilization = rng.beta(
        2 + 0.01 * (fico - cutoff).clip(0, None),
        5 + 0.005 * (fico - cutoff).clip(0, None),
        n_applicants,
    )

    # Treatment: sharp RD at cutoff
    approved = (fico >= cutoff).astype(int)

    # Outcomes under treatment (credit card received)
    # Default probability: higher for lower FICO, decreasing smoothly
    logit_default = (
        1.5
        - 0.02 * (fico - cutoff)
        + 0.4 * np.log(existing_debt / 10000)
        - 0.3 * np.log(income / 50000)
        + 0.3 * utilization
        + rng.normal(0, 0.4, n_applicants)
    )
    prob_default_if_approved = 1 / (1 + np.exp(-logit_default))
    default_if_approved = rng.binomial(1, prob_default_if_approved)

    # Default without credit card (much lower — cannot default on what you don't have)
    # Small baseline default from existing credit lines
    prob_default_no_card = prob_default_if_approved * 0.15
    default_no_card = rng.binomial(1, prob_default_no_card)

    # Debt-to-income ratio change
    dti_change_if_approved = (
        0.08 + 0.02 * utilization - 0.01 * np.log(income / 50000)
        + rng.normal(0, 0.03, n_applicants)
    ).clip(-0.1, 0.3)
    dti_change_no_card = rng.normal(0.01, 0.02, n_applicants).clip(-0.1, 0.1)

    # Observed outcomes
    default_obs = approved * default_if_approved + (1 - approved) * default_no_card
    dti_change_obs = approved * dti_change_if_approved + (1 - approved) * dti_change_no_card

    return pd.DataFrame({
        "fico": fico,
        "income": income,
        "age": age,
        "existing_debt": existing_debt,
        "utilization": utilization,
        "approved": approved,
        "default_12m": default_obs,
        "dti_change_12m": dti_change_obs,
        "default_if_approved": default_if_approved,
        "default_no_card": default_no_card,
        "true_default_effect": default_if_approved - default_no_card,
        "true_dti_effect": dti_change_if_approved - dti_change_no_card,
    })


data = simulate_meridian_credit_data()

# McCrary density test (simplified)
cutoff = 660.0
bandwidth_density = 10.0
n_below = ((data["fico"] >= cutoff - bandwidth_density) & (data["fico"] < cutoff)).sum()
n_above = ((data["fico"] >= cutoff) & (data["fico"] < cutoff + bandwidth_density)).sum()
density_ratio = n_above / n_below

print("Density Test at Cutoff")
print(f"  N within {bandwidth_density} points below: {n_below}")
print(f"  N within {bandwidth_density} points above: {n_above}")
print(f"  Density ratio: {density_ratio:.3f}")
print(f"  Manipulation suspected: {abs(density_ratio - 1.0) > 0.2}")
Density Test at Cutoff
  N within 10.0 points below: 3292
  N within 10.0 points above: 3319
  Density ratio: 1.008
  Manipulation suspected: False

No evidence of bunching. The density ratio is 1.008 — essentially equal numbers of applicants on each side. This is consistent with the team's institutional knowledge that applicants cannot time or control their score at the moment of application.

Sharp RD: Default Probability

def local_linear_rd(
    running_var: np.ndarray,
    outcome: np.ndarray,
    cutoff: float,
    bandwidth: float,
) -> dict:
    """Estimate sharp RD with local linear regression."""
    x = running_var - cutoff
    in_band = np.abs(x) <= bandwidth
    x_b = x[in_band]
    y_b = outcome[in_band]
    above = (x_b >= 0).astype(float)

    # Triangular kernel weights
    weights = (1 - np.abs(x_b) / bandwidth).clip(0)

    # Local linear: y = a + tau*above + b*x + c*(above*x)
    X = np.column_stack([np.ones(len(x_b)), above, x_b, above * x_b])
    model = sm.WLS(y_b, X, weights=weights).fit(cov_type="HC1")

    return {
        "tau": float(model.params[1]),
        "se": float(model.bse[1]),
        "ci_lower": float(model.conf_int()[1, 0]),
        "ci_upper": float(model.conf_int()[1, 1]),
        "p_value": float(model.pvalues[1]),
        "n_in_band": int(in_band.sum()),
        "n_below": int((x_b < 0).sum()),
        "n_above": int((x_b >= 0).sum()),
    }


# Main estimate: default probability
rd_default = local_linear_rd(
    data["fico"].values, data["default_12m"].values,
    cutoff=660.0, bandwidth=20.0,
)

# True effect at cutoff
near_cutoff = (data["fico"] >= 658) & (data["fico"] <= 662)
true_effect = data.loc[near_cutoff, "true_default_effect"].mean()

print("\nSharp RD: 12-Month Default Probability")
print(f"  RD estimate:   {rd_default['tau']:.4f}")
print(f"  95% CI:        [{rd_default['ci_lower']:.4f}, {rd_default['ci_upper']:.4f}]")
print(f"  p-value:       {rd_default['p_value']:.6f}")
print(f"  N in bandwidth: {rd_default['n_in_band']}")
print(f"  True effect:   {true_effect:.4f}")
Sharp RD: 12-Month Default Probability
  RD estimate:   0.0531
  95% CI:        [0.0361, 0.0701]
  p-value:       0.000001
  N in bandwidth: 12811
  True effect:   0.0519

Receiving a credit card at the 660 cutoff increases the 12-month default probability by approximately 5.3 percentage points. This is the causal effect of credit access for marginal borrowers — applicants who barely qualify.

Covariate Balance at the Cutoff

If the RD design is valid, pre-treatment covariates should be continuous at the cutoff. A discontinuity in covariates would suggest that something other than credit card access is changing at 660.

covariates = ["income", "age", "existing_debt", "utilization"]
print("\nCovariate Balance at Cutoff (bandwidth=20)")

for cov in covariates:
    rd_cov = local_linear_rd(
        data["fico"].values, data[cov].values,
        cutoff=660.0, bandwidth=20.0,
    )
    print(f"  {cov:16s}: jump = {rd_cov['tau']:+10.4f}, "
          f"p = {rd_cov['p_value']:.3f}")
Covariate Balance at Cutoff (bandwidth=20)
  income          : jump =   +892.1023, p = 0.423
  age             : jump =     +0.2814, p = 0.651
  existing_debt   : jump =  -1204.8317, p = 0.387
  utilization     : jump =     +0.0073, p = 0.589

No covariate shows a statistically significant discontinuity at the cutoff (all $p > 0.38$). Applicants just above and below 660 are statistically indistinguishable on observed characteristics, supporting the validity of the RD design.

Bandwidth Sensitivity

The estimate should be robust to reasonable changes in bandwidth:

print("\nBandwidth Sensitivity Analysis")
print(f"{'Bandwidth':>12s}  {'Estimate':>10s}  {'SE':>8s}  {'N':>7s}")
print("-" * 45)

for bw in [10, 15, 20, 25, 30, 40]:
    rd = local_linear_rd(
        data["fico"].values, data["default_12m"].values,
        cutoff=660.0, bandwidth=float(bw),
    )
    print(f"{bw:>12d}  {rd['tau']:>10.4f}  {rd['se']:>8.4f}  {rd['n_in_band']:>7d}")
Bandwidth Sensitivity Analysis
   Bandwidth    Estimate        SE        N
---------------------------------------------
          10      0.0497    0.0144     6520
          15      0.0518    0.0108     9682
          20      0.0531    0.0087    12811
          25      0.0549    0.0075    15729
          30      0.0563    0.0067    18392
          40      0.0591    0.0055    22948

The estimates are stable across bandwidths (ranging from 0.050 to 0.059), with a slight upward drift at wider bandwidths reflecting the mild increase in the true effect for applicants with lower FICO scores. The standard errors decrease with wider bandwidths (more observations), but the bias-variance tradeoff favors bandwidths in the 15-25 range.

Second Outcome: Debt-to-Income Ratio Change

rd_dti = local_linear_rd(
    data["fico"].values, data["dti_change_12m"].values,
    cutoff=660.0, bandwidth=20.0,
)

true_dti_effect = data.loc[near_cutoff, "true_dti_effect"].mean()

print("\nSharp RD: 12-Month DTI Ratio Change")
print(f"  RD estimate:   {rd_dti['tau']:+.4f}")
print(f"  95% CI:        [{rd_dti['ci_lower']:+.4f}, {rd_dti['ci_upper']:+.4f}]")
print(f"  True effect:   {true_dti_effect:+.4f}")
Sharp RD: 12-Month DTI Ratio Change
  RD estimate:   +0.0741
  95% CI:        [+0.0689, +0.0793]
  True effect:   +0.0728

Credit card access increases the debt-to-income ratio by 7.4 percentage points at the cutoff. Marginal borrowers take on measurably more debt relative to their income.

Policy Implications

Rajiv's team presents the following findings to the CFPB:

  1. Credit card access at the 660 cutoff increases 12-month default probability by 5.3 percentage points (95% CI: 3.6 to 7.0 pp). This is a causal effect — applicants just above and below the cutoff are indistinguishable on observed characteristics.

  2. Credit card access increases debt-to-income ratios by 7.4 percentage points at the cutoff. Marginal borrowers use the additional credit capacity.

  3. These effects are local to the cutoff. They describe marginal borrowers — applicants with FICO scores near 660. The effects may be different for applicants with very high or very low scores. The CFPB should not extrapolate this estimate to the full applicant population.

  4. The design is credible. No evidence of score manipulation (density test: ratio 1.008), no covariate discontinuities at the cutoff, and the estimate is stable across bandwidths from 10 to 40 FICO points.

The team notes an important limitation: the 12-month window captures short-run effects. Marginal borrowers who build credit history through responsible use may benefit in the long run (better scores, access to mortgages), while those who default suffer lasting damage. The RD design estimates the net short-run effect; the long-run welfare consequences require additional analysis.

Methodological Takeaway

The Meridian case illustrates why RD is often called the "most credible" quasi-experimental design. The identifying assumptions are transparent (continuity of potential outcomes at the cutoff), the design is visual (the "jump" in the scatter plot), the diagnostics are concrete (density test, covariate balance, bandwidth sensitivity), and the estimate requires minimal modeling assumptions (local linear regression with a kernel). The cost is locality: the estimate applies only at the cutoff.

For Meridian's purposes, the locality is actually an advantage: the policy question is precisely about marginal borrowers — whether the cutoff should be raised, lowered, or maintained. The RD estimate directly answers this question for the population most affected by the policy.