psi-inference 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ matrix:
12
+ python-version: ["3.11", "3.12", "3.13"]
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: ${{ matrix.python-version }}
18
+ - run: pip install -e ".[dev]"
19
+ - run: ruff check .
20
+ - run: pytest -q
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
@@ -0,0 +1,36 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it as below."
3
+ type: software
4
+ title: "psi-inference: critical values and p-values for population stability testing"
5
+ authors:
6
+ - family-names: Khobotov
7
+ given-names: Nikolai
8
+ orcid: "https://orcid.org/0009-0005-3612-830X"
9
+ email: nicholaask@gmail.com
10
+ version: 0.1.0
11
+ doi: 10.5281/zenodo.22342344
12
+ identifiers:
13
+ - type: doi
14
+ value: 10.5281/zenodo.22342343
15
+ description: "Concept DOI for all versions; resolves to the latest."
16
+ date-released: 2026-09-05
17
+ url: "https://github.com/primaryaesthetics/psi-inference"
18
+ repository-code: "https://github.com/primaryaesthetics/psi-inference"
19
+ license: MIT
20
+ keywords:
21
+ - population stability index
22
+ - PSI
23
+ - population resemblance statistic
24
+ - PRS
25
+ - credit risk
26
+ - model monitoring
27
+ - model validation
28
+ abstract: >-
29
+ Critical values and p-values for population stability testing in credit
30
+ risk monitoring, in pure numpy and scipy. Implements the Yurdakul-Naranjo
31
+ null distribution of the population stability index, the Population
32
+ Resemblance Statistic of Potgieter, Van Zyl, Schutte and Lombard, and the
33
+ effect-size and overlapping tests of Du Pisanie, Allison, Budde and
34
+ Visagie. Every formula is pinned by a test to a worked example in its
35
+ source paper, and a Monte Carlo gate checks the analytic critical values
36
+ against the simulated null.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nikolai Khobotov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,186 @@
1
+ Metadata-Version: 2.5
2
+ Name: psi-inference
3
+ Version: 0.1.0
4
+ Summary: Critical values and p-values for population stability testing: Yurdakul-Naranjo PSI, the Population Resemblance Statistic, and effect-size and overlapping tests
5
+ Project-URL: Homepage, https://github.com/primaryaesthetics/psi-inference
6
+ Project-URL: Repository, https://github.com/primaryaesthetics/psi-inference
7
+ Project-URL: Issues, https://github.com/primaryaesthetics/psi-inference/issues
8
+ Author-email: Nikolai Khobotov <nicholaask@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: PRS,PSI,credit risk,model monitoring,population stability index
12
+ Classifier: Intended Audience :: Financial and Insurance Industry
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: numpy>=1.24
22
+ Requires-Dist: scipy>=1.10
23
+ Provides-Extra: dev
24
+ Requires-Dist: feature-engine; extra == 'dev'
25
+ Requires-Dist: iyipada; extra == 'dev'
26
+ Requires-Dist: pytest>=8; extra == 'dev'
27
+ Requires-Dist: ruff>=0.6; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # psi-inference
31
+
32
+ [![PyPI](https://img.shields.io/pypi/v/psi-inference.svg)](https://pypi.org/project/psi-inference/)
33
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22342343.svg)](https://doi.org/10.5281/zenodo.22342343)
34
+
35
+ Critical values and p-values for population stability testing in credit risk
36
+ monitoring: pure numpy and scipy, no pandas dependency, Python 3.11+.
37
+
38
+ A model built on one population gets used on another one later, and the
39
+ question a monitoring report has to answer is whether the two still look
40
+ alike. The population stability index (PSI) is the standard tool for this,
41
+ and the standard reading of it is a pair of constants: below 0.10 is fine,
42
+ above 0.25 needs a rebuild. Those constants do not know the sample size or
43
+ the number of bins the PSI was computed on, so the same reading means
44
+ different things on a 500-row book and a 500,000-row one. This package
45
+ implements the statistics that do know it.
46
+
47
+ ## Example
48
+
49
+ ```python
50
+ import numpy as np
51
+ from psi_inference import psi_test
52
+
53
+ # Seven credit grades: the base sample and a later monitoring sample.
54
+ base = np.array([253, 302, 204, 134, 72, 26, 8]) # 999 accounts
55
+ current = np.array([177, 262, 285, 158, 88, 25, 6]) # 1001 accounts
56
+
57
+ result = psi_test(base, current, alpha=0.05)
58
+ print(result.statistic) # 0.0677
59
+ print(result.critical_value) # 0.0252
60
+ print(result.p_value) # 7.2e-06
61
+ print(result.reject) # True
62
+ ```
63
+
64
+ Read against the 0.10 rule of thumb, 0.068 says nothing has changed. This
65
+ sample has seven categories and about a thousand rows on each side. At 5%
66
+ significance the critical value here is 0.025. The observed PSI is more than
67
+ two and a half times that. The p-value says the same thing in one number: a
68
+ shift this large would happen by chance in roughly seven per hundred
69
+ thousand comparisons if the population had not moved at all. The rule of
70
+ thumb and the correctly sized test disagree here. On this sample size, the
71
+ test is the one with a stated error rate.
72
+
73
+ ## Critical values
74
+
75
+ At alpha = 0.05, from `psi_critical_value(n, m, bins, alpha=0.05)`:
76
+
77
+ | n = m | B = 10 | B = 20 |
78
+ | --------- | ------- | ------- |
79
+ | 1,000 | 0.0338 | 0.0603 |
80
+ | 10,000 | 0.0034 | 0.0060 |
81
+ | 100,000 | 0.0003 | 0.0006 |
82
+
83
+ The critical value falls by roughly an order of magnitude for each order of
84
+ magnitude of sample size, and rises with the number of bins. A PSI of 0.05
85
+ is a strong signal at 100,000 rows per side and unremarkable at 1,000. Fixed
86
+ thresholds cannot see either fact.
87
+
88
+ ## Why 0.10 and 0.25 are not thresholds
89
+
90
+ The 0.10 / 0.25 pair comes from Lewis (1994) as a rule of thumb, with no
91
+ stated error rate and no dependence on how much data the PSI was computed on.
92
+ Yurdakul and Naranjo (2020) show that under the hypothesis of no population
93
+ shift, PSI is approximately distributed as (1/n + 1/m) times a chi-square
94
+ random variable with B − 1 degrees of freedom. Here n and m are the two
95
+ sample sizes and B is the number of bins. That scale factor is exactly what
96
+ the fixed thresholds throw away. The practical consequence is two-sided: a
97
+ small monitoring sample crosses 0.10 routinely on pure sampling noise, and a
98
+ large one can move by an amount that would fail any honest test while never
99
+ approaching 0.25. Reporting a PSI against 0.10 or 0.25 is reporting a number
100
+ against constants that were never fit to this sample size, this bin count,
101
+ or any stated tolerance for being wrong.
102
+
103
+ ## What this package implements
104
+
105
+ - **PSI** (`psi_statistic`, `psi_critical_value`, `psi_pvalue`, `psi_test`):
106
+ Yurdakul, B. and Naranjo, J. (2020), "Statistical properties of the
107
+ population stability index", *Journal of Risk Model Validation* 14(4),
108
+ 89-100, section 3 (theorems 3.1-3.3) and section 4 (equation 4.1). Two
109
+ other Python packages compute the same asymptotic critical value, `iyipada`
110
+ and `feature-engine`'s `DropHighPSIFeatures(threshold="auto")`; this module
111
+ is checked against both in `tests/test_cross_check.py` and is included here
112
+ for completeness, not as a new result.
113
+ - **PRS** (`prs_statistic`, `prs_delta`, `prs_thresholds`, `prs_classify`):
114
+ the Population Resemblance Statistic of Potgieter, C.J., Van Zyl, C.,
115
+ Schutte, W.D. and Lombard, F., "The Population Resemblance Statistic: A
116
+ Chi-Square Measure of Fit for Banking" (arXiv:2307.11878). Compares one
117
+ sample against a fixed reference population rather than two samples
118
+ against each other, and gives sample-size-calibrated critical values from
119
+ the non-central chi-square distribution, plus a three-tier decision
120
+ (acceptable / partial / full discrepancy) instead of a single cutoff.
121
+ - **Effect-size test** (`effect_size_statistic`, `effect_size_critical_value`,
122
+ `effect_size_pvalue`) and **overlapping test** (`overlapping_statistic`,
123
+ `overlapping_critical_value`, `overlapping_pvalue`): Du Pisanie, J.,
124
+ Allison, J.S., Budde, C.J. and Visagie, I.J.H., "A critical review of
125
+ existing and new population stability testing procedures in credit risk
126
+ scoring" (arXiv:2303.01227), sections 3.6 and 3.7. The effect-size
127
+ statistic does not grow with sample size, which is the failure mode of the
128
+ classical goodness-of-fit tests on a bank-sized book. The overlapping
129
+ statistic reports the share of probability mass two distributions hold in
130
+ common, which needs no explanation of a p-value to a non-technical
131
+ audience.
132
+
133
+ None of these three had a Python implementation before this package.
134
+ `rpsi` on CRAN implements the Yurdakul-Naranjo critical value in R and
135
+ nothing beyond it.
136
+
137
+ ## Installing and running the tests
138
+
139
+ ```
140
+ pip install psi-inference
141
+ ```
142
+
143
+ To run the tests from a checkout:
144
+
145
+ ```
146
+ uv venv --python 3.12
147
+ uv pip install -e ".[dev]"
148
+ uv run pytest
149
+ ```
150
+
151
+ The test suite includes a Monte Carlo gate. It simulates the null directly
152
+ for several sample sizes and bin counts, including a small sample (500 rows)
153
+ and unequal sample sizes (50,000 against 2,000). It checks that the
154
+ empirical 95th percentile of the simulated statistic matches the analytic
155
+ critical value within simulation error for the PSI and for the PRS. A
156
+ formula recalled wrong from the paper produces a number, not an error. This
157
+ is the check that catches it.
158
+
159
+ ## Notes on the sources
160
+
161
+ Every formula in this package is pinned by a test to a worked example
162
+ printed in its source paper: the PSI to Yurdakul and Naranjo's seven-grade
163
+ example and to their critical-value tables for B = 10 and B = 20; the PRS to
164
+ the four (sample size, bin count) cases of the source paper's critical-value
165
+ table; the effect-size and overlapping statistics to the paper's example of
166
+ a two-category shift from 50/50 to 50.5/49.5. Each matches to the digits the
167
+ source prints, and the test names say which table.
168
+
169
+ The PSI null agrees everywhere it was checked: Yurdakul and Naranjo's own
170
+ equation, the `rpsi` R source, `iyipada` and `feature-engine` all reduce to
171
+ the same line, `chi2.ppf(1 - alpha, bins - 1) * (1/n + 1/m)`. `rpsi` also
172
+ offers a mode that treats the base distribution as a fixed population rather
173
+ than a random sample, which drops the `1/n` term. That mode is not
174
+ implemented here. The PRS covers the fixed-reference case with a null that
175
+ is built for it.
176
+
177
+ ## Citing
178
+
179
+ The package is archived on Zenodo. The concept DOI 10.5281/zenodo.22342343
180
+ resolves to the latest version; the DOI of version 0.1.0 is
181
+ 10.5281/zenodo.22342344. `CITATION.cff` in the repository carries the full
182
+ record.
183
+
184
+ ## Licence
185
+
186
+ MIT.
@@ -0,0 +1,157 @@
1
+ # psi-inference
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/psi-inference.svg)](https://pypi.org/project/psi-inference/)
4
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22342343.svg)](https://doi.org/10.5281/zenodo.22342343)
5
+
6
+ Critical values and p-values for population stability testing in credit risk
7
+ monitoring: pure numpy and scipy, no pandas dependency, Python 3.11+.
8
+
9
+ A model built on one population gets used on another one later, and the
10
+ question a monitoring report has to answer is whether the two still look
11
+ alike. The population stability index (PSI) is the standard tool for this,
12
+ and the standard reading of it is a pair of constants: below 0.10 is fine,
13
+ above 0.25 needs a rebuild. Those constants do not know the sample size or
14
+ the number of bins the PSI was computed on, so the same reading means
15
+ different things on a 500-row book and a 500,000-row one. This package
16
+ implements the statistics that do know it.
17
+
18
+ ## Example
19
+
20
+ ```python
21
+ import numpy as np
22
+ from psi_inference import psi_test
23
+
24
+ # Seven credit grades: the base sample and a later monitoring sample.
25
+ base = np.array([253, 302, 204, 134, 72, 26, 8]) # 999 accounts
26
+ current = np.array([177, 262, 285, 158, 88, 25, 6]) # 1001 accounts
27
+
28
+ result = psi_test(base, current, alpha=0.05)
29
+ print(result.statistic) # 0.0677
30
+ print(result.critical_value) # 0.0252
31
+ print(result.p_value) # 7.2e-06
32
+ print(result.reject) # True
33
+ ```
34
+
35
+ Read against the 0.10 rule of thumb, 0.068 says nothing has changed. This
36
+ sample has seven categories and about a thousand rows on each side. At 5%
37
+ significance the critical value here is 0.025. The observed PSI is more than
38
+ two and a half times that. The p-value says the same thing in one number: a
39
+ shift this large would happen by chance in roughly seven per hundred
40
+ thousand comparisons if the population had not moved at all. The rule of
41
+ thumb and the correctly sized test disagree here. On this sample size, the
42
+ test is the one with a stated error rate.
43
+
44
+ ## Critical values
45
+
46
+ At alpha = 0.05, from `psi_critical_value(n, m, bins, alpha=0.05)`:
47
+
48
+ | n = m | B = 10 | B = 20 |
49
+ | --------- | ------- | ------- |
50
+ | 1,000 | 0.0338 | 0.0603 |
51
+ | 10,000 | 0.0034 | 0.0060 |
52
+ | 100,000 | 0.0003 | 0.0006 |
53
+
54
+ The critical value falls by roughly an order of magnitude for each order of
55
+ magnitude of sample size, and rises with the number of bins. A PSI of 0.05
56
+ is a strong signal at 100,000 rows per side and unremarkable at 1,000. Fixed
57
+ thresholds cannot see either fact.
58
+
59
+ ## Why 0.10 and 0.25 are not thresholds
60
+
61
+ The 0.10 / 0.25 pair comes from Lewis (1994) as a rule of thumb, with no
62
+ stated error rate and no dependence on how much data the PSI was computed on.
63
+ Yurdakul and Naranjo (2020) show that under the hypothesis of no population
64
+ shift, PSI is approximately distributed as (1/n + 1/m) times a chi-square
65
+ random variable with B − 1 degrees of freedom. Here n and m are the two
66
+ sample sizes and B is the number of bins. That scale factor is exactly what
67
+ the fixed thresholds throw away. The practical consequence is two-sided: a
68
+ small monitoring sample crosses 0.10 routinely on pure sampling noise, and a
69
+ large one can move by an amount that would fail any honest test while never
70
+ approaching 0.25. Reporting a PSI against 0.10 or 0.25 is reporting a number
71
+ against constants that were never fit to this sample size, this bin count,
72
+ or any stated tolerance for being wrong.
73
+
74
+ ## What this package implements
75
+
76
+ - **PSI** (`psi_statistic`, `psi_critical_value`, `psi_pvalue`, `psi_test`):
77
+ Yurdakul, B. and Naranjo, J. (2020), "Statistical properties of the
78
+ population stability index", *Journal of Risk Model Validation* 14(4),
79
+ 89-100, section 3 (theorems 3.1-3.3) and section 4 (equation 4.1). Two
80
+ other Python packages compute the same asymptotic critical value, `iyipada`
81
+ and `feature-engine`'s `DropHighPSIFeatures(threshold="auto")`; this module
82
+ is checked against both in `tests/test_cross_check.py` and is included here
83
+ for completeness, not as a new result.
84
+ - **PRS** (`prs_statistic`, `prs_delta`, `prs_thresholds`, `prs_classify`):
85
+ the Population Resemblance Statistic of Potgieter, C.J., Van Zyl, C.,
86
+ Schutte, W.D. and Lombard, F., "The Population Resemblance Statistic: A
87
+ Chi-Square Measure of Fit for Banking" (arXiv:2307.11878). Compares one
88
+ sample against a fixed reference population rather than two samples
89
+ against each other, and gives sample-size-calibrated critical values from
90
+ the non-central chi-square distribution, plus a three-tier decision
91
+ (acceptable / partial / full discrepancy) instead of a single cutoff.
92
+ - **Effect-size test** (`effect_size_statistic`, `effect_size_critical_value`,
93
+ `effect_size_pvalue`) and **overlapping test** (`overlapping_statistic`,
94
+ `overlapping_critical_value`, `overlapping_pvalue`): Du Pisanie, J.,
95
+ Allison, J.S., Budde, C.J. and Visagie, I.J.H., "A critical review of
96
+ existing and new population stability testing procedures in credit risk
97
+ scoring" (arXiv:2303.01227), sections 3.6 and 3.7. The effect-size
98
+ statistic does not grow with sample size, which is the failure mode of the
99
+ classical goodness-of-fit tests on a bank-sized book. The overlapping
100
+ statistic reports the share of probability mass two distributions hold in
101
+ common, which needs no explanation of a p-value to a non-technical
102
+ audience.
103
+
104
+ None of these three had a Python implementation before this package.
105
+ `rpsi` on CRAN implements the Yurdakul-Naranjo critical value in R and
106
+ nothing beyond it.
107
+
108
+ ## Installing and running the tests
109
+
110
+ ```
111
+ pip install psi-inference
112
+ ```
113
+
114
+ To run the tests from a checkout:
115
+
116
+ ```
117
+ uv venv --python 3.12
118
+ uv pip install -e ".[dev]"
119
+ uv run pytest
120
+ ```
121
+
122
+ The test suite includes a Monte Carlo gate. It simulates the null directly
123
+ for several sample sizes and bin counts, including a small sample (500 rows)
124
+ and unequal sample sizes (50,000 against 2,000). It checks that the
125
+ empirical 95th percentile of the simulated statistic matches the analytic
126
+ critical value within simulation error for the PSI and for the PRS. A
127
+ formula recalled wrong from the paper produces a number, not an error. This
128
+ is the check that catches it.
129
+
130
+ ## Notes on the sources
131
+
132
+ Every formula in this package is pinned by a test to a worked example
133
+ printed in its source paper: the PSI to Yurdakul and Naranjo's seven-grade
134
+ example and to their critical-value tables for B = 10 and B = 20; the PRS to
135
+ the four (sample size, bin count) cases of the source paper's critical-value
136
+ table; the effect-size and overlapping statistics to the paper's example of
137
+ a two-category shift from 50/50 to 50.5/49.5. Each matches to the digits the
138
+ source prints, and the test names say which table.
139
+
140
+ The PSI null agrees everywhere it was checked: Yurdakul and Naranjo's own
141
+ equation, the `rpsi` R source, `iyipada` and `feature-engine` all reduce to
142
+ the same line, `chi2.ppf(1 - alpha, bins - 1) * (1/n + 1/m)`. `rpsi` also
143
+ offers a mode that treats the base distribution as a fixed population rather
144
+ than a random sample, which drops the `1/n` term. That mode is not
145
+ implemented here. The PRS covers the fixed-reference case with a null that
146
+ is built for it.
147
+
148
+ ## Citing
149
+
150
+ The package is archived on Zenodo. The concept DOI 10.5281/zenodo.22342343
151
+ resolves to the latest version; the DOI of version 0.1.0 is
152
+ 10.5281/zenodo.22342344. `CITATION.cff` in the repository carries the full
153
+ record.
154
+
155
+ ## Licence
156
+
157
+ MIT.
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "psi-inference"
7
+ version = "0.1.0"
8
+ description = "Critical values and p-values for population stability testing: Yurdakul-Naranjo PSI, the Population Resemblance Statistic, and effect-size and overlapping tests"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Nikolai Khobotov", email = "nicholaask@gmail.com" }]
14
+ keywords = [
15
+ "population stability index",
16
+ "credit risk",
17
+ "model monitoring",
18
+ "PSI",
19
+ "PRS",
20
+ ]
21
+ classifiers = [
22
+ "Intended Audience :: Financial and Insurance Industry",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Operating System :: OS Independent",
29
+ "Topic :: Scientific/Engineering :: Mathematics",
30
+ ]
31
+ dependencies = [
32
+ "numpy>=1.24",
33
+ "scipy>=1.10",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/primaryaesthetics/psi-inference"
38
+ Repository = "https://github.com/primaryaesthetics/psi-inference"
39
+ Issues = "https://github.com/primaryaesthetics/psi-inference/issues"
40
+
41
+ [project.optional-dependencies]
42
+ dev = ["pytest>=8", "ruff>=0.6", "iyipada", "feature-engine"]
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/psi_inference"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ pythonpath = ["src"]
50
+
51
+ [tool.ruff]
52
+ line-length = 100
@@ -0,0 +1,75 @@
1
+ """Critical values and p-values for population stability testing.
2
+
3
+ Four statistics used to test whether the population a model scores today
4
+ still resembles the one it was built on, each with the inference the
5
+ rule-of-thumb thresholds of 0.10 and 0.25 never had:
6
+
7
+ - `psi_statistic`, `psi_critical_value`, `psi_pvalue`, `psi_test` --
8
+ the population stability index, tested against Yurdakul and Naranjo's
9
+ (2020) asymptotic chi-square null.
10
+ - `prs_statistic`, `prs_delta`, `prs_thresholds`, `prs_classify` --
11
+ the Population Resemblance Statistic of Potgieter, Van Zyl, Schutte and
12
+ Lombard (arXiv:2307.11878), a fixed-reference alternative with
13
+ sample-size-calibrated critical values from the non-central chi-square.
14
+ - `effect_size_statistic`, `effect_size_critical_value`, `effect_size_pvalue` --
15
+ the category-level effect-size test of Du Pisanie, Allison, Budde and
16
+ Visagie (arXiv:2303.01227), which does not grow with sample size.
17
+ - `overlapping_statistic`, `overlapping_critical_value`, `overlapping_pvalue` --
18
+ the overlapping statistic from the same paper, the share of probability
19
+ mass two distributions hold in common.
20
+
21
+ See the README for which statistic to reach for and why 0.10/0.25 are not
22
+ thresholds.
23
+ """
24
+
25
+ from .effect_size import (
26
+ effect_size_critical_value,
27
+ effect_size_per_category,
28
+ effect_size_pvalue,
29
+ effect_size_statistic,
30
+ )
31
+ from .overlapping import (
32
+ overlapping_critical_value,
33
+ overlapping_pvalue,
34
+ overlapping_statistic,
35
+ )
36
+ from .prs import (
37
+ PRSThresholds,
38
+ prs_classify,
39
+ prs_delta,
40
+ prs_noncentrality_sup,
41
+ prs_statistic,
42
+ prs_thresholds,
43
+ )
44
+ from .yurdakul import (
45
+ PSITestResult,
46
+ psi_critical_value,
47
+ psi_pvalue,
48
+ psi_scale,
49
+ psi_statistic,
50
+ psi_test,
51
+ )
52
+
53
+ __version__ = "0.1.0"
54
+
55
+ __all__ = [
56
+ "PRSThresholds",
57
+ "PSITestResult",
58
+ "effect_size_critical_value",
59
+ "effect_size_per_category",
60
+ "effect_size_pvalue",
61
+ "effect_size_statistic",
62
+ "overlapping_critical_value",
63
+ "overlapping_pvalue",
64
+ "overlapping_statistic",
65
+ "prs_classify",
66
+ "prs_delta",
67
+ "prs_noncentrality_sup",
68
+ "prs_statistic",
69
+ "prs_thresholds",
70
+ "psi_critical_value",
71
+ "psi_pvalue",
72
+ "psi_scale",
73
+ "psi_statistic",
74
+ "psi_test",
75
+ ]
@@ -0,0 +1,89 @@
1
+ """The parametric-bootstrap null shared by the effect-size and overlapping tests.
2
+
3
+ Du Pisanie, J., Allison, J.S., Budde, C.J. and Visagie, I.J.H., "A critical
4
+ review of existing and new population stability testing procedures in credit
5
+ risk scoring" (arXiv:2303.01227), section 3.1.
6
+
7
+ Unlike the PSI and the PRS, the effect-size statistic and the overlapping
8
+ statistic have no closed-form asymptotic null in the paper. The paper's own
9
+ answer, stated as a five-step algorithm in section 3.1, is to simulate it:
10
+ since the reference distribution q is treated as fixed and known, a
11
+ realisation of the current sample can be drawn directly from
12
+ Multinomial(m, q), with no need for the observed sample at all.
13
+
14
+ 1. Draw a realisation M* from Multinomial(m, q); let P* = M*/m.
15
+ 2. Compute Delta* = Delta(q, P*).
16
+ 3. Repeat steps 1-2 b times to get Delta*_1, ..., Delta*_b.
17
+ 4. Sort them: Delta*_(1) <= ... <= Delta*_(b).
18
+ 5. The critical value at level alpha is Delta*_(beta), beta = floor(b*(1-alpha)).
19
+
20
+ and the p-value of an observed value is the share of the b simulated values
21
+ at least as large as it. The paper notes this same algorithm covers every
22
+ statistic considered except the overlapping measure, for which the required
23
+ substitution is Delta(q, P) = 1 - eta(q, P) -- handled by `overlapping.py`,
24
+ not by this module, which only ever sees an already-increasing-in-discrepancy
25
+ statistic.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from collections.abc import Callable
31
+
32
+ import numpy as np
33
+
34
+
35
+ def bootstrap_null(
36
+ reference: np.ndarray,
37
+ m: int,
38
+ statistic: Callable[[np.ndarray, np.ndarray], float],
39
+ *,
40
+ draws: int = 10_000,
41
+ rng: np.random.Generator | int | None = None,
42
+ ) -> np.ndarray:
43
+ """b draws of `statistic(reference, P*)` under Multinomial(m, reference).
44
+
45
+ `statistic` must take (reference_distribution, sample_proportions) and
46
+ return a single float that increases with discrepancy from the reference.
47
+ """
48
+ reference = np.asarray(reference, dtype=np.float64)
49
+ if m < 1:
50
+ raise ValueError("m must be at least 1")
51
+ if draws < 1:
52
+ raise ValueError("draws must be at least 1")
53
+ if isinstance(rng, (int, np.integer)) or rng is None:
54
+ rng = np.random.default_rng(rng)
55
+ counts = rng.multinomial(m, reference, size=draws)
56
+ p_star = counts / m
57
+ return np.array([statistic(reference, row) for row in p_star])
58
+
59
+
60
+ def bootstrap_critical_value(
61
+ reference: np.ndarray,
62
+ m: int,
63
+ statistic: Callable[[np.ndarray, np.ndarray], float],
64
+ alpha: float = 0.05,
65
+ *,
66
+ draws: int = 10_000,
67
+ rng: np.random.Generator | int | None = None,
68
+ ) -> float:
69
+ """The order statistic at floor(b*(1-alpha)) of the simulated null (step 5)."""
70
+ if not 0 < alpha < 1:
71
+ raise ValueError("alpha must be in (0, 1)")
72
+ null = np.sort(bootstrap_null(reference, m, statistic, draws=draws, rng=rng))
73
+ b = null.shape[0]
74
+ index = min(int(np.floor(b * (1.0 - alpha))), b - 1)
75
+ return float(null[index])
76
+
77
+
78
+ def bootstrap_pvalue(
79
+ observed: float,
80
+ reference: np.ndarray,
81
+ m: int,
82
+ statistic: Callable[[np.ndarray, np.ndarray], float],
83
+ *,
84
+ draws: int = 10_000,
85
+ rng: np.random.Generator | int | None = None,
86
+ ) -> float:
87
+ """The share of the simulated null at least as large as the observed value."""
88
+ null = bootstrap_null(reference, m, statistic, draws=draws, rng=rng)
89
+ return float(np.mean(null >= observed))