Skip to content

Monitor a credit model

A score shift does not, by itself, tell you whether outcomes worsened. Imagine a lender whose model was trained on low-risk applicants. A new partner channel or a changing market brings in riskier applicants. The model keeps scoring applications. A rise in predicted risk could mean the pool is riskier, the model performs worse on it, or both.

The dataset

HELOC means home equity line of credit: a revolving credit line secured by the borrower's home. This FICO Explainable AI Challenge dataset contains anonymized credit-bureau features. It also records whether an account went 90 days past due, or worse, in its first 24 months.

The model estimates each applicant's probability of that outcome (default probability). A group's mean predicted probability is its model-implied bad rate. The example loads 9,871 applications from OpenML (data_id=45554) with fetch_openml.

The split

We split applications at 63 on ExternalRiskEstimate, following Gardner et al. (2023). Higher values indicate safer applicants. Here, source is the lower-risk reference group used to train the model; target is a simulated deployment of higher-risk applicants:

  • Source: 7,683 applications above 63, the low-risk book, with a 43.5% observed bad rate.
  • Target: 2,188 applications at or below 63, the riskier book, with an 81.9% observed bad rate.

The code below creates these source and target samples:

import re

import pandas as pd
from sklearn.datasets import fetch_openml

fico = fetch_openml(data_id=45554, as_frame=True)
X, y = fico.data, fico.target

re_obj = re.compile(r"external.*risk.*estimate", flags=re.IGNORECASE)
col_split = next((c for c in X.columns if re_obj.search(c)), None)
mask_high = X[col_split].astype(float) > 63

X_train = X[mask_high].reset_index(drop=True)
y_train = y[mask_high].reset_index(drop=True)
X_deployment = X[~mask_high].reset_index(drop=True)
y_deployment = y[~mask_high].reset_index(drop=True)

Scores

With source and target defined, compare three scores. They are not interchangeable: each answers a different question and becomes available at a different time.

Score Requires labels? Harmful direction worse When it helps
Predicted risk No Higher risk higher The output itself represents harm
Confidence (LogitGap) No Lower certainty lower Early warning before labels arrive
Prediction error (Brier) Yes Larger error higher Post-outcome accuracy check

Use predicted risk when the model output already represents harm, confidence for an early warning while labels are delayed, and prediction error for the clearest post-outcome check.

The score examples build on the same model and out-of-sample predictions:

import numpy as np
import samesame as ss

from sklearn.ensemble import RandomForestClassifier

y_train_binary = y_train.map({"Good": 0, "Bad": 1}).astype(int).values

rf_bad = RandomForestClassifier(
    n_estimators=500,
    oob_score=True,
    random_state=12345,
    min_samples_leaf=10,
)
rf_bad.fit(X_train, y_train_binary)

train_risk = rf_bad.oob_decision_function_[:, 1].ravel()
deployment_risk = rf_bad.predict_proba(X_deployment)[:, 1].ravel()

Start with predicted risk when the model output already represents harm. P(default) is directly tied to the outcome: larger means more harm, so use worse="higher".

harm = ss.test_harm(
    source=train_risk, target=deployment_risk,
    worse="higher", rng=np.random.default_rng(12345),
)
print(f"Harm  p-value: {harm.pvalue:.4f}")   # → 0.0001

The test finds evidence of movement toward higher risk. Mean predicted risk—the model-implied bad rate—rises from about 44% in source to 73% in target.

LogitGap (Liang et al., 2025) measures how clearly the model favors one class over the other. A large gap means high confidence; a small gap means the model is undecided. Lower confidence is harmful here, so we use worse="lower".

import numpy as np
from scipy.special import logit
def logit_gap(logits: NDArray[np.float64]) -> NDArray[np.float64]:
    logits = np.asarray(logits, dtype=float)
    max_logits = np.max(logits, axis=1)
    mean_rest = (np.sum(logits, axis=1) - max_logits) / (logits.shape[1] - 1)
    return max_logits - mean_rest


def confidence_scores_from_probabilities(
    probabilities: NDArray[np.float64],
    *,
    clip: float = 1e-6,
) -> NDArray[np.float64]:
    clipped = np.clip(np.asarray(probabilities, dtype=float), clip, 1.0 - clip)
    return logit_gap(logit(clipped))


train_conf = confidence_scores_from_probabilities(
    rf_bad.oob_decision_function_
)
deploy_conf = confidence_scores_from_probabilities(
    rf_bad.predict_proba(X_deployment)
)

harm = ss.test_harm(
    source=train_conf, target=deploy_conf,
    worse="lower",  # lower confidence = harm
    rng=np.random.default_rng(12345),
)
print(f"Harm  p-value: {harm.pvalue:.4f}")  # → 1.0000

Default probability and confidence answer different questions. The first asks, "How risky does the model think this applicant is?" LogitGap asks, "How sure is the model?" Target predictions are both riskier and more confident. The test finds no evidence of a harmful confidence drop. Higher confidence alone does not mean the model is safer; confidence adds a separate dimension to risk.

Once labels arrive, prediction error (Brier) gives a post-outcome check. It uses the same split and source-trained model as the other scores. Now that labels are available, we can measure each prediction's error.

y_deployment_binary = y_deployment.map({"Good": 0, "Bad": 1}).astype(int).values
brier_source = (y_train_binary - train_risk) ** 2
brier_target = (y_deployment_binary - deployment_risk) ** 2

harm = ss.test_harm(
    source=brier_source, target=brier_target,
    worse="higher", rng=np.random.default_rng(12345),
)
print(f"Brier p-value: {harm.pvalue:.4f}")  # → 1.0000

Brier score asks whether those probabilities were accurate once outcomes arrived. With p=1.0000, we do not reject the null of no harmful increase in prediction error. Risk rises on this split, but the test finds no evidence of a harmful error increase.

Wrap up

Predicted risk is higher in target, while these tests find no evidence of lower confidence or higher prediction error. The split deliberately selects a riskier target group; it cannot show why risk differs or prove that model quality is unchanged.

If the comparison is driven by applications in regions where the groups barely overlap, see Weight for common support.