Score Stability and Migration Matrix
Install and import#
npm install fintech-algorithmsimport { scoreStabilityAndMigrationMatrix } from "fintech-algorithms/model-validation-and-backtesting/classification-and-score-validation/score-stability-and-migration-matrix";Signature#
scoreStabilityAndMigrationMatrix(inputs)Matches two score snapshots of the same population by id, bands both scores on shared edges, and reports the band-to-band migration counts, per-record moves, and the drift between the two band distributions.
Parameters#
| Name | Type | Notes |
|---|---|---|
inputs | { baseline: Array<{ id: string; score: number }>; current: Array<{ id: string; score: number }>; band_edges: number[]; higher_score_higher_risk: true; baseline_observed_at: string; current_observed_at: string } | Two nonempty snapshots covering exactly the same set of ids, each record carrying a unique nonempty id and a finite score in [0,1]. band_edges needs at least three strictly increasing values running from 0 to 1, and yields one band fewer than it has edges. higher_score_higher_risk must be true, fixing the orientation of the improved and worsened counts. baseline_observed_at must sort strictly before current_observed_at as a string.band_edges: at least three values, strictly increasing, first 0 and last 1 · score: between 0 and 1 inclusive · higher_score_higher_risk: must be true |
Returns#
{ band_edges: number[]; matrix: number[][]; row_rates: Array<Array<number | null>>; baseline_band_shares: number[]; current_band_shares: number[]; migrations: Array<{ id: string; baseline_score: number; current_score: number; baseline_band: number; current_band: number; band_move: number; score_change: number }>; matched_count: number; stable_count: number; improved_count: number; worsened_count: number; stable_rate: number; mean_band_move: number; mean_absolute_band_move: number; mean_score_change: number; mean_absolute_score_change: number; band_distribution_total_variation: number; state: string }
matrix[i][j] counts records that started in band i and ended in band j, with row_rates the same rows normalised (null for an empty baseline band). migrations lists every id in ascending id order with its one-based bands and moves, a negative band_move counting as improved. band_distribution_total_variation is half the L1 gap between the two band share vectors, and state is migration-evaluated.
Errors#
- When
baselineorcurrentis not an array, or is empty — throws RangeError - When
higher_score_higher_riskis not exactlytrue— throws RangeError - When
baseline_observed_atorcurrent_observed_atis not a string, or the baseline does not sort strictly before the current — throws RangeError - When
band_edgesis not an array of at least three values — throws RangeError - When
band_edgesdoes not run strictly increasing from 0 to 1 — throws RangeError - When a snapshot entry is not an object — throws TypeError
- When a snapshot
idis missing, empty, or repeats within that snapshot — throws RangeError - When a snapshot
scoreis not a finite number — throws TypeError - When a snapshot
scorefalls outside [0,1] — throws RangeError - When the two snapshots do not carry identical id sets — throws RangeError
Complexity: time O(n log n + b^2),
space O(n + b^2).
Worked example#
verified This is the worked example published in the article, replayed by the test suite on every run. The output cannot drift.
Input#
{
"baseline": [
{
"id": "E01",
"score": 0.08
},
{
"id": "E02",
"score": 0.14
},
{
"id": "E03",
"score": 0.19
}
],
"current": [
{
"id": "E01",
"score": 0.1
},
{
"id": "E02",
"score": 0.18
},
{
"id": "E03",
"score": 0.17
}
],
"band_edges": [0, 0.2, 0.4, 0.6, 0.8, 1],
"higher_score_higher_risk": true,
"baseline_observed_at": "2025-01-01T00:00:00Z",
"current_observed_at": "2026-01-01T00:00:00Z"
}Call#
scoreStabilityAndMigrationMatrix(inputs)Returns#
object with 17 fields: band_edges, matrix, row_rates, baseline_band_shares, current_band_shares, migrations, matched_count, stable_count, …
{
"band_edges": [0, 0.2, 0.4, 0.6, 0.8, 1],
"matrix": [
[3, 0, 0, 0, 0],
[0, 2, 1, 0, 0],
[0, 0, 2, 1, 0]
],
"row_rates": [
[1, 0, 0, 0, 0],
[0, 0.6666666666666666, 0.3333333333333333, 0, 0],
[0, 0, 0.6666666666666666, 0.3333333333333333, 0]
],
"baseline_band_shares": [0.25, 0.25, 0.25, 0.16666666666666666, 0.08333333333333333],
"current_band_shares": [0.25, 0.16666666666666666, 0.25, 0.16666666666666666, 0.16666666666666666],
"migrations": [
{
"id": "E01",
"baseline_score": 0.08,
"current_score": 0.1,
"baseline_band": 1,
"current_band": 1,
"band_move": 0,
"score_change": 0.020000000000000004
},
{
"id": "E02",
"baseline_score": 0.14,
"current_score": 0.18,
"baseline_band": 1,
"current_band": 1,
"band_move": 0,
"score_change": 0.03999999999999998
},
{
"id": "E03",
"baseline_score": 0.19,
"current_score": 0.17,
"baseline_band": 1,
"current_band": 1,
"band_move": 0,
"score_change": -0.01999999999999999
}
],
"matched_count": 12,
"stable_count": 9,
"improved_count": 0,
"worsened_count": 3,
"stable_rate": 0.75,
"mean_band_move": 0.25,
"mean_absolute_band_move": 0.25,
"mean_score_change": 0.02583333333333333
}Showing 14 of 17 fields.
Other exports#
This module also exports
rocCurveAndRocAuc, precisionRecallCurveAndPrAuc, brierScore, logLoss, reliabilityDiagramAndExpectedCalibrationError, gainsLiftAndDecileCapture, costSensitiveThresholdOptimization, sliceBasedValidationBySectorCountryAndRegime, rareEventBacktestAndConfidenceBounds, calculate. Every module additionally exports run as an alias of its
primary function, and a meta object carrying its catalog id, domain, family,
shape and article URL.
Diagrams#
Calculation flow#
Score Stability and Migration Matrix calculation flow
flowchart LR
S1["Validate IDs score bounds band edges orientation and t"]
S2["Match baseline and current entities"]
S3["Assign both scores using identical edges"]
S4["Populate the migration matrix"]
S5["Normalize each baseline row"]
S1 --> S2
S2 --> S3
S3 --> S4
S4 --> S5
S5 --> D{"identical IDs ordered clocks orientation and band edges ar"}
D --> O["stable_rate + diagnostics"]
O --> A["Audit: All migration cells sum to the matched entity count and ev"]
How it works#
This page states the contract — how to call it correctly. The article explains the concept: why it works, and where it breaks.
References#
- Revised Guidance on Model Risk Management — Board of Governors of the Federal Reserve System, OCC, and FDIC
- Credit Risk Modelling: Current Practices and Applications — Basel Committee on Banking Supervision
- Evidence boundary