PSI (Population Stability Index)
The population stability index (PSI) measures how much the distribution of one variable has moved between two samples: a baseline, such as the training data or an earlier month, and a current sample, such as this month’s production traffic. It is applied to model scores and to individual input features, and it comes from credit scoring, where lenders used it to check whether the applicants being scored still resembled those a scorecard was built on.
How it is calculated
Divide the variable’s range into bins, often ten bins holding equal shares of the baseline, or one bin per category for a categorical feature. For each bin i, let pi be the baseline share and qi the current share. Then
PSI = Σi (pi − qi) × ln(pi / qi)
Each term is zero or positive, because the difference and the logarithm always have the same sign, so PSI is zero only when every bin’s share is unchanged. The sum equals the Kullback–Leibler divergence taken in both directions and added together, known as the Jeffreys divergence, which is why it treats the two vectors of shares symmetrically when the bin boundaries stay fixed. Recomputing baseline quantiles after swapping samples can change those boundaries and therefore the result. Yurdakul and Naranjo derive this connection and discuss the binning qualification.
A small example with credit scores in three bins:
| Score range | Baseline share | Current share | Term |
|---|---|---|---|
| Low (0–300) | 0.20 | 0.10 | 0.10 × ln(2) = 0.0693 |
| Medium (301–600) | 0.50 | 0.60 | −0.10 × ln(0.833) = 0.0182 |
| High (601–900) | 0.30 | 0.30 | 0 |
| PSI | 0.0875 | ||
The low-score group’s share has halved, from 20% to 10%, even though PSI is below 0.1. Its count need not have fallen: 20% of 1,000 is 200 applicants, while 10% of 3,000 is 300. PSI compares proportions; whether the change matters requires the denominator, variable, and operational context.
What the value depends on
Bins. PSI sees only changes that move observations between bins. A shift inside a bin is invisible, so a few coarse bins can miss real change, while many narrow bins pick up more random variation. Bin boundaries should be fixed from the baseline and kept the same for every comparison, or values from different periods cannot be compared.
Empty bins. If a bin is empty in one sample but not the other, the logarithm is undefined and the index becomes infinite. This happens with a new category value or a rare range in a small window. Implementations replace zero shares with a small constant, and that choice changes the result, so it should be recorded. A new category is usually worth reporting on its own rather than hiding inside an index.
Sample size. Two samples from exactly the same distribution can give a positive PSI because their bin shares differ by chance. Identical empirical bin shares give zero; the positive values describe sampling variation, not a change that must occur in every comparison. For independent samples of sizes n and m in b fixed bins with positive probabilities and sufficiently large expected counts, a large-sample approximation to the usual finite, no-change PSI is (b − 1)(1/n + 1/m). It describes the central sampling behavior; an unsmoothed one-sided empty bin makes PSI infinite, so it is not a claim about an unconditional finite expectation at every sample size. With ten bins and 1,000 observations in each window, that is about 0.018, and in a simulation of that setting one comparison in a hundred exceeded 0.0443 with no change at all. With 500 in each window, the typical no-change value is about 0.036 and the simulated 99th percentile is 0.0849, close to the common 0.1 cut-off. With very large samples the noise vanishes, and small but real changes produce small values.
About the 0.1 and 0.25 rule
A rule of thumb from credit-scoring practice reads PSI below 0.1 as stable, 0.1 to 0.25 as a moderate shift worth watching, and above 0.25 as a major shift. Yurdakul and Naranjo discuss this convention and its lack of calibrated error rates; the fixed cutoffs themselves ignore bins and sample size. For scale, in a simulation with ten decile bins and large samples, moving a normally distributed feature’s mean by 0.2 standard deviations gave a PSI of about 0.04, and a shift of half a standard deviation about 0.24. Whether a 0.2 standard-deviation shift matters depends entirely on the feature and the model.
A PSI value also says nothing directly about model quality. A feature the model barely uses can shift a great deal without harm, and a model can degrade when the relationship between inputs and outcome changes while every input PSI stays low. Thresholds are best set per variable, checked against past periods for how often they would have fired without a problem and whether they would have caught changes that mattered, and used to start an investigation rather than a retraining. That design is described in drift detection, and what a detected shift means in data drift.
The following author-created simulation uses Python and NumPy, seed 20260922, independent standard-normal samples, and baseline decile bins recalculated for each replicate. It uses no smoothing and reports infinite results explicitly. Across 2,000 comparisons per sample size, the means are 0.0364, 0.0182, and 0.0036 for 500, 1,000, and 5,000 observations per sample; no empty-bin infinities occurred. The large-sample approximation above uses fixed bins, so these estimated-bin results are an illustration, not a proof of its assumptions. The final comparisons use 200,000 observations per sample and mean shifts of 0.1, 0.2, and 0.5, producing PSI 0.0108, 0.0386, and 0.2417. These synthetic results do not calibrate an operational threshold.
import numpy as np
rng = np.random.default_rng(20260922)
def psi(reference, current):
edges = np.r_[-np.inf, np.quantile(reference, np.arange(1, 10) / 10), np.inf]
p = np.histogram(reference, edges)[0] / len(reference)
q = np.histogram(current, edges)[0] / len(current)
# No smoothing: a one-sided empty bin has infinite divergence.
if np.any((p == 0) != (q == 0)):
return np.inf
keep = (p > 0) & (q > 0)
return np.sum((p[keep] - q[keep]) * np.log(p[keep] / q[keep]))
for n in (500, 1000, 5000):
values = np.array([psi(rng.normal(size=n), rng.normal(size=n)) for _ in range(2000)])
print(n, f'mean={values.mean():.4f}', f'q99={np.quantile(values, .99):.4f}',
f'infinite={np.isinf(values).sum()}')
for shift in (.1, .2, .5):
value = psi(rng.normal(size=200000), rng.normal(loc=shift, size=200000))
print(f'shift={shift:.1f}', f'PSI={value:.4f}')
References
- Bilal Yurdakul and Joshua Naranjo, Statistical properties of the population stability index, Journal of Risk Model Validation 14(4), 89–100 — formula, divergence relationship, binning qualification, and sampling properties.
Discover more from Insightful Data Lab
Subscribe to get the latest posts sent to your email.
