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.
Files changed (79) hide show
  1. ExcelMaster/ExcelFormatTool.py +487 -0
  2. ExcelMaster/ExcelMaster.py +917 -0
  3. ExcelMaster/Template.py +525 -0
  4. ExcelMaster/Utility.py +233 -0
  5. ExcelMaster/__init__.py +3 -0
  6. Modeling_Tool/Core/Binning_Tool.py +1608 -0
  7. Modeling_Tool/Core/Binning_Tool.pyi +48 -0
  8. Modeling_Tool/Core/Check_DuckDB_Compatibility.py +835 -0
  9. Modeling_Tool/Core/Json_Data_Converter.py +621 -0
  10. Modeling_Tool/Core/Model_Registry_Tool.py +199 -0
  11. Modeling_Tool/Core/ODPS_Tool.py +284 -0
  12. Modeling_Tool/Core/Slope_Tool.py +356 -0
  13. Modeling_Tool/Core/Slope_Tool.pyi +31 -0
  14. Modeling_Tool/Core/XOR_Encryptor.py +207 -0
  15. Modeling_Tool/Core/XOR_Encryptor.pyi +26 -0
  16. Modeling_Tool/Core/__init__.py +99 -0
  17. Modeling_Tool/Core/kDataFrame.py +228 -0
  18. Modeling_Tool/Core/kDataFrame.pyi +43 -0
  19. Modeling_Tool/Core/sample_weight_utils.py +77 -0
  20. Modeling_Tool/Core/utils.py +2672 -0
  21. Modeling_Tool/Eval/Evaluation_Tool.py +1452 -0
  22. Modeling_Tool/Eval/Evaluation_Tool.pyi +47 -0
  23. Modeling_Tool/Eval/Model_Eval_Tool.py +2548 -0
  24. Modeling_Tool/Eval/Model_Eval_Tool.pyi +37 -0
  25. Modeling_Tool/Eval/__init__.py +54 -0
  26. Modeling_Tool/Eval/evaluate_model.py +2008 -0
  27. Modeling_Tool/Eval/evaluate_model.pyi +50 -0
  28. Modeling_Tool/Eval/weighted_eval_utils.py +326 -0
  29. Modeling_Tool/Explainability/Coalition_Structure.py +305 -0
  30. Modeling_Tool/Explainability/Model_Explainer.py +743 -0
  31. Modeling_Tool/Explainability/__init__.py +24 -0
  32. Modeling_Tool/Feature/Distribution_Tool.py +509 -0
  33. Modeling_Tool/Feature/Distribution_Tool.pyi +42 -0
  34. Modeling_Tool/Feature/Feature_Insights.py +762 -0
  35. Modeling_Tool/Feature/Feature_Insights.pyi +33 -0
  36. Modeling_Tool/Feature/PSI_Tool.py +1195 -0
  37. Modeling_Tool/Feature/PSI_Tool.pyi +29 -0
  38. Modeling_Tool/Feature/WOE_Engine_Feature_Patch.py +355 -0
  39. Modeling_Tool/Feature/__init__.py +40 -0
  40. Modeling_Tool/Model/Backward_Tool.py +778 -0
  41. Modeling_Tool/Model/Backward_Tool.pyi +45 -0
  42. Modeling_Tool/Model/GBM_Search_Tool.py +251 -0
  43. Modeling_Tool/Model/GBM_Tool.py +1610 -0
  44. Modeling_Tool/Model/GBM_Tool.pyi +90 -0
  45. Modeling_Tool/Model/LRM_Tool.py +1198 -0
  46. Modeling_Tool/Model/LRM_Tool.pyi +47 -0
  47. Modeling_Tool/Model/__init__.py +61 -0
  48. Modeling_Tool/Sample/Distribution_Adaptation.py +131 -0
  49. Modeling_Tool/Sample/Distribution_Adaptation.pyi +30 -0
  50. Modeling_Tool/Sample/Reject_Infer.py +413 -0
  51. Modeling_Tool/Sample/Reject_Infer.pyi +43 -0
  52. Modeling_Tool/Sample/Sample_Split.py +520 -0
  53. Modeling_Tool/Sample/Sample_Split.pyi +43 -0
  54. Modeling_Tool/Sample/__init__.py +31 -0
  55. Modeling_Tool/UAT/UAT_Consistency_Checker.py +1180 -0
  56. Modeling_Tool/UAT/__init__.py +19 -0
  57. Modeling_Tool/WOE/WOE_Adapter.py +204 -0
  58. Modeling_Tool/WOE/WOE_Adapter.pyi +21 -0
  59. Modeling_Tool/WOE/WOE_Master.py +491 -0
  60. Modeling_Tool/WOE/WOE_Master.pyi +40 -0
  61. Modeling_Tool/WOE/WOE_Monotone_Binner.py +3324 -0
  62. Modeling_Tool/WOE/WOE_Monotone_Binner.pyi +71 -0
  63. Modeling_Tool/WOE/WOE_Plot_Tool.py +919 -0
  64. Modeling_Tool/WOE/WOE_Plot_Tool.pyi +46 -0
  65. Modeling_Tool/WOE/WOE_Report_Builder.py +214 -0
  66. Modeling_Tool/WOE/WOE_Report_Builder.pyi +24 -0
  67. Modeling_Tool/WOE/WOE_Tool.py +1094 -0
  68. Modeling_Tool/WOE/WOE_Tool.pyi +41 -0
  69. Modeling_Tool/WOE/__init__.py +86 -0
  70. Modeling_Tool/WOE/plot_woe_tool.py +290 -0
  71. Modeling_Tool/WOE/plot_woe_tool.pyi +25 -0
  72. Modeling_Tool/__init__.py +176 -0
  73. Report/Report_Tool.py +380 -0
  74. Report/__init__.py +3 -0
  75. supermodelingfactory-0.2.0.dist-info/METADATA +268 -0
  76. supermodelingfactory-0.2.0.dist-info/RECORD +79 -0
  77. supermodelingfactory-0.2.0.dist-info/WHEEL +5 -0
  78. supermodelingfactory-0.2.0.dist-info/licenses/LICENSE +102 -0
  79. 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