Credit scoring model stability: monotonicity and PSI in Python

The advice most beginners get in credit risk modeling is short: train an algorithm, look at the AUC or the Gini coefficient, ship it. It leaves out credit scoring model stability, which is the part that decides whether the model survives contact with production. I have watched models that looked excellent in a Jupyter notebook fall apart on real data because the variables underneath them were not stable over time.

Performance and reliability are different questions. If your variables do not tell a consistent story about risk, the score they produce is not worth much no matter how it benchmarks. Two things are worth validating before you get anywhere near final estimation: monotonicity and stability.

Monotonicity is the first check worth running

Monotonicity is a formal word for asking whether a variable makes sense. Every variable in a credit score should have a logical risk direction. As a person’s income rises, their probability of default should generally fall, which is a negative risk direction. If the data says higher earners are more likely to default, something is off, either in data quality or in how the sample was drawn.

During selection, person_age is the variable that misbehaves most often for me. The very young and the very old are expected to carry higher risk, and the middle of the range frequently shows no clear trend at all. When a variable inverts its risk direction from one year to the next, I cut it. You cannot refactor a bad foundation.

Discretizing variables for analysis

To study this, bin the continuous variables (terciles are usually enough) and calculate the empirical default rate for each bin. This is how I normally do it in Python with Pandas:

import pandas as pd

def bbioon_analyze_monotonicity(df, variable, target, bins=3):
    # Create quantiles
    df['bin'] = pd.qcut(df[variable], q=bins, duplicates='drop')
    
    # Calculate default rate per bin
    analysis = df.groupby('bin')[target].mean().reset_index()
    analysis.columns = ['Bin Range', 'Default Rate']
    
    return analysis

# Example usage for income
# income_risk = bbioon_analyze_monotonicity(train_df, 'person_income', 'default')
# print(income_risk)

Measuring credit scoring model stability with PSI

Once the risk direction holds, the next question is whether the variables stay put over time and across your train, test and out-of-time samples. That is what the Population Stability Index (PSI) measures. It is the industry standard here because it puts one number on a distributional shift using a logarithmic formula.

It compares the share of observations in each bin between a reference dataset and a target one. Below 0.10, you are fine. Between 0.10 and 0.25, keep watching it. Above 0.25, the population has changed enough that the model may be out of date before it ever ships.

On the data side, I have also written about credit scoring EDA and robust credit scoring models with Python.

Implementing the PSI formula in Python

This function calculates the shift directly instead of leaving it to judgement. It uses the standard PSI formula from the credit risk modeling documentation:

import numpy as np

def calculate_psi(expected, actual, buckets=10):
    def scale_to_proportions(arr):
        return arr / np.sum(arr)

    # Calculate proportions
    expected_percents = scale_to_proportions(np.histogram(expected, bins=buckets)[0])
    actual_percents = scale_to_proportions(np.histogram(actual, bins=buckets)[0])

    # Avoid division by zero
    expected_percents = np.where(expected_percents == 0, 0.0001, expected_percents)
    actual_percents = np.where(actual_percents == 0, 0.0001, actual_percents)

    # PSI Formula: sum((actual - expected) * ln(actual / expected))
    psi_value = np.sum((actual_percents - expected_percents) * np.log(actual_percents / expected_percents))
    
    return psi_value

If this kind of validation work is eating your dev hours, I can take it on. I have been working with WordPress, Python integrations and messy data logic since the 4.x days.

Before you ship

Most of the real work in a scoring model happens in validation rather than training. Checking monotonicity and stability is what keeps the result interpretable and keeps it working a year later. A good algorithm will happily hide a bad variable from you. Run the PSI numbers, confirm the risk directions, then ship.

author avatar
Ahmad Wael
I'm a WordPress and WooCommerce developer with 15+ years of experience building custom e-commerce solutions and plugins. I specialize in PHP development, following WordPress coding standards to deliver clean, maintainable code. Currently, I'm exploring AI and e-commerce by building multi-agent systems and SaaS products that integrate technologies like Google Gemini API with WordPress platforms, approaching every project with a commitment to performance, security, and exceptional user experience.