fintech-algorithms
Using a coding agent? Give it the skill: npx skills add IslamBaraka90/Fintech-Algorithms-Library What it does →

Score Stability and Migration Matrix

Install and import#

bash
npm install fintech-algorithms
ts
import { 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#

NameTypeNotes
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 baseline or current is not an array, or is empty — throws RangeError
  • When higher_score_higher_risk is not exactly true — throws RangeError
  • When baseline_observed_at or current_observed_at is not a string, or the baseline does not sort strictly before the current — throws RangeError
  • When band_edges is not an array of at least three values — throws RangeError
  • When band_edges does not run strictly increasing from 0 to 1 — throws RangeError
  • When a snapshot entry is not an object — throws TypeError
  • When a snapshot id is missing, empty, or repeats within that snapshot — throws RangeError
  • When a snapshot score is not a finite number — throws TypeError
  • When a snapshot score falls 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#

inputs
{
  "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#

Score Stability and Migration Matrix — article hero
Score Stability and Migration Matrix — decision boundaries
Score Stability and Migration Matrix — method selection
Score Stability and Migration Matrix — system map

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.

Read the article →

References#

The rest of the Classification and Score Validation family#