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.
- psi_inference-0.1.0/.github/workflows/ci.yml +20 -0
- psi_inference-0.1.0/.gitignore +8 -0
- psi_inference-0.1.0/CITATION.cff +36 -0
- psi_inference-0.1.0/LICENSE +21 -0
- psi_inference-0.1.0/PKG-INFO +186 -0
- psi_inference-0.1.0/README.md +157 -0
- psi_inference-0.1.0/pyproject.toml +52 -0
- psi_inference-0.1.0/src/psi_inference/__init__.py +75 -0
- psi_inference-0.1.0/src/psi_inference/bootstrap.py +89 -0
- psi_inference-0.1.0/src/psi_inference/effect_size.py +64 -0
- psi_inference-0.1.0/src/psi_inference/overlapping.py +55 -0
- psi_inference-0.1.0/src/psi_inference/prs.py +144 -0
- psi_inference-0.1.0/src/psi_inference/yurdakul.py +138 -0
- psi_inference-0.1.0/tests/test_bootstrap.py +51 -0
- psi_inference-0.1.0/tests/test_cross_check.py +57 -0
- psi_inference-0.1.0/tests/test_effect_size.py +59 -0
- psi_inference-0.1.0/tests/test_monte_carlo.py +82 -0
- psi_inference-0.1.0/tests/test_overlapping.py +51 -0
- psi_inference-0.1.0/tests/test_prs.py +86 -0
- psi_inference-0.1.0/tests/test_yurdakul.py +98 -0
|
@@ -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,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
|
+
[](https://pypi.org/project/psi-inference/)
|
|
33
|
+
[](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
|
+
[](https://pypi.org/project/psi-inference/)
|
|
4
|
+
[](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))
|