SuperModelingFactory 0.2.0__py3-none-any.whl
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.
- ExcelMaster/ExcelFormatTool.py +487 -0
- ExcelMaster/ExcelMaster.py +917 -0
- ExcelMaster/Template.py +525 -0
- ExcelMaster/Utility.py +233 -0
- ExcelMaster/__init__.py +3 -0
- Modeling_Tool/Core/Binning_Tool.py +1608 -0
- Modeling_Tool/Core/Binning_Tool.pyi +48 -0
- Modeling_Tool/Core/Check_DuckDB_Compatibility.py +835 -0
- Modeling_Tool/Core/Json_Data_Converter.py +621 -0
- Modeling_Tool/Core/Model_Registry_Tool.py +199 -0
- Modeling_Tool/Core/ODPS_Tool.py +284 -0
- Modeling_Tool/Core/Slope_Tool.py +356 -0
- Modeling_Tool/Core/Slope_Tool.pyi +31 -0
- Modeling_Tool/Core/XOR_Encryptor.py +207 -0
- Modeling_Tool/Core/XOR_Encryptor.pyi +26 -0
- Modeling_Tool/Core/__init__.py +99 -0
- Modeling_Tool/Core/kDataFrame.py +228 -0
- Modeling_Tool/Core/kDataFrame.pyi +43 -0
- Modeling_Tool/Core/sample_weight_utils.py +77 -0
- Modeling_Tool/Core/utils.py +2672 -0
- Modeling_Tool/Eval/Evaluation_Tool.py +1452 -0
- Modeling_Tool/Eval/Evaluation_Tool.pyi +47 -0
- Modeling_Tool/Eval/Model_Eval_Tool.py +2548 -0
- Modeling_Tool/Eval/Model_Eval_Tool.pyi +37 -0
- Modeling_Tool/Eval/__init__.py +54 -0
- Modeling_Tool/Eval/evaluate_model.py +2008 -0
- Modeling_Tool/Eval/evaluate_model.pyi +50 -0
- Modeling_Tool/Eval/weighted_eval_utils.py +326 -0
- Modeling_Tool/Explainability/Coalition_Structure.py +305 -0
- Modeling_Tool/Explainability/Model_Explainer.py +743 -0
- Modeling_Tool/Explainability/__init__.py +24 -0
- Modeling_Tool/Feature/Distribution_Tool.py +509 -0
- Modeling_Tool/Feature/Distribution_Tool.pyi +42 -0
- Modeling_Tool/Feature/Feature_Insights.py +762 -0
- Modeling_Tool/Feature/Feature_Insights.pyi +33 -0
- Modeling_Tool/Feature/PSI_Tool.py +1195 -0
- Modeling_Tool/Feature/PSI_Tool.pyi +29 -0
- Modeling_Tool/Feature/WOE_Engine_Feature_Patch.py +355 -0
- Modeling_Tool/Feature/__init__.py +40 -0
- Modeling_Tool/Model/Backward_Tool.py +778 -0
- Modeling_Tool/Model/Backward_Tool.pyi +45 -0
- Modeling_Tool/Model/GBM_Search_Tool.py +251 -0
- Modeling_Tool/Model/GBM_Tool.py +1610 -0
- Modeling_Tool/Model/GBM_Tool.pyi +90 -0
- Modeling_Tool/Model/LRM_Tool.py +1198 -0
- Modeling_Tool/Model/LRM_Tool.pyi +47 -0
- Modeling_Tool/Model/__init__.py +61 -0
- Modeling_Tool/Sample/Distribution_Adaptation.py +131 -0
- Modeling_Tool/Sample/Distribution_Adaptation.pyi +30 -0
- Modeling_Tool/Sample/Reject_Infer.py +413 -0
- Modeling_Tool/Sample/Reject_Infer.pyi +43 -0
- Modeling_Tool/Sample/Sample_Split.py +520 -0
- Modeling_Tool/Sample/Sample_Split.pyi +43 -0
- Modeling_Tool/Sample/__init__.py +31 -0
- Modeling_Tool/UAT/UAT_Consistency_Checker.py +1180 -0
- Modeling_Tool/UAT/__init__.py +19 -0
- Modeling_Tool/WOE/WOE_Adapter.py +204 -0
- Modeling_Tool/WOE/WOE_Adapter.pyi +21 -0
- Modeling_Tool/WOE/WOE_Master.py +491 -0
- Modeling_Tool/WOE/WOE_Master.pyi +40 -0
- Modeling_Tool/WOE/WOE_Monotone_Binner.py +3324 -0
- Modeling_Tool/WOE/WOE_Monotone_Binner.pyi +71 -0
- Modeling_Tool/WOE/WOE_Plot_Tool.py +919 -0
- Modeling_Tool/WOE/WOE_Plot_Tool.pyi +46 -0
- Modeling_Tool/WOE/WOE_Report_Builder.py +214 -0
- Modeling_Tool/WOE/WOE_Report_Builder.pyi +24 -0
- Modeling_Tool/WOE/WOE_Tool.py +1094 -0
- Modeling_Tool/WOE/WOE_Tool.pyi +41 -0
- Modeling_Tool/WOE/__init__.py +86 -0
- Modeling_Tool/WOE/plot_woe_tool.py +290 -0
- Modeling_Tool/WOE/plot_woe_tool.pyi +25 -0
- Modeling_Tool/__init__.py +176 -0
- Report/Report_Tool.py +380 -0
- Report/__init__.py +3 -0
- supermodelingfactory-0.2.0.dist-info/METADATA +268 -0
- supermodelingfactory-0.2.0.dist-info/RECORD +79 -0
- supermodelingfactory-0.2.0.dist-info/WHEEL +5 -0
- supermodelingfactory-0.2.0.dist-info/licenses/LICENSE +102 -0
- supermodelingfactory-0.2.0.dist-info/top_level.txt +3 -0
|
@@ -0,0 +1,1195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PSI (Population Stability Index) Calculator Module
|
|
3
|
+
|
|
4
|
+
This module provides functions and classes for calculating Population Stability Index (PSI)
|
|
5
|
+
to measure the distribution drift between expected and actual datasets.
|
|
6
|
+
|
|
7
|
+
Author: Matrix Agent
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from typing import Union, List, Dict, Optional, Tuple, Callable, Any
|
|
13
|
+
from tqdm import tqdm
|
|
14
|
+
from Modeling_Tool.Core.Binning_Tool import quick_binning
|
|
15
|
+
|
|
16
|
+
# ============================================================================
|
|
17
|
+
# Classes
|
|
18
|
+
# ============================================================================
|
|
19
|
+
|
|
20
|
+
class PSICalculator:
|
|
21
|
+
"""
|
|
22
|
+
A class for calculating Population Stability Index (PSI) with configurable parameters.
|
|
23
|
+
|
|
24
|
+
This class encapsulates common PSI calculation parameters and provides methods
|
|
25
|
+
for various PSI calculations including single variable, grouped, and multi-variable
|
|
26
|
+
comparisons between datasets.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
buckets : int, optional
|
|
31
|
+
Number of bins for binning. Default is 10.
|
|
32
|
+
equal_freq : bool, optional
|
|
33
|
+
Whether to use equal frequency binning. Default is True.
|
|
34
|
+
min_bin_prop : float, optional
|
|
35
|
+
Minimum proportion for each bin. Default is 0.05.
|
|
36
|
+
content : float, optional
|
|
37
|
+
Small value to avoid division by zero. Default is 1e-6.
|
|
38
|
+
precision : int, optional
|
|
39
|
+
Decimal precision for results. Default is 5.
|
|
40
|
+
|
|
41
|
+
Examples
|
|
42
|
+
--------
|
|
43
|
+
>>> calculator = PSICalculator(buckets=10, equal_freq=True)
|
|
44
|
+
>>> psi = calculator.calculate(expected_df, actual_df, 'score')
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
buckets: int = 10,
|
|
50
|
+
equal_freq: bool = True,
|
|
51
|
+
min_bin_prop: float = 0.05,
|
|
52
|
+
content: float = 1e-6,
|
|
53
|
+
precision: int = 5
|
|
54
|
+
):
|
|
55
|
+
"""
|
|
56
|
+
Initialize PSICalculator with configuration parameters.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
buckets : int, optional
|
|
61
|
+
Number of bins for binning. Default is 10.
|
|
62
|
+
equal_freq : bool, optional
|
|
63
|
+
Use equal frequency binning if True. Default is True.
|
|
64
|
+
min_bin_prop : float, optional
|
|
65
|
+
Minimum proportion for each bin. Default is 0.05.
|
|
66
|
+
content : float, optional
|
|
67
|
+
Small value to prevent division by zero. Default is 1e-6.
|
|
68
|
+
precision : int, optional
|
|
69
|
+
Decimal precision for rounding. Default is 5.
|
|
70
|
+
"""
|
|
71
|
+
self.buckets = buckets
|
|
72
|
+
self.equal_freq = equal_freq
|
|
73
|
+
self.min_bin_prop = min_bin_prop
|
|
74
|
+
self.content = content
|
|
75
|
+
self.precision = precision
|
|
76
|
+
|
|
77
|
+
# def _calculate_single_psi(
|
|
78
|
+
# self,
|
|
79
|
+
# expected_series: pd.Series,
|
|
80
|
+
# actual_series: pd.Series,
|
|
81
|
+
# return_details: bool = False
|
|
82
|
+
# ) -> Union[float, Tuple[float, pd.DataFrame]]:
|
|
83
|
+
# """
|
|
84
|
+
# Calculate PSI for a single variable.
|
|
85
|
+
|
|
86
|
+
# This method performs binning on both expected and actual series,
|
|
87
|
+
# then calculates the PSI value based on the distribution difference.
|
|
88
|
+
|
|
89
|
+
# Parameters
|
|
90
|
+
# ----------
|
|
91
|
+
# expected_series : pandas.Series
|
|
92
|
+
# Expected/baseline data series.
|
|
93
|
+
# actual_series : pandas.Series
|
|
94
|
+
# Actual/comparison data series.
|
|
95
|
+
# return_details : bool, optional
|
|
96
|
+
# Whether to return detailed bin information. Default is False.
|
|
97
|
+
|
|
98
|
+
# Returns
|
|
99
|
+
# -------
|
|
100
|
+
# float or tuple
|
|
101
|
+
# If return_details is False: Returns total PSI value.
|
|
102
|
+
# If return_details is True: Returns tuple of (PSI value, details DataFrame).
|
|
103
|
+
# """
|
|
104
|
+
# # Drop NA
|
|
105
|
+
# expected_clean = expected_series.dropna()
|
|
106
|
+
# actual_clean = actual_series.dropna()
|
|
107
|
+
|
|
108
|
+
# # Bin the expected data to get breakpoints
|
|
109
|
+
# expected_bins, breakpoints = quick_binning(
|
|
110
|
+
# pd.DataFrame(expected_clean),
|
|
111
|
+
# expected_clean.name,
|
|
112
|
+
# labels=None,
|
|
113
|
+
# nbins=self.buckets,
|
|
114
|
+
# precision=self.precision,
|
|
115
|
+
# equal_freq=self.equal_freq,
|
|
116
|
+
# right=True,
|
|
117
|
+
# include_lowest=False,
|
|
118
|
+
# min_bin_prop=self.min_bin_prop,
|
|
119
|
+
# tree_binning=False,
|
|
120
|
+
# target=None,
|
|
121
|
+
# random_state=42
|
|
122
|
+
# )
|
|
123
|
+
|
|
124
|
+
# # Bin the actual data using the same breakpoints
|
|
125
|
+
# actual_bins, _ = quick_binning(
|
|
126
|
+
# pd.DataFrame(actual_clean),
|
|
127
|
+
# actual_clean.name,
|
|
128
|
+
# labels=None,
|
|
129
|
+
# nbins=list(breakpoints),
|
|
130
|
+
# precision=self.precision,
|
|
131
|
+
# equal_freq=self.equal_freq,
|
|
132
|
+
# right=True,
|
|
133
|
+
# include_lowest=False,
|
|
134
|
+
# min_bin_prop=self.min_bin_prop,
|
|
135
|
+
# tree_binning=False,
|
|
136
|
+
# target=None,
|
|
137
|
+
# random_state=42
|
|
138
|
+
# )
|
|
139
|
+
|
|
140
|
+
# # Get bin proportions
|
|
141
|
+
# expected_percents = expected_bins.value_counts(normalize=True, sort=False)
|
|
142
|
+
# actual_percents = actual_bins.value_counts(normalize=True, sort=False)
|
|
143
|
+
|
|
144
|
+
# # Ensure both series have the same bin indices
|
|
145
|
+
# all_bins = expected_percents.index.union(actual_percents.index)
|
|
146
|
+
# expected_percents = expected_percents.reindex(all_bins, fill_value=self.content)
|
|
147
|
+
# actual_percents = actual_percents.reindex(all_bins, fill_value=self.content)
|
|
148
|
+
|
|
149
|
+
# # Clip to avoid division by zero
|
|
150
|
+
# expected_percents = expected_percents.clip(lower=self.content)
|
|
151
|
+
# actual_percents = actual_percents.clip(lower=self.content)
|
|
152
|
+
|
|
153
|
+
# # Calculate PSI
|
|
154
|
+
# psi_values = (actual_percents - expected_percents) * np.log(actual_percents / expected_percents)
|
|
155
|
+
# psi_total = psi_values.sum()
|
|
156
|
+
|
|
157
|
+
# if return_details:
|
|
158
|
+
# details = pd.DataFrame({
|
|
159
|
+
# 'expected_percent': expected_percents,
|
|
160
|
+
# 'actual_percent': actual_percents,
|
|
161
|
+
# 'psi_component': psi_values
|
|
162
|
+
# })
|
|
163
|
+
# return psi_total, details
|
|
164
|
+
# else:
|
|
165
|
+
# return psi_total
|
|
166
|
+
|
|
167
|
+
# def calculate_psi(
|
|
168
|
+
# self,
|
|
169
|
+
# expected: Union[pd.DataFrame, pd.Series],
|
|
170
|
+
# actual: Union[pd.DataFrame, pd.Series],
|
|
171
|
+
# target_col: str,
|
|
172
|
+
# group_by: Optional[Union[str, List[str]]] = None,
|
|
173
|
+
# return_details: bool = False
|
|
174
|
+
# ) -> Union[float, pd.DataFrame, Tuple[Dict, Dict]]:
|
|
175
|
+
# """
|
|
176
|
+
# Calculate PSI for a variable, optionally by groups.
|
|
177
|
+
|
|
178
|
+
# Parameters
|
|
179
|
+
# ----------
|
|
180
|
+
# expected : pandas.DataFrame or pandas.Series
|
|
181
|
+
# Expected/baseline data.
|
|
182
|
+
# actual : pandas.DataFrame or pandas.Series
|
|
183
|
+
# Actual/comparison data.
|
|
184
|
+
# target_col : str
|
|
185
|
+
# Column name to calculate PSI for.
|
|
186
|
+
# group_by : str or list, optional
|
|
187
|
+
# Column(s) to group by. Default is None (no grouping).
|
|
188
|
+
# return_details : bool, optional
|
|
189
|
+
# Whether to return detailed bin information. Default is False.
|
|
190
|
+
|
|
191
|
+
# Returns
|
|
192
|
+
# -------
|
|
193
|
+
# float, pandas.DataFrame, or tuple
|
|
194
|
+
# PSI value(s) and optionally details.
|
|
195
|
+
# """
|
|
196
|
+
# if group_by is not None:
|
|
197
|
+
# if isinstance(group_by, str):
|
|
198
|
+
# group_by = [group_by]
|
|
199
|
+
|
|
200
|
+
# expected_subset = expected[[target_col] + group_by].copy()
|
|
201
|
+
# actual_subset = actual[[target_col] + group_by].copy()
|
|
202
|
+
|
|
203
|
+
# expected_subset = expected_subset.copy()
|
|
204
|
+
# actual_subset = actual_subset.copy()
|
|
205
|
+
|
|
206
|
+
# expected_subset['_dataset'] = 'expected'
|
|
207
|
+
# actual_subset['_dataset'] = 'actual'
|
|
208
|
+
|
|
209
|
+
# combined = pd.concat([expected_subset, actual_subset], ignore_index=True)
|
|
210
|
+
|
|
211
|
+
# results = {}
|
|
212
|
+
# details_dict = {}
|
|
213
|
+
|
|
214
|
+
# for group, group_data in combined.groupby(group_by):
|
|
215
|
+
# expected_group = group_data[group_data['_dataset'] == 'expected'].drop('_dataset', axis=1)
|
|
216
|
+
# actual_group = group_data[group_data['_dataset'] == 'actual'].drop('_dataset', axis=1)
|
|
217
|
+
|
|
218
|
+
# if expected_group.shape[0] == 0 or actual_group.shape[0] == 0:
|
|
219
|
+
# results[group] = 999999
|
|
220
|
+
# continue
|
|
221
|
+
|
|
222
|
+
# if return_details:
|
|
223
|
+
# psi_value, detail = self._calculate_single_psi(
|
|
224
|
+
# expected_group[target_col],
|
|
225
|
+
# actual_group[target_col],
|
|
226
|
+
# return_details=True
|
|
227
|
+
# )
|
|
228
|
+
# results[group] = psi_value
|
|
229
|
+
# details_dict[group] = detail
|
|
230
|
+
# else:
|
|
231
|
+
# results[group] = self._calculate_single_psi(
|
|
232
|
+
# expected_group[target_col],
|
|
233
|
+
# actual_group[target_col]
|
|
234
|
+
# )
|
|
235
|
+
|
|
236
|
+
# if return_details:
|
|
237
|
+
# return results, details_dict
|
|
238
|
+
# else:
|
|
239
|
+
# return pd.DataFrame(results, index=['psi']).T
|
|
240
|
+
|
|
241
|
+
# else:
|
|
242
|
+
# if return_details:
|
|
243
|
+
# return self._calculate_single_psi(
|
|
244
|
+
# expected[target_col],
|
|
245
|
+
# actual[target_col],
|
|
246
|
+
# return_details=True
|
|
247
|
+
# )
|
|
248
|
+
# else:
|
|
249
|
+
# return self._calculate_single_psi(
|
|
250
|
+
# expected[target_col],
|
|
251
|
+
# actual[target_col]
|
|
252
|
+
# )
|
|
253
|
+
|
|
254
|
+
# def calculate_within_psi(
|
|
255
|
+
# self,
|
|
256
|
+
# data: pd.DataFrame,
|
|
257
|
+
# grp_name: str,
|
|
258
|
+
# target_col: str,
|
|
259
|
+
# benchmark: Optional[Any] = None,
|
|
260
|
+
# return_details: bool = False,
|
|
261
|
+
# benchmark_display_name: Optional[str] = None
|
|
262
|
+
# ) -> Union[pd.DataFrame, Dict]:
|
|
263
|
+
# """
|
|
264
|
+
# Calculate PSI values within a single dataset, comparing groups to a benchmark.
|
|
265
|
+
|
|
266
|
+
# Parameters
|
|
267
|
+
# ----------
|
|
268
|
+
# data : pandas.DataFrame
|
|
269
|
+
# Input dataset containing all groups.
|
|
270
|
+
# grp_name : str
|
|
271
|
+
# Column name for grouping.
|
|
272
|
+
# target_col : str
|
|
273
|
+
# Column name to calculate PSI for.
|
|
274
|
+
# benchmark : str or callable, optional
|
|
275
|
+
# Benchmark group value or filter function. If None, uses first group.
|
|
276
|
+
# return_details : bool, optional
|
|
277
|
+
# Whether to return detailed bin information. Default is False.
|
|
278
|
+
# benchmark_display_name : str, optional
|
|
279
|
+
# Custom name for benchmark in results.
|
|
280
|
+
|
|
281
|
+
# Returns
|
|
282
|
+
# -------
|
|
283
|
+
# pandas.DataFrame or dict
|
|
284
|
+
# PSI results by group, or dict with 'psi' and 'details' keys.
|
|
285
|
+
# """
|
|
286
|
+
# if callable(benchmark):
|
|
287
|
+
# benchmark_data = data[benchmark(data)]
|
|
288
|
+
# else:
|
|
289
|
+
# benchmark_data = data[data[grp_name] == benchmark] if benchmark is not None else data
|
|
290
|
+
|
|
291
|
+
# obs_values = [x for x in data[grp_name].unique().tolist() if x != benchmark]
|
|
292
|
+
|
|
293
|
+
# res_dict = {benchmark_display_name: 0} if benchmark_display_name is not None else {}
|
|
294
|
+
# detail_dict = {}
|
|
295
|
+
|
|
296
|
+
# for obs_value in obs_values:
|
|
297
|
+
# obs_data = data[data[grp_name] == obs_value]
|
|
298
|
+
|
|
299
|
+
# if return_details:
|
|
300
|
+
# psi, details = self.calculate_psi(
|
|
301
|
+
# benchmark_data,
|
|
302
|
+
# obs_data,
|
|
303
|
+
# target_col=target_col,
|
|
304
|
+
# return_details=True
|
|
305
|
+
# )
|
|
306
|
+
# res_dict[obs_value] = psi
|
|
307
|
+
# detail_dict[obs_value] = details
|
|
308
|
+
# else:
|
|
309
|
+
# psi = self.calculate_psi(
|
|
310
|
+
# benchmark_data,
|
|
311
|
+
# obs_data,
|
|
312
|
+
# target_col=target_col
|
|
313
|
+
# )
|
|
314
|
+
# res_dict[obs_value] = psi
|
|
315
|
+
|
|
316
|
+
# fnl_res = pd.DataFrame(res_dict, index=['psi']).T\
|
|
317
|
+
# .reset_index(drop=False)\
|
|
318
|
+
# .rename(columns={"index": grp_name})
|
|
319
|
+
|
|
320
|
+
# if return_details:
|
|
321
|
+
# return {"psi": fnl_res, "details": detail_dict}
|
|
322
|
+
# else:
|
|
323
|
+
# return fnl_res
|
|
324
|
+
|
|
325
|
+
# def calculate_psi_within_dataset(
|
|
326
|
+
# self,
|
|
327
|
+
# data: pd.DataFrame,
|
|
328
|
+
# grp_name: str,
|
|
329
|
+
# varlist: List[str],
|
|
330
|
+
# benchmark: Optional[Any] = None
|
|
331
|
+
# ) -> pd.DataFrame:
|
|
332
|
+
# """
|
|
333
|
+
# Calculate PSI for multiple variables within a dataset.
|
|
334
|
+
|
|
335
|
+
# Parameters
|
|
336
|
+
# ----------
|
|
337
|
+
# data : pandas.DataFrame
|
|
338
|
+
# Input dataset.
|
|
339
|
+
# grp_name : str
|
|
340
|
+
# Column name for grouping.
|
|
341
|
+
# varlist : list
|
|
342
|
+
# List of variable names to calculate PSI for.
|
|
343
|
+
# benchmark : str or callable, optional
|
|
344
|
+
# Benchmark group value or filter function.
|
|
345
|
+
|
|
346
|
+
# Returns
|
|
347
|
+
# -------
|
|
348
|
+
# pandas.DataFrame
|
|
349
|
+
# Combined PSI results for all variables.
|
|
350
|
+
# """
|
|
351
|
+
# fnl_psi_res = []
|
|
352
|
+
# for var in tqdm(varlist):
|
|
353
|
+
# single_psi = self.calculate_within_psi(
|
|
354
|
+
# data=data,
|
|
355
|
+
# grp_name=grp_name,
|
|
356
|
+
# benchmark=benchmark,
|
|
357
|
+
# target_col=var
|
|
358
|
+
# ).sort_values([grp_name]).reset_index(drop=True)
|
|
359
|
+
|
|
360
|
+
# single_psi['var'] = var
|
|
361
|
+
# fnl_psi_res.append(single_psi)
|
|
362
|
+
|
|
363
|
+
# return pd.concat(fnl_psi_res)
|
|
364
|
+
|
|
365
|
+
# def calculate_multivar_psi_two_sets(
|
|
366
|
+
# self,
|
|
367
|
+
# expected_df: pd.DataFrame,
|
|
368
|
+
# actual_df: pd.DataFrame,
|
|
369
|
+
# varlist: List[str],
|
|
370
|
+
# group_by: Optional[Union[str, List[str]]] = None
|
|
371
|
+
# ) -> pd.DataFrame:
|
|
372
|
+
# """
|
|
373
|
+
# Calculate PSI for multiple variables by comparing two datasets.
|
|
374
|
+
|
|
375
|
+
# Parameters
|
|
376
|
+
# ----------
|
|
377
|
+
# expected_df : pandas.DataFrame
|
|
378
|
+
# Expected/baseline dataset.
|
|
379
|
+
# actual_df : pandas.DataFrame
|
|
380
|
+
# Actual/comparison dataset.
|
|
381
|
+
# varlist : list
|
|
382
|
+
# List of variable names to calculate PSI for.
|
|
383
|
+
# group_by : str or list, optional
|
|
384
|
+
# Column(s) to group by.
|
|
385
|
+
|
|
386
|
+
# Returns
|
|
387
|
+
# -------
|
|
388
|
+
# pandas.DataFrame
|
|
389
|
+
# PSI results for all variables.
|
|
390
|
+
# """
|
|
391
|
+
# multi_psi_res = []
|
|
392
|
+
# for var in tqdm(varlist):
|
|
393
|
+
# single_psi = self.calculate_psi(
|
|
394
|
+
# expected=expected_df,
|
|
395
|
+
# actual=actual_df,
|
|
396
|
+
# target_col=var,
|
|
397
|
+
# group_by=group_by
|
|
398
|
+
# )
|
|
399
|
+
# if group_by is None:
|
|
400
|
+
# single_psi = pd.DataFrame([single_psi], columns=['psi'])
|
|
401
|
+
# single_psi['var'] = var
|
|
402
|
+
# multi_psi_res.append(single_psi)
|
|
403
|
+
|
|
404
|
+
# return pd.concat(multi_psi_res)
|
|
405
|
+
|
|
406
|
+
def calculate(
|
|
407
|
+
self,
|
|
408
|
+
expected_df: pd.DataFrame,
|
|
409
|
+
current_data: pd.DataFrame,
|
|
410
|
+
varlist: List[str],
|
|
411
|
+
group_by: Optional[str] = None,
|
|
412
|
+
group_name: Optional[str] = None,
|
|
413
|
+
return_details = False
|
|
414
|
+
) -> pd.DataFrame:
|
|
415
|
+
"""
|
|
416
|
+
Calculate grouped PSI comparing two datasets, using expected as benchmark.
|
|
417
|
+
|
|
418
|
+
Parameters
|
|
419
|
+
----------
|
|
420
|
+
expected_df : pandas.DataFrame
|
|
421
|
+
Expected/baseline dataset.
|
|
422
|
+
current_data : pandas.DataFrame
|
|
423
|
+
Actual/comparison dataset.
|
|
424
|
+
varlist : list
|
|
425
|
+
List of variable names.
|
|
426
|
+
group_by : str, optional
|
|
427
|
+
Column to group by in both datasets.
|
|
428
|
+
group_name : str, optional
|
|
429
|
+
Specific group column name for multi-group calculation.
|
|
430
|
+
|
|
431
|
+
Returns
|
|
432
|
+
-------
|
|
433
|
+
pandas.DataFrame
|
|
434
|
+
Grouped PSI results.
|
|
435
|
+
"""
|
|
436
|
+
return calculate_multigroup_psi_two_sets(
|
|
437
|
+
expected_df = expected_df,
|
|
438
|
+
actual_df = current_data,
|
|
439
|
+
varlist = varlist,
|
|
440
|
+
group_by = group_by,
|
|
441
|
+
buckets = self.buckets,
|
|
442
|
+
equal_freq = self.equal_freq,
|
|
443
|
+
min_bin_prop = self.min_bin_prop,
|
|
444
|
+
content = self.content,
|
|
445
|
+
precision = self.precision,
|
|
446
|
+
group_name = group_name,
|
|
447
|
+
return_details = return_details
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ============================================================================
|
|
452
|
+
# Standalone Functions (Preserved from original code with improvements)
|
|
453
|
+
# ============================================================================
|
|
454
|
+
|
|
455
|
+
def _calculate_single_psi(
|
|
456
|
+
expected_series: pd.Series,
|
|
457
|
+
actual_series: pd.Series,
|
|
458
|
+
buckets: int = 10,
|
|
459
|
+
equal_freq: bool = True,
|
|
460
|
+
return_details: bool = False,
|
|
461
|
+
min_bin_prop: float = 0.05,
|
|
462
|
+
content: float = 1e-6,
|
|
463
|
+
precision: int = 5
|
|
464
|
+
) -> Union[float, Tuple[float, pd.DataFrame]]:
|
|
465
|
+
"""
|
|
466
|
+
Calculate Population Stability Index (PSI) for a single variable.
|
|
467
|
+
|
|
468
|
+
This function bins both expected and actual series using the same breakpoints,
|
|
469
|
+
then calculates PSI based on the distribution difference between them.
|
|
470
|
+
|
|
471
|
+
Parameters
|
|
472
|
+
----------
|
|
473
|
+
expected_series : pandas.Series
|
|
474
|
+
Expected/baseline data series.
|
|
475
|
+
actual_series : pandas.Series
|
|
476
|
+
Actual/comparison data series.
|
|
477
|
+
buckets : int, optional
|
|
478
|
+
Number of bins. Default is 10.
|
|
479
|
+
equal_freq : bool, optional
|
|
480
|
+
Use equal frequency binning. Default is True.
|
|
481
|
+
return_details : bool, optional
|
|
482
|
+
Return detailed bin information. Default is False.
|
|
483
|
+
min_bin_prop : float, optional
|
|
484
|
+
Minimum proportion for each bin. Default is 0.05.
|
|
485
|
+
content : float, optional
|
|
486
|
+
Small value to avoid division by zero. Default is 1e-6.
|
|
487
|
+
precision : int, optional
|
|
488
|
+
Decimal precision. Default is 5.
|
|
489
|
+
|
|
490
|
+
Returns
|
|
491
|
+
-------
|
|
492
|
+
float or tuple
|
|
493
|
+
If return_details is False: Returns total PSI value.
|
|
494
|
+
If return_details is True: Returns tuple of (PSI value, details DataFrame).
|
|
495
|
+
|
|
496
|
+
Notes
|
|
497
|
+
-----
|
|
498
|
+
PSI Formula: Σ (Actual% - Expected%) * ln(Actual% / Expected%)
|
|
499
|
+
A PSI < 0.1 indicates stable population, 0.1-0.25 suggests some change,
|
|
500
|
+
and > 0.25 indicates significant drift.
|
|
501
|
+
"""
|
|
502
|
+
# Drop NA
|
|
503
|
+
expected_clean = expected_series.dropna()
|
|
504
|
+
actual_clean = actual_series.dropna()
|
|
505
|
+
|
|
506
|
+
# Bin the expected data to get breakpoints
|
|
507
|
+
expected_bins, breakpoints = quick_binning(
|
|
508
|
+
pd.DataFrame(expected_clean),
|
|
509
|
+
expected_clean.name,
|
|
510
|
+
labels=None,
|
|
511
|
+
nbins=buckets,
|
|
512
|
+
precision=precision,
|
|
513
|
+
equal_freq=equal_freq,
|
|
514
|
+
right=True,
|
|
515
|
+
include_lowest=False,
|
|
516
|
+
min_bin_prop=min_bin_prop,
|
|
517
|
+
tree_binning=False,
|
|
518
|
+
target=None,
|
|
519
|
+
random_state=42
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
# Bin the actual data using the same breakpoints
|
|
523
|
+
actual_bins, _ = quick_binning(
|
|
524
|
+
pd.DataFrame(actual_clean),
|
|
525
|
+
actual_clean.name,
|
|
526
|
+
labels=None,
|
|
527
|
+
nbins=list(breakpoints),
|
|
528
|
+
precision=precision,
|
|
529
|
+
equal_freq=equal_freq,
|
|
530
|
+
right=True,
|
|
531
|
+
include_lowest=False,
|
|
532
|
+
min_bin_prop=min_bin_prop,
|
|
533
|
+
tree_binning=False,
|
|
534
|
+
target=None,
|
|
535
|
+
random_state=42
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Get bin counts
|
|
539
|
+
expected_count = expected_bins.value_counts(normalize=False, sort=False)
|
|
540
|
+
actual_count = actual_bins.value_counts(normalize=False, sort=False)
|
|
541
|
+
|
|
542
|
+
# Get bin proportions
|
|
543
|
+
expected_percents = expected_bins.value_counts(normalize=True, sort=False)
|
|
544
|
+
actual_percents = actual_bins.value_counts(normalize=True, sort=False)
|
|
545
|
+
|
|
546
|
+
# Ensure both series have the same bin indices
|
|
547
|
+
all_bins = expected_percents.index.union(actual_percents.index)
|
|
548
|
+
expected_percents = expected_percents.reindex(all_bins, fill_value=content)
|
|
549
|
+
actual_percents = actual_percents.reindex(all_bins, fill_value=content)
|
|
550
|
+
|
|
551
|
+
# Clip to avoid division by zero
|
|
552
|
+
expected_percents = expected_percents.clip(lower=content)
|
|
553
|
+
actual_percents = actual_percents.clip(lower=content)
|
|
554
|
+
|
|
555
|
+
# Calculate PSI
|
|
556
|
+
psi_values = (actual_percents - expected_percents) * np.log(actual_percents / expected_percents)
|
|
557
|
+
psi_total = psi_values.sum()
|
|
558
|
+
|
|
559
|
+
if return_details:
|
|
560
|
+
details = pd.DataFrame({
|
|
561
|
+
'expected_count': expected_count,
|
|
562
|
+
'actual_count': actual_count,
|
|
563
|
+
'expected_percent': expected_percents,
|
|
564
|
+
'actual_percent': actual_percents,
|
|
565
|
+
'psi_component': psi_values
|
|
566
|
+
})
|
|
567
|
+
return psi_total, details
|
|
568
|
+
else:
|
|
569
|
+
return psi_total
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def calculate_psi(
|
|
573
|
+
expected: Union[pd.DataFrame, pd.Series],
|
|
574
|
+
actual: Union[pd.DataFrame, pd.Series],
|
|
575
|
+
target_col: str,
|
|
576
|
+
buckets: int = 10,
|
|
577
|
+
equal_freq: bool = True,
|
|
578
|
+
group_by: Optional[Union[str, List[str]]] = None,
|
|
579
|
+
return_details: bool = False,
|
|
580
|
+
min_bin_prop: float = 0.05,
|
|
581
|
+
content: float = 1e-6,
|
|
582
|
+
precision: int = 5
|
|
583
|
+
) -> Union[float, pd.DataFrame, Tuple[Dict, Dict]]:
|
|
584
|
+
"""
|
|
585
|
+
Calculate Population Stability Index (PSI) for a variable, optionally by groups.
|
|
586
|
+
|
|
587
|
+
This function computes PSI to measure the distribution shift between expected
|
|
588
|
+
(baseline) and actual (comparison) datasets for a specified variable.
|
|
589
|
+
|
|
590
|
+
Parameters
|
|
591
|
+
----------
|
|
592
|
+
expected : pandas.DataFrame or pandas.Series
|
|
593
|
+
Expected/baseline data.
|
|
594
|
+
actual : pandas.DataFrame or pandas.Series
|
|
595
|
+
Actual/comparison data.
|
|
596
|
+
target_col : str
|
|
597
|
+
Column name to calculate PSI for.
|
|
598
|
+
buckets : int, optional
|
|
599
|
+
Number of bins. Default is 10.
|
|
600
|
+
equal_freq : bool, optional
|
|
601
|
+
Use equal frequency binning. Default is True.
|
|
602
|
+
group_by : str or list, optional
|
|
603
|
+
Column(s) to group by for stratified PSI calculation. Default is None.
|
|
604
|
+
return_details : bool, optional
|
|
605
|
+
Return detailed bin information. Default is False.
|
|
606
|
+
min_bin_prop : float, optional
|
|
607
|
+
Minimum proportion for each bin. Default is 0.05.
|
|
608
|
+
content : float, optional
|
|
609
|
+
Small value to avoid division by zero. Default is 1e-6.
|
|
610
|
+
precision : int, optional
|
|
611
|
+
Decimal precision. Default is 5.
|
|
612
|
+
|
|
613
|
+
Returns
|
|
614
|
+
-------
|
|
615
|
+
float, pandas.DataFrame, or tuple
|
|
616
|
+
- If group_by is None and return_details is False: Single PSI float value.
|
|
617
|
+
- If group_by is None and return_details is True: Tuple of (psi_value, details_dict).
|
|
618
|
+
- If group_by is set and return_details is False: DataFrame with PSI values per group.
|
|
619
|
+
- If group_by is set and return_details is True: Tuple of (results_dict, details_dict).
|
|
620
|
+
|
|
621
|
+
Examples
|
|
622
|
+
--------
|
|
623
|
+
>>> # Simple PSI calculation
|
|
624
|
+
>>> psi = calculate_psi(expected_df, actual_df, 'score')
|
|
625
|
+
|
|
626
|
+
>>> # PSI by groups
|
|
627
|
+
>>> psi_by_region = calculate_psi(expected_df, actual_df, 'score', group_by='region')
|
|
628
|
+
"""
|
|
629
|
+
if group_by is not None:
|
|
630
|
+
if isinstance(group_by, str):
|
|
631
|
+
group_by = [group_by]
|
|
632
|
+
|
|
633
|
+
expected_subset = expected[[target_col] + group_by].copy()
|
|
634
|
+
actual_subset = actual[[target_col] + group_by].copy()
|
|
635
|
+
|
|
636
|
+
expected_subset = expected_subset.copy()
|
|
637
|
+
actual_subset = actual_subset.copy()
|
|
638
|
+
|
|
639
|
+
expected_subset['_dataset'] = 'expected'
|
|
640
|
+
actual_subset['_dataset'] = 'actual'
|
|
641
|
+
|
|
642
|
+
combined = pd.concat([expected_subset, actual_subset], ignore_index=True)
|
|
643
|
+
|
|
644
|
+
results = {}
|
|
645
|
+
details_dict = {}
|
|
646
|
+
|
|
647
|
+
for group, group_data in combined.groupby(group_by):
|
|
648
|
+
expected_group = group_data[group_data['_dataset'] == 'expected'].drop('_dataset', axis=1)
|
|
649
|
+
actual_group = group_data[group_data['_dataset'] == 'actual'].drop('_dataset', axis=1)
|
|
650
|
+
|
|
651
|
+
if expected_group.shape[0] == 0 or actual_group.shape[0] == 0:
|
|
652
|
+
results[group] = 999999
|
|
653
|
+
continue
|
|
654
|
+
|
|
655
|
+
if return_details:
|
|
656
|
+
psi_value, detail = _calculate_single_psi(
|
|
657
|
+
expected_group[target_col],
|
|
658
|
+
actual_group[target_col],
|
|
659
|
+
buckets,
|
|
660
|
+
equal_freq,
|
|
661
|
+
True,
|
|
662
|
+
min_bin_prop,
|
|
663
|
+
content,
|
|
664
|
+
precision
|
|
665
|
+
)
|
|
666
|
+
results[group] = psi_value
|
|
667
|
+
details_dict[group] = detail
|
|
668
|
+
else:
|
|
669
|
+
results[group] = _calculate_single_psi(
|
|
670
|
+
expected_group[target_col],
|
|
671
|
+
actual_group[target_col],
|
|
672
|
+
buckets,
|
|
673
|
+
equal_freq,
|
|
674
|
+
False,
|
|
675
|
+
min_bin_prop,
|
|
676
|
+
content,
|
|
677
|
+
precision
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
if return_details:
|
|
681
|
+
return results, details_dict
|
|
682
|
+
else:
|
|
683
|
+
return pd.DataFrame(results, index=['psi']).T
|
|
684
|
+
|
|
685
|
+
else:
|
|
686
|
+
if return_details:
|
|
687
|
+
return _calculate_single_psi(
|
|
688
|
+
expected[target_col],
|
|
689
|
+
actual[target_col],
|
|
690
|
+
buckets,
|
|
691
|
+
equal_freq,
|
|
692
|
+
True,
|
|
693
|
+
min_bin_prop,
|
|
694
|
+
content,
|
|
695
|
+
precision
|
|
696
|
+
)
|
|
697
|
+
else:
|
|
698
|
+
return _calculate_single_psi(
|
|
699
|
+
expected[target_col],
|
|
700
|
+
actual[target_col],
|
|
701
|
+
buckets,
|
|
702
|
+
equal_freq,
|
|
703
|
+
False,
|
|
704
|
+
min_bin_prop,
|
|
705
|
+
content,
|
|
706
|
+
precision
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def calculate_within_psi(
|
|
711
|
+
data: pd.DataFrame,
|
|
712
|
+
grp_name: str,
|
|
713
|
+
target_col: str,
|
|
714
|
+
benchmark: Optional[Any] = None,
|
|
715
|
+
equal_freq: bool = True,
|
|
716
|
+
buckets: int = 10,
|
|
717
|
+
return_details: bool = False,
|
|
718
|
+
min_bin_prop: float = 0.05,
|
|
719
|
+
content: float = 1e-6,
|
|
720
|
+
precision: int = 5,
|
|
721
|
+
benchmark_display_name: Optional[str] = None
|
|
722
|
+
) -> Union[pd.DataFrame, Dict]:
|
|
723
|
+
"""
|
|
724
|
+
Calculate PSI values within a single dataset, comparing groups to a benchmark.
|
|
725
|
+
|
|
726
|
+
This function computes PSI between a benchmark group and all other groups
|
|
727
|
+
in a specified column, useful for monitoring population stability over time.
|
|
728
|
+
|
|
729
|
+
Parameters
|
|
730
|
+
----------
|
|
731
|
+
data : pandas.DataFrame
|
|
732
|
+
Input dataset containing all groups.
|
|
733
|
+
grp_name : str
|
|
734
|
+
Column name for grouping.
|
|
735
|
+
target_col : str
|
|
736
|
+
Column name to calculate PSI for.
|
|
737
|
+
benchmark : str or callable, optional
|
|
738
|
+
Benchmark group value or filter function. If None, uses the first group.
|
|
739
|
+
equal_freq : bool, optional
|
|
740
|
+
Use equal frequency binning. Default is True.
|
|
741
|
+
buckets : int, optional
|
|
742
|
+
Number of bins. Default is 10.
|
|
743
|
+
return_details : bool, optional
|
|
744
|
+
Return detailed bin information. Default is False.
|
|
745
|
+
min_bin_prop : float, optional
|
|
746
|
+
Minimum proportion for each bin. Default is 0.05.
|
|
747
|
+
content : float, optional
|
|
748
|
+
Small value to avoid division by zero. Default is 1e-6.
|
|
749
|
+
precision : int, optional
|
|
750
|
+
Decimal precision. Default is 5.
|
|
751
|
+
benchmark_display_name : str, optional
|
|
752
|
+
Custom name for benchmark in results.
|
|
753
|
+
|
|
754
|
+
Returns
|
|
755
|
+
-------
|
|
756
|
+
pandas.DataFrame or dict
|
|
757
|
+
If return_details is False: DataFrame with PSI values per group.
|
|
758
|
+
If return_details is True: Dict with 'psi' (DataFrame) and 'details' (dict).
|
|
759
|
+
|
|
760
|
+
Examples
|
|
761
|
+
--------
|
|
762
|
+
>>> # Compare all months against January
|
|
763
|
+
>>> psi_results = calculate_within_psi(data, 'month', 'score', benchmark='2024-01')
|
|
764
|
+
"""
|
|
765
|
+
if callable(benchmark):
|
|
766
|
+
benchmark_data = data[benchmark(data)]
|
|
767
|
+
else:
|
|
768
|
+
benchmark_data = data[data[grp_name] == benchmark] if benchmark is not None else data
|
|
769
|
+
|
|
770
|
+
obs_values = [x for x in data[grp_name].unique().tolist() if x != benchmark]
|
|
771
|
+
|
|
772
|
+
res_dict = {benchmark_display_name: 0} if benchmark_display_name is not None else {}
|
|
773
|
+
detail_dict = {}
|
|
774
|
+
|
|
775
|
+
for obs_value in obs_values:
|
|
776
|
+
obs_data = data[data[grp_name] == obs_value]
|
|
777
|
+
|
|
778
|
+
if return_details:
|
|
779
|
+
psi, details = calculate_psi(
|
|
780
|
+
benchmark_data,
|
|
781
|
+
obs_data,
|
|
782
|
+
target_col=target_col,
|
|
783
|
+
buckets=buckets,
|
|
784
|
+
equal_freq=equal_freq,
|
|
785
|
+
return_details=True,
|
|
786
|
+
min_bin_prop=min_bin_prop,
|
|
787
|
+
content=content,
|
|
788
|
+
precision=precision
|
|
789
|
+
)
|
|
790
|
+
res_dict[obs_value] = psi
|
|
791
|
+
detail_dict[obs_value] = details
|
|
792
|
+
else:
|
|
793
|
+
psi = calculate_psi(
|
|
794
|
+
benchmark_data,
|
|
795
|
+
obs_data,
|
|
796
|
+
target_col=target_col,
|
|
797
|
+
buckets=buckets,
|
|
798
|
+
equal_freq=equal_freq,
|
|
799
|
+
return_details=False,
|
|
800
|
+
min_bin_prop=min_bin_prop,
|
|
801
|
+
content=content,
|
|
802
|
+
precision=precision
|
|
803
|
+
)
|
|
804
|
+
res_dict[obs_value] = psi
|
|
805
|
+
|
|
806
|
+
fnl_res = pd.DataFrame(res_dict, index=['psi']).T\
|
|
807
|
+
.reset_index(drop=False)\
|
|
808
|
+
.rename(columns={"index": grp_name})
|
|
809
|
+
|
|
810
|
+
if return_details:
|
|
811
|
+
return {"psi": fnl_res, "details": detail_dict}
|
|
812
|
+
else:
|
|
813
|
+
return fnl_res
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def calculate_psi_within_dataset(
|
|
817
|
+
data: pd.DataFrame,
|
|
818
|
+
grp_name: str,
|
|
819
|
+
varlist: List[str],
|
|
820
|
+
benchmark: Optional[Any] = None,
|
|
821
|
+
equal_freq: bool = True,
|
|
822
|
+
buckets: int = 10,
|
|
823
|
+
min_bin_prop: float = 0.05,
|
|
824
|
+
content: float = 1e-6,
|
|
825
|
+
precision: int = 5
|
|
826
|
+
) -> pd.DataFrame:
|
|
827
|
+
"""
|
|
828
|
+
Calculate PSI for multiple variables within a dataset, comparing groups to a benchmark.
|
|
829
|
+
|
|
830
|
+
This function iterates over a list of variables and calculates PSI for each,
|
|
831
|
+
combining results into a single DataFrame.
|
|
832
|
+
|
|
833
|
+
Parameters
|
|
834
|
+
----------
|
|
835
|
+
data : pandas.DataFrame
|
|
836
|
+
Input dataset.
|
|
837
|
+
grp_name : str
|
|
838
|
+
Column name for grouping.
|
|
839
|
+
varlist : list
|
|
840
|
+
List of variable names to calculate PSI for.
|
|
841
|
+
benchmark : str or callable, optional
|
|
842
|
+
Benchmark group value or filter function.
|
|
843
|
+
equal_freq : bool, optional
|
|
844
|
+
Use equal frequency binning. Default is True.
|
|
845
|
+
buckets : int, optional
|
|
846
|
+
Number of bins. Default is 10.
|
|
847
|
+
min_bin_prop : float, optional
|
|
848
|
+
Minimum proportion for each bin. Default is 0.05.
|
|
849
|
+
content : float, optional
|
|
850
|
+
Small value to avoid division by zero. Default is 1e-6.
|
|
851
|
+
precision : int, optional
|
|
852
|
+
Decimal precision. Default is 5.
|
|
853
|
+
|
|
854
|
+
Returns
|
|
855
|
+
-------
|
|
856
|
+
pandas.DataFrame
|
|
857
|
+
Combined PSI results for all variables, sorted by group.
|
|
858
|
+
|
|
859
|
+
Examples
|
|
860
|
+
--------
|
|
861
|
+
>>> variables = ['score', 'age', 'income']
|
|
862
|
+
>>> psi_df = calculate_psi_within_dataset(data, 'month', variables, benchmark='2024-01')
|
|
863
|
+
"""
|
|
864
|
+
fnl_psi_res = []
|
|
865
|
+
for var in tqdm(varlist):
|
|
866
|
+
single_psi = calculate_within_psi(
|
|
867
|
+
data=data,
|
|
868
|
+
grp_name=grp_name,
|
|
869
|
+
benchmark=benchmark,
|
|
870
|
+
target_col=var,
|
|
871
|
+
buckets=buckets,
|
|
872
|
+
equal_freq=equal_freq,
|
|
873
|
+
return_details=False,
|
|
874
|
+
min_bin_prop=min_bin_prop,
|
|
875
|
+
content=content,
|
|
876
|
+
precision=precision
|
|
877
|
+
).sort_values([grp_name]).reset_index(drop=True)
|
|
878
|
+
|
|
879
|
+
single_psi['var'] = var
|
|
880
|
+
fnl_psi_res.append(single_psi)
|
|
881
|
+
|
|
882
|
+
return pd.concat(fnl_psi_res)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def calculate_multivar_psi_two_sets(
|
|
886
|
+
expected_df: pd.DataFrame,
|
|
887
|
+
actual_df: pd.DataFrame,
|
|
888
|
+
varlist: List[str],
|
|
889
|
+
group_by: Optional[Union[str, List[str]]] = None,
|
|
890
|
+
buckets: int = 10,
|
|
891
|
+
equal_freq: bool = True,
|
|
892
|
+
min_bin_prop: float = 0.05,
|
|
893
|
+
content: float = 1e-6,
|
|
894
|
+
precision: int = 5
|
|
895
|
+
) -> pd.DataFrame:
|
|
896
|
+
"""
|
|
897
|
+
Calculate PSI for multiple variables by comparing two different datasets.
|
|
898
|
+
|
|
899
|
+
This function computes PSI for each variable in varlist between expected
|
|
900
|
+
and actual DataFrames.
|
|
901
|
+
|
|
902
|
+
Parameters
|
|
903
|
+
----------
|
|
904
|
+
expected_df : pandas.DataFrame
|
|
905
|
+
Expected/baseline dataset.
|
|
906
|
+
actual_df : pandas.DataFrame
|
|
907
|
+
Actual/comparison dataset.
|
|
908
|
+
varlist : list
|
|
909
|
+
List of variable names to calculate PSI for.
|
|
910
|
+
group_by : str or list, optional
|
|
911
|
+
Column(s) to group by. Default is None.
|
|
912
|
+
buckets : int, optional
|
|
913
|
+
Number of bins. Default is 10.
|
|
914
|
+
equal_freq : bool, optional
|
|
915
|
+
Use equal frequency binning. Default is True.
|
|
916
|
+
min_bin_prop : float, optional
|
|
917
|
+
Minimum proportion for each bin. Default is 0.05.
|
|
918
|
+
content : float, optional
|
|
919
|
+
Small value to avoid division by zero. Default is 1e-6.
|
|
920
|
+
precision : int, optional
|
|
921
|
+
Decimal precision. Default is 5.
|
|
922
|
+
|
|
923
|
+
Returns
|
|
924
|
+
-------
|
|
925
|
+
pandas.DataFrame
|
|
926
|
+
PSI results for all variables with 'var' and 'psi' columns.
|
|
927
|
+
|
|
928
|
+
Examples
|
|
929
|
+
--------
|
|
930
|
+
>>> variables = ['score', 'age', 'income']
|
|
931
|
+
>>> psi_df = calculate_multivar_psi_two_sets(train_df, production_df, variables)
|
|
932
|
+
"""
|
|
933
|
+
multi_psi_res = []
|
|
934
|
+
for var in tqdm(varlist):
|
|
935
|
+
single_psi = calculate_psi(
|
|
936
|
+
expected=expected_df,
|
|
937
|
+
actual=actual_df,
|
|
938
|
+
target_col=var,
|
|
939
|
+
group_by=group_by,
|
|
940
|
+
buckets=buckets,
|
|
941
|
+
return_details=False
|
|
942
|
+
)
|
|
943
|
+
if group_by is None:
|
|
944
|
+
single_psi = pd.DataFrame([single_psi], columns=['psi'])
|
|
945
|
+
single_psi['var'] = var
|
|
946
|
+
multi_psi_res.append(single_psi)
|
|
947
|
+
|
|
948
|
+
return pd.concat(multi_psi_res)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
# def calculate_multigroup_psi_two_sets(
|
|
952
|
+
# expected_df: pd.DataFrame,
|
|
953
|
+
# actual_df: pd.DataFrame,
|
|
954
|
+
# varlist: List[str],
|
|
955
|
+
# group_by: Optional[Union[str, List[str]]] = None,
|
|
956
|
+
# buckets: int = 10,
|
|
957
|
+
# equal_freq: bool = True,
|
|
958
|
+
# min_bin_prop: float = 0.05,
|
|
959
|
+
# content: float = 1e-6,
|
|
960
|
+
# precision: int = 5,
|
|
961
|
+
# group_name: Optional[str] = None
|
|
962
|
+
# ) -> pd.DataFrame:
|
|
963
|
+
# """
|
|
964
|
+
# Calculate grouped PSI using expected DataFrame as benchmark, applied to actual DataFrame groups.
|
|
965
|
+
|
|
966
|
+
# This function uses expected_df as the baseline and calculates PSI for each group
|
|
967
|
+
# in actual_df, useful for comparing multiple time periods or segments.
|
|
968
|
+
|
|
969
|
+
# Parameters
|
|
970
|
+
# ----------
|
|
971
|
+
# expected_df : pandas.DataFrame
|
|
972
|
+
# Expected/baseline dataset used as benchmark.
|
|
973
|
+
# actual_df : pandas.DataFrame
|
|
974
|
+
# Actual/comparison dataset to iterate over groups.
|
|
975
|
+
# varlist : list
|
|
976
|
+
# List of variable names to calculate PSI for.
|
|
977
|
+
# group_by : str or list, optional
|
|
978
|
+
# Column(s) to group by. Default is None.
|
|
979
|
+
# buckets : int, optional
|
|
980
|
+
# Number of bins. Default is 10.
|
|
981
|
+
# equal_freq : bool, optional
|
|
982
|
+
# Use equal frequency binning. Default is True.
|
|
983
|
+
# min_bin_prop : float, optional
|
|
984
|
+
# Minimum proportion for each bin. Default is 0.05.
|
|
985
|
+
# content : float, optional
|
|
986
|
+
# Small value to avoid division by zero. Default is 1e-6.
|
|
987
|
+
# precision : int, optional
|
|
988
|
+
# Decimal precision. Default is 5.
|
|
989
|
+
# group_name : str, optional
|
|
990
|
+
# Column name for grouping in actual_df. If provided, iterates over groups.
|
|
991
|
+
|
|
992
|
+
# Returns
|
|
993
|
+
# -------
|
|
994
|
+
# pandas.DataFrame
|
|
995
|
+
# Grouped PSI results for all variables and groups.
|
|
996
|
+
|
|
997
|
+
# Examples
|
|
998
|
+
# --------
|
|
999
|
+
# >>> # Calculate PSI for each month against Q1 benchmark
|
|
1000
|
+
# >>> psi_df = calculate_multigroup_psi_two_sets(
|
|
1001
|
+
# ... expected_df=q1_df,
|
|
1002
|
+
# ... actual_df=all_months_df,
|
|
1003
|
+
# ... varlist=['score', 'age'],
|
|
1004
|
+
# ... group_name='month'
|
|
1005
|
+
# ... )
|
|
1006
|
+
# """
|
|
1007
|
+
# if group_name is not None:
|
|
1008
|
+
# if actual_df[group_name].isna().sum() > 0:
|
|
1009
|
+
# actual_df = actual_df.copy()
|
|
1010
|
+
# actual_df[group_name] = actual_df[group_name].fillna('__NULL__')
|
|
1011
|
+
|
|
1012
|
+
# multi_psi_res = []
|
|
1013
|
+
# for group, group_data in actual_df.groupby(group_name):
|
|
1014
|
+
# group_psi = calculate_multivar_psi_two_sets(
|
|
1015
|
+
# expected_df=expected_df,
|
|
1016
|
+
# actual_df=group_data,
|
|
1017
|
+
# varlist=varlist,
|
|
1018
|
+
# group_by=None,
|
|
1019
|
+
# buckets=buckets,
|
|
1020
|
+
# equal_freq=equal_freq,
|
|
1021
|
+
# min_bin_prop=min_bin_prop,
|
|
1022
|
+
# content=content,
|
|
1023
|
+
# precision=precision
|
|
1024
|
+
# )
|
|
1025
|
+
# group_psi[group_name] = group
|
|
1026
|
+
# multi_psi_res.append(group_psi)
|
|
1027
|
+
|
|
1028
|
+
# return pd.concat(multi_psi_res)
|
|
1029
|
+
|
|
1030
|
+
# group_psi = calculate_multivar_psi_two_sets(
|
|
1031
|
+
# expected_df=expected_df,
|
|
1032
|
+
# actual_df=actual_df,
|
|
1033
|
+
# varlist=varlist,
|
|
1034
|
+
# group_by=None,
|
|
1035
|
+
# buckets=buckets,
|
|
1036
|
+
# equal_freq=equal_freq,
|
|
1037
|
+
# min_bin_prop=min_bin_prop,
|
|
1038
|
+
# content=content,
|
|
1039
|
+
# precision=precision
|
|
1040
|
+
# )
|
|
1041
|
+
|
|
1042
|
+
# return group_psi
|
|
1043
|
+
|
|
1044
|
+
def calculate_multigroup_psi_two_sets(
|
|
1045
|
+
expected_df: pd.DataFrame,
|
|
1046
|
+
actual_df: pd.DataFrame,
|
|
1047
|
+
varlist: List[str],
|
|
1048
|
+
group_by: Optional[Union[str, List[str]]] = None,
|
|
1049
|
+
buckets: int = 10,
|
|
1050
|
+
equal_freq: bool = True,
|
|
1051
|
+
min_bin_prop: float = 0.05,
|
|
1052
|
+
content: float = 1e-6,
|
|
1053
|
+
precision: int = 5,
|
|
1054
|
+
group_name: Optional[str] = None,
|
|
1055
|
+
return_details: bool = False
|
|
1056
|
+
) -> Union[pd.DataFrame, Dict[str, pd.DataFrame]]:
|
|
1057
|
+
"""
|
|
1058
|
+
Calculate grouped PSI using expected DataFrame as benchmark, applied to actual DataFrame groups.
|
|
1059
|
+
|
|
1060
|
+
Parameters
|
|
1061
|
+
----------
|
|
1062
|
+
expected_df : pandas.DataFrame
|
|
1063
|
+
基准/期望分布数据集。
|
|
1064
|
+
actual_df : pandas.DataFrame
|
|
1065
|
+
实际/对比分布数据集。
|
|
1066
|
+
varlist : list
|
|
1067
|
+
待计算 PSI 的变量名列表。
|
|
1068
|
+
group_by : str or list, optional
|
|
1069
|
+
分组列名,对每个分组分别计算 PSI。默认 None。
|
|
1070
|
+
buckets : int, optional
|
|
1071
|
+
分箱数,默认 10。
|
|
1072
|
+
equal_freq : bool, optional
|
|
1073
|
+
是否等频分箱,默认 True。
|
|
1074
|
+
min_bin_prop : float, optional
|
|
1075
|
+
每箱最小占比,默认 0.05。
|
|
1076
|
+
content : float, optional
|
|
1077
|
+
防除零小量,默认 1e-6。
|
|
1078
|
+
precision : int, optional
|
|
1079
|
+
数值精度,默认 5。
|
|
1080
|
+
group_name : str, optional
|
|
1081
|
+
多分组计算时的分组列名。默认 None。
|
|
1082
|
+
return_details : bool, optional
|
|
1083
|
+
是否返回详细分箱信息。若为 True,返回字典 {'psi': psi_df, 'details': details_df},
|
|
1084
|
+
details_df 包含列:['bin', 'expected_percent', 'actual_percent', 'psi_component', group_name, 'var']
|
|
1085
|
+
"""
|
|
1086
|
+
if group_name is not None:
|
|
1087
|
+
if actual_df[group_name].isna().sum() > 0:
|
|
1088
|
+
actual_df = actual_df.copy()
|
|
1089
|
+
actual_df[group_name] = actual_df[group_name].fillna('__NULL__')
|
|
1090
|
+
|
|
1091
|
+
if return_details:
|
|
1092
|
+
psi_records = []
|
|
1093
|
+
detail_records = []
|
|
1094
|
+
|
|
1095
|
+
for group, group_data in actual_df.groupby(group_name):
|
|
1096
|
+
for var in tqdm(varlist, desc=f"Processing group {group}"):
|
|
1097
|
+
psi_val, detail_df = calculate_psi(
|
|
1098
|
+
expected=expected_df,
|
|
1099
|
+
actual=group_data,
|
|
1100
|
+
target_col=var,
|
|
1101
|
+
group_by=group_by,
|
|
1102
|
+
buckets=buckets,
|
|
1103
|
+
equal_freq=equal_freq,
|
|
1104
|
+
return_details=True,
|
|
1105
|
+
min_bin_prop=min_bin_prop,
|
|
1106
|
+
content=content,
|
|
1107
|
+
precision=precision
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
psi_records.append({group_name: group, 'var': var, 'psi': psi_val})
|
|
1111
|
+
|
|
1112
|
+
# ========== 标准化 detail_df(修复后的核心代码) ==========
|
|
1113
|
+
if detail_df is not None and not detail_df.empty:
|
|
1114
|
+
if not isinstance(detail_df, pd.DataFrame):
|
|
1115
|
+
detail_df = pd.DataFrame(detail_df)
|
|
1116
|
+
|
|
1117
|
+
if 'bin' not in detail_df.columns:
|
|
1118
|
+
# 重置索引,原索引列可能名为 'index' 或其他
|
|
1119
|
+
detail_df = detail_df.reset_index()
|
|
1120
|
+
# reset_index 后第一列就是原来的索引列
|
|
1121
|
+
index_col = detail_df.columns[0]
|
|
1122
|
+
detail_df = detail_df.rename(columns={index_col: 'bin'})
|
|
1123
|
+
|
|
1124
|
+
detail_df[group_name] = group
|
|
1125
|
+
detail_df['var'] = var
|
|
1126
|
+
|
|
1127
|
+
required_cols = ['bin', 'expected_count', 'actual_count', 'expected_percent', 'actual_percent', 'psi_component', group_name, 'var']
|
|
1128
|
+
for col in required_cols:
|
|
1129
|
+
if col not in detail_df.columns:
|
|
1130
|
+
detail_df[col] = np.nan
|
|
1131
|
+
detail_df = detail_df[required_cols]
|
|
1132
|
+
detail_records.append(detail_df)
|
|
1133
|
+
# ======================================================
|
|
1134
|
+
|
|
1135
|
+
psi_df = pd.DataFrame(psi_records)
|
|
1136
|
+
details_df = pd.concat(detail_records, ignore_index=True) if detail_records else pd.DataFrame(columns=['bin', 'expected_count', 'actual_count', 'expected_percent', 'actual_percent', 'psi_component', group_name, 'var'])
|
|
1137
|
+
return {'psi': psi_df, 'details': details_df}
|
|
1138
|
+
|
|
1139
|
+
else:
|
|
1140
|
+
# 未指定 group_name 时
|
|
1141
|
+
if return_details:
|
|
1142
|
+
# 支持多个变量
|
|
1143
|
+
psi_records = []
|
|
1144
|
+
detail_records = []
|
|
1145
|
+
for var in tqdm(varlist, desc="Calculating PSI with details"):
|
|
1146
|
+
psi_val, detail_df = calculate_psi(
|
|
1147
|
+
expected=expected_df,
|
|
1148
|
+
actual=actual_df,
|
|
1149
|
+
target_col=var,
|
|
1150
|
+
group_by=group_by,
|
|
1151
|
+
buckets=buckets,
|
|
1152
|
+
equal_freq=equal_freq,
|
|
1153
|
+
return_details=True,
|
|
1154
|
+
min_bin_prop=min_bin_prop,
|
|
1155
|
+
content=content,
|
|
1156
|
+
precision=precision
|
|
1157
|
+
)
|
|
1158
|
+
psi_records.append({'var': var, 'psi': psi_val})
|
|
1159
|
+
|
|
1160
|
+
# 标准化 detail_df
|
|
1161
|
+
if detail_df is not None and not detail_df.empty:
|
|
1162
|
+
if not isinstance(detail_df, pd.DataFrame):
|
|
1163
|
+
detail_df = pd.DataFrame(detail_df)
|
|
1164
|
+
if 'bin' not in detail_df.columns:
|
|
1165
|
+
detail_df = detail_df.reset_index()
|
|
1166
|
+
index_col = detail_df.columns[0]
|
|
1167
|
+
detail_df = detail_df.rename(columns={index_col: 'bin'})
|
|
1168
|
+
detail_df['var'] = var
|
|
1169
|
+
required_cols = ['bin', 'expected_count', 'actual_count', 'expected_percent', 'actual_percent', 'psi_component', 'var']
|
|
1170
|
+
for col in required_cols:
|
|
1171
|
+
if col not in detail_df.columns:
|
|
1172
|
+
detail_df[col] = np.nan
|
|
1173
|
+
detail_df = detail_df[required_cols]
|
|
1174
|
+
detail_records.append(detail_df)
|
|
1175
|
+
|
|
1176
|
+
psi_df = pd.DataFrame(psi_records)
|
|
1177
|
+
details_df = pd.concat(detail_records, ignore_index=True) if detail_records else pd.DataFrame(columns=['bin', 'expected_count', 'actual_count', 'expected_percent', 'actual_percent', 'psi_component', 'var'])
|
|
1178
|
+
return {'psi': psi_df, 'details': details_df}
|
|
1179
|
+
|
|
1180
|
+
else:
|
|
1181
|
+
|
|
1182
|
+
# 不返回详情,使用原有的批量计算函数
|
|
1183
|
+
group_psi = calculate_multivar_psi_two_sets(
|
|
1184
|
+
expected_df=expected_df,
|
|
1185
|
+
actual_df=actual_df,
|
|
1186
|
+
varlist=varlist,
|
|
1187
|
+
group_by=None,
|
|
1188
|
+
buckets=buckets,
|
|
1189
|
+
equal_freq=equal_freq,
|
|
1190
|
+
min_bin_prop=min_bin_prop,
|
|
1191
|
+
content=content,
|
|
1192
|
+
precision=precision
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
return group_psi
|