In scorecard terminology, PSI and CSI apply the same stability formula to different variables. The Population Stability Index summarizes movement in the model’s score distribution. The Characteristic Stability Index summarizes movement in an individual input characteristic. Comparing them helps locate distribution changes; it does not establish which feature caused a performance change.
The distinction matters because general ML tooling collapsed it. When a monitoring platform reports “PSI per feature,” that is CSI under a borrowed name. Nothing breaks, but the moment you sit in a model validation meeting with a credit-risk team, the two terms mean specific and different things, and the report has to say which one it computed.
For unstructured inputs and retrieval-based applications, monitoring tabular models vs LLM systems explains which stability checks need different instruments.
PSI vs CSI: same formula, different input
Both are the discrete symmetrised Kullback-Leibler divergence between a reference distribution and a current one:
index = Σ_b (Actual_b - Expected_b) * ln(Actual_b / Expected_b)
Expected_b is the share of the reference sample in bin b, Actual_b the share of the current sample. Feed it the score band distribution and you have PSI. Feed it one characteristic’s binned distribution and you have CSI for that characteristic. A 2025 arXiv paper on information-theoretic credit risk modeling shows the statistic is exactly Jeffreys divergence: non-negative, zero only at equality, and symmetric in the two samples.
The formula, the binning tradeoffs, the epsilon smoothing that keeps empty bins from producing infinities, and the code to compute it are all covered in population stability index explained. Everything below assumes that machinery and focuses on what changes when you point it at inputs instead of outputs.
A worked PSI and CSI example with offsetting drift
This hypothetical scorecard illustrates the arithmetic; it is not a measured production result. Take ten equal-frequency bands from a reference sample, so every band holds 10% of the population. Suppose the current score distribution looks like this:
| Score band | Expected | Actual | Contribution |
|---|---|---|---|
| 1 (worst) | 10% | 11% | 0.0010 |
| 2 | 10% | 11% | 0.0010 |
| 3 | 10% | 11% | 0.0010 |
| 4 | 10% | 10% | 0.0000 |
| 5 | 10% | 10% | 0.0000 |
| 6 | 10% | 10% | 0.0000 |
| 7 | 10% | 10% | 0.0000 |
| 8 | 10% | 9% | 0.0011 |
| 9 | 10% | 9% | 0.0011 |
| 10 (best) | 10% | 9% | 0.0011 |
PSI = 0.006. That is as green as a stability metric gets. Now compute CSI on two of the characteristics feeding that score.
Credit utilisation:
| Utilisation | Expected | Actual | Contribution |
|---|---|---|---|
| 0-10% | 22% | 10% | 0.0946 |
| 10-30% | 26% | 18% | 0.0294 |
| 30-50% | 20% | 20% | 0.0000 |
| 50-70% | 16% | 22% | 0.0191 |
| 70-90% | 10% | 19% | 0.0578 |
| 90%+ | 6% | 11% | 0.0303 |
CSI = 0.231. Verified income band, over the same window:
| Income band | Expected | Actual | Contribution |
|---|---|---|---|
| Under 30k | 18% | 10% | 0.0470 |
| 30-50k | 24% | 18% | 0.0173 |
| 50-80k | 26% | 26% | 0.0000 |
| 80-120k | 20% | 28% | 0.0269 |
| 120k+ | 12% | 18% | 0.0243 |
CSI = 0.116.
The applicant pool became materially more leveraged and materially higher earning at the same time. Inside the scorecard those two movements push the score in opposite directions and very nearly cancel. PSI reports a stable population. The population is not stable; it is a different population that happens to average out to the same score distribution, and its default behaviour has no obligation to average out the same way.
This is the entire argument for computing both. PSI alone cannot distinguish “nothing changed” from “several things changed and offset.”
Reading the two together
| PSI (score) | CSI (characteristics) | What it usually means | Action |
|---|---|---|---|
| Low | Low | No large shift in the monitored marginal distributions | Keep checking labeled outcomes and important segments |
| Low | One or more high | Offsetting shifts, or a characteristic with low weight in the model | Investigate the characteristic; check whether its weight makes the score insensitive to it |
| High | One high | A candidate feature shift to investigate | Trace the feature’s pipeline and check other causes |
| High | All low | Changed scoring logic or changed relationships between inputs | Check deployments, transformations, score scaling, and joint distributions |
The bottom row does not identify a single cause. Stable per-feature marginals can hide changed relationships between features. Also check for changes in the model artifact or score scaling, and compare transformation paths for training-serving skew.
Note also what neither metric can see. Both compare marginal distributions of things you can measure without labels. Neither can tell you whether the relationship between characteristics and default has changed, which is concept drift, and in credit risk it is the drift with the longest detection lag because ground truth arrives months later. Strategies for that blind window are in monitoring models when ground truth is late.
Thresholds, and how much to trust them
The convention inherited from credit scorecard practice is the same for both:
- Under 0.10 — no meaningful shift
- 0.10 to 0.25 — moderate shift, investigate and document
- 0.25 and above — significant shift, treat as a trigger for revalidation
Fiddler’s writeup uses a tighter 0.2 upper cutoff, which is the useful reminder that these are industry convention rather than derived quantities. Two practical adjustments are worth making. First, use feature importance and affected-segment evidence to prioritize investigation; a low model weight is not proof that a change can be ignored. Second, binning affects both indices, so state the bin definitions and smoothing policy in a reproducible report. Choosing between PSI, KS, and calibration error for a given monitoring question is worked through in choosing monitoring metrics.
Computing both from one function
There is no separate CSI implementation. There is one stability function and two call sites, and the only discipline that matters is freezing the bin edges from the reference sample:
import numpy as np
def finite_sample(values, name):
values = np.asarray(values, dtype=float)
if values.ndim != 1 or values.size == 0 or not np.isfinite(values).all():
raise ValueError(f"{name} must be a nonempty, finite, 1-D sample")
return values
def stability_index(expected, actual, edges, eps=1e-6):
"""PSI for scores, CSI for one numeric characteristic."""
expected = finite_sample(expected, "expected")
actual = finite_sample(actual, "actual")
if not np.isfinite(eps) or not 0 < eps < 1:
raise ValueError("eps must be finite and between 0 and 1")
edges = np.asarray(edges, dtype=float).copy()
if (edges.ndim != 1 or edges.size < 3 or np.isnan(edges).any()
or not np.isfinite(edges[1:-1]).all()
or not (edges[1:] > edges[:-1]).all()):
raise ValueError("edges must increase strictly with finite interior bounds")
# Preserve frozen interior boundaries; include both overflow tails.
edges[0], edges[-1] = -np.inf, np.inf
exp_counts = np.histogram(expected, edges)[0]
act_counts = np.histogram(actual, edges)[0]
exp_pct = exp_counts / exp_counts.sum()
act_pct = act_counts / act_counts.sum()
exp_pct = np.clip(exp_pct, eps, None)
act_pct = np.clip(act_pct, eps, None)
exp_pct /= exp_pct.sum()
act_pct /= act_pct.sum()
return float(np.sum((act_pct - exp_pct) * np.log(act_pct / exp_pct)))
def score_edges(reference_scores, bands=10):
reference_scores = finite_sample(reference_scores, "reference_scores")
if isinstance(bands, bool) or not isinstance(bands, (int, np.integer)) or bands < 2:
raise ValueError("bands must be an integer of at least 2")
if reference_scores.min() == reference_scores.max():
raise ValueError("constant reference: use an explicit value-change check")
quantiles = np.linspace(0, 100, bands + 1)[1:-1]
interior = np.unique(np.percentile(reference_scores, quantiles))
return np.r_[-np.inf, interior, np.inf]
# Hypothetical score samples, including two values beyond the reference range.
ref_scores = np.arange(10, dtype=float)
cur_scores = np.r_[ref_scores, 100.0, 100.0]
edges = score_edges(ref_scores)
assert np.histogram(ref_scores, edges)[0].sum() == ref_scores.size == 10
assert np.histogram(cur_scores, edges)[0].sum() == cur_scores.size == 12
print(round(stability_index(ref_scores, cur_scores, edges), 6)) # 0.164792
For numeric feature DataFrames, freeze one set of edges per characteristic and reuse it:
frozen_edges = {name: score_edges(ref_df[name]) for name in characteristics}
psi = stability_index(ref_scores, cur_scores, score_edges(ref_scores))
csi = {
name: stability_index(ref_df[name], cur_df[name], frozen_edges[name])
for name in characteristics
}
The infinite outer edges retain every finite observation, including both overflow tails. Repeated quantiles are collapsed, smoothing is followed by normalization, and empty or nonfinite samples are rejected. A constant reference needs a separate value-change check. These examples are for numeric values; categorical characteristics need counts over a shared set of categories instead of quantile edges.
Recomputing edges from each new batch is the failure that makes a stability report useless: both samples get binned to look uniform against themselves, and the index collapses toward zero no matter how far the distributions actually moved. Store the edges alongside the model artefact so the reference used in month eighteen is the one the model was validated against.
What the 2026 supervisory guidance changed
If your model sits inside a regulated institution, the reference point moved this year. On 17 April 2026 the Federal Reserve, OCC and FDIC issued SR 26-2, Revised Guidance on Model Risk Management, which supersedes and replaces SR 11-7, the 2011 letter that most model monitoring policy still cites, along with the 2021 BSA/AML model statement.
Two points matter for a stability report. First, the revised guidance describes ongoing model monitoring as evaluating “the extent to which a model is performing as expected given potential changes in products, exposures, activities, clients, data relevance, or market conditions” — which is a description of exactly what a CSI panel measures, and a reason to keep the per-characteristic detail in the report rather than only the headline PSI. Second, it states that the frequency and scope of monitoring reports depend on the nature of the model, the availability of new data, and model materiality, and it frames the whole document as a risk-based approach tailored to the organisation’s model risk profile rather than a uniform standard. The letter itself is scoped to institutions above 30 billion dollars in total assets, though it notes it can still be relevant below that where model exposure is significant.
Tiering models by materiality and then setting monitoring depth per tier is the same structure the ML model monitoring framework recommends for non-regulated systems, which is a rare case of supervisory guidance and engineering practice converging on the same answer.
Caveats
Neither index is a performance metric. A stable population can still be scored badly, and an unstable one can still be scored well. Pair both with realised discrimination and calibration once outcomes land.
High-cardinality characteristics need grouping. A variable with hundreds of levels becomes hundreds of near-empty bins, and its CSI is mostly sampling noise.
Empty bins need an explicit epsilon. A zero on either side sends the log term to infinity or silently drops a bin that should have flagged. Whatever epsilon you pick changes the number, so document it in the report.
Reference choice is a policy decision. A frozen validation snapshot measures drift away from the model’s design population; a rolling window measures month-over-month change and will not notice a slow two-year slide. Regulated monitoring generally wants the frozen snapshot, with a rolling view alongside it rather than instead of it.
Seasonality can explain distribution movement. Compare with an equivalent seasonal period as well as the fixed reference before deciding whether a shift warrants action. The broader set of things that masquerade as drift is catalogued in types of data drift.