classifier-toolkit 0.1.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 (26) hide show
  1. classifier_toolkit/eda/__init__.py +19 -0
  2. classifier_toolkit/eda/bivariate_analysis.py +413 -0
  3. classifier_toolkit/eda/eda_toolkit.py +549 -0
  4. classifier_toolkit/eda/feature_engineering.py +1310 -0
  5. classifier_toolkit/eda/first_glance.py +253 -0
  6. classifier_toolkit/eda/univariate_analysis.py +778 -0
  7. classifier_toolkit/eda/visualizations.py +1248 -0
  8. classifier_toolkit/eda/warnings/__init__.py +4 -0
  9. classifier_toolkit/eda/warnings/automated_warnings.py +20 -0
  10. classifier_toolkit/eda/warnings/default_warnings.py +286 -0
  11. classifier_toolkit/feature_selection/__init__.py +33 -0
  12. classifier_toolkit/feature_selection/base.py +182 -0
  13. classifier_toolkit/feature_selection/embedded_methods/__init__.py +7 -0
  14. classifier_toolkit/feature_selection/embedded_methods/elastic_net.py +178 -0
  15. classifier_toolkit/feature_selection/meta_selector.py +329 -0
  16. classifier_toolkit/feature_selection/utils/__init__.py +17 -0
  17. classifier_toolkit/feature_selection/utils/plottings.py +65 -0
  18. classifier_toolkit/feature_selection/utils/scoring.py +86 -0
  19. classifier_toolkit/feature_selection/wrapper_methods/__init__.py +11 -0
  20. classifier_toolkit/feature_selection/wrapper_methods/rfe.py +345 -0
  21. classifier_toolkit/feature_selection/wrapper_methods/sequential_selection.py +566 -0
  22. classifier_toolkit/model_fitting/__init__.py +0 -0
  23. classifier_toolkit/model_fitting/model_search.py +3 -0
  24. classifier_toolkit-0.1.0.dist-info/METADATA +99 -0
  25. classifier_toolkit-0.1.0.dist-info/RECORD +26 -0
  26. classifier_toolkit-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,778 @@
1
+ import math
2
+ from datetime import date, datetime
3
+ from typing import List, Literal, Optional, Tuple, Union
4
+
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+ import pandas as pd
8
+ import plotly.express as px
9
+ import plotly.graph_objects as go
10
+ from scipy.stats import chi2_contingency
11
+
12
+
13
+ class UnivariateAnalysis:
14
+ def __init__(
15
+ self, df: pd.DataFrame, target_column: str, numerical_features: List[str]
16
+ ) -> None:
17
+ """
18
+ Initialize the UnivariateAnalysis class with data and configuration.
19
+
20
+ Parameters
21
+ ----------
22
+ df : pd.DataFrame
23
+ The data to be analyzed.
24
+ target_column : str
25
+ The target column for analysis.
26
+ numerical_features : List[str]
27
+ List of numerical features.
28
+ """
29
+ self.data = df.copy()
30
+ self.target_column = target_column
31
+ self.numerical_features = numerical_features or []
32
+
33
+ @staticmethod
34
+ def _get_cramers_v(data: pd.DataFrame, var1: str, var2: str) -> float:
35
+ """
36
+ Calculate Cramer's V statistic for two variables.
37
+
38
+ Parameters
39
+ ----------
40
+ data : pd.DataFrame
41
+ The data containing the variables.
42
+ var1 : str
43
+ The first variable.
44
+ var2 : str
45
+ The second variable.
46
+
47
+ Returns
48
+ -------
49
+ float
50
+ Cramer's V statistic.
51
+ """
52
+ confusion_matrix = pd.crosstab(data[var1], data[var2])
53
+ chi2 = chi2_contingency(confusion_matrix)[0]
54
+ n = confusion_matrix.sum().sum()
55
+ min_dim = min(confusion_matrix.shape) - 1
56
+
57
+ return np.sqrt(chi2 / (n * min_dim))
58
+
59
+ def _prepare_cramersV_features(
60
+ self, numerical_features: List[str], n_bins: int = 5
61
+ ) -> List[str]:
62
+ """
63
+ Prepare features for Cramer's V calculation by binning numerical features.
64
+
65
+ Parameters
66
+ ----------
67
+ numerical_features : List[str]
68
+ List of numerical feature names.
69
+ n_bins : int, optional
70
+ Number of bins for continuous variables, by default 5.
71
+
72
+ Returns
73
+ -------
74
+ List[str]
75
+ List of features prepared for Cramer's V calculation.
76
+ """
77
+ cramersV_var_list = self.data.select_dtypes(
78
+ include=["object", "category"]
79
+ ).columns.tolist()
80
+
81
+ # Exclude the target column
82
+ cramersV_var_list = [
83
+ var for var in cramersV_var_list if var != self.target_column
84
+ ]
85
+
86
+ for num in numerical_features:
87
+ unique_values = self.data[num].nunique()
88
+
89
+ if (
90
+ unique_values > 1
91
+ ): # Ensure there are at least two unique values for binning
92
+ try:
93
+ self.data[f"{num}_bin"] = pd.qcut(
94
+ self.data[num], q=n_bins, duplicates="drop"
95
+ )
96
+ # Check if the resulting bins are more than 1
97
+ if self.data[f"{num}_bin"].nunique() <= 1:
98
+ raise ValueError(
99
+ f"Only 1 bin created for {num} with q={n_bins}."
100
+ )
101
+
102
+ except ValueError as e:
103
+ print(
104
+ f"Equal frequency binning not possible for {num}, attempting 2 bins: {e}"
105
+ )
106
+ if pd.api.types.is_integer_dtype(self.data[num]):
107
+ # For integer type, ensure bins are whole numbers
108
+ self.data[f"{num}_bin"] = pd.cut(
109
+ self.data[num], bins=2, include_lowest=True, right=False
110
+ )
111
+ else:
112
+ # For non-integer type, fallback to 2 equal frequency bins
113
+ self.data[f"{num}_bin"] = pd.qcut(
114
+ self.data[num], q=2, duplicates="drop"
115
+ )
116
+ cramersV_var_list.append(f"{num}_bin")
117
+ else:
118
+ # Handle the case where there is only one unique value
119
+ print(f"Skipping {num} as it has only {unique_values} unique value(s).")
120
+
121
+ return cramersV_var_list
122
+
123
+ def plot_cramers_v(
124
+ self,
125
+ numerical_features: List[str],
126
+ excluded_vars: List[str],
127
+ filter_threshold: float,
128
+ n_bins: int = 5,
129
+ fig_width: int = 800,
130
+ fig_height: int = 800,
131
+ ) -> Tuple[pd.DataFrame, List[str]]:
132
+ """
133
+ Calculate and plot Cramer's V for multiple features using Plotly Express.
134
+
135
+ Parameters
136
+ ----------
137
+ numerical_features : List[str]
138
+ List of numerical feature names.
139
+ excluded_vars : List[str]
140
+ List of variables to exclude from the analysis.
141
+ n_bins : int, optional
142
+ Number of bins for continuous variables, by default 5.
143
+ fig_width : int, optional
144
+ Width of the figure in pixels, by default 800.
145
+ fig_height : int, optional
146
+ Height of the figure in pixels, by default 800.
147
+
148
+ Returns
149
+ -------
150
+ pd.DataFrame
151
+ DataFrame containing Cramer's V values.
152
+ """
153
+ cramersV_var_list = self._prepare_cramersV_features(numerical_features, n_bins)
154
+
155
+ # Exclude any specified variables
156
+ cramersV_var_list = [
157
+ var for var in cramersV_var_list if var not in excluded_vars
158
+ ]
159
+
160
+ rows = []
161
+ for cat in cramersV_var_list:
162
+ cramers = UnivariateAnalysis._get_cramers_v(
163
+ self.data, cat, self.target_column
164
+ )
165
+ rows.append(round(cramers, 2))
166
+
167
+ cramers_results = pd.DataFrame(
168
+ rows, columns=[self.target_column], index=cramersV_var_list
169
+ )
170
+
171
+ # Create a Plotly heatmap
172
+ fig = px.imshow(
173
+ cramers_results,
174
+ labels={"x": self.target_column, "y": "Features", "color": "Cramer's V"},
175
+ color_continuous_scale="Plasma",
176
+ text_auto=True,
177
+ aspect="auto",
178
+ width=fig_width,
179
+ height=fig_height,
180
+ )
181
+
182
+ fig.update_layout(
183
+ title="Cramer's V with Target Variable",
184
+ xaxis_title=self.target_column,
185
+ yaxis_title="Features",
186
+ )
187
+
188
+ fig.show()
189
+
190
+ filtered_vars = cramers_results[
191
+ cramers_results[self.target_column] > filter_threshold
192
+ ].index.tolist()
193
+
194
+ return cramers_results, filtered_vars
195
+
196
+ def _calculate_information_value(
197
+ self, feature_col: str, target_col: str, bins: int = 10, show_woe: bool = False
198
+ ) -> Union[Tuple[float, pd.DataFrame], float]:
199
+ """
200
+ Calculate Information Value for a single feature.
201
+
202
+ Parameters
203
+ ----------
204
+ feature_col : str
205
+ Name of the feature column.
206
+ target_col : str
207
+ Name of the target column.
208
+ bins : int, optional
209
+ Number of bins for continuous variables, by default 10.
210
+ show_woe : bool, optional
211
+ Boolean to show WOE values, by default False.
212
+
213
+ Returns
214
+ -------
215
+ Tuple[float, pd.DataFrame] or float
216
+ Information Value and optionally the WOE DataFrame.
217
+ """
218
+ df = pd.DataFrame(
219
+ {
220
+ feature_col: self.data[feature_col],
221
+ target_col: self.data[target_col].astype(float),
222
+ }
223
+ )
224
+
225
+ if df[feature_col].dtype in ["int64", "float64"]:
226
+ df["bins"] = pd.qcut(df[feature_col], q=bins, duplicates="drop")
227
+ else:
228
+ df["bins"] = df[feature_col]
229
+
230
+ total_good = df[target_col].sum()
231
+ total_bad = df[target_col].count() - total_good
232
+
233
+ # Explicitly setting observed=False to retain current behavior
234
+ grouped = df.groupby("bins", observed=False)[target_col].agg(["sum", "count"])
235
+ grouped["good"] = grouped["sum"]
236
+ grouped["bad"] = grouped["count"] - grouped["sum"]
237
+
238
+ # Add a small value to prevent division by zero
239
+ epsilon = 1e-10
240
+ grouped["good_pct"] = (grouped["good"] + epsilon) / (total_good + epsilon)
241
+ grouped["bad_pct"] = (grouped["bad"] + epsilon) / (total_bad + epsilon)
242
+
243
+ grouped["woe"] = np.log(grouped["good_pct"] / grouped["bad_pct"])
244
+ grouped["iv"] = (grouped["good_pct"] - grouped["bad_pct"]) * grouped["woe"]
245
+
246
+ iv = grouped["iv"].sum()
247
+
248
+ if show_woe:
249
+ return iv, grouped[["good", "bad", "good_pct", "bad_pct", "woe", "iv"]]
250
+ else:
251
+ return iv
252
+
253
+ def plot_information_value(
254
+ self,
255
+ numerical_features: List[str],
256
+ filter_threshold: float,
257
+ target_column: str,
258
+ bins: int = 10,
259
+ fig_width: int = 800,
260
+ fig_height: int = 800,
261
+ ) -> Tuple[pd.DataFrame, List[str]]:
262
+ """
263
+ Calculate and plot Information Value for multiple features using Plotly Express.
264
+
265
+ Parameters
266
+ ----------
267
+ numerical_features : List[str]
268
+ List of numerical feature names.
269
+ target_column : str
270
+ Name of the target column.
271
+ bins : int, optional
272
+ Number of bins for continuous variables, by default 10.
273
+ fig_width : int, optional
274
+ Width of the figure in pixels, by default 800.
275
+ fig_height : int, optional
276
+ Height of the figure in pixels, by default 800.
277
+
278
+ Returns
279
+ -------
280
+ pd.DataFrame
281
+ DataFrame containing Information Values.
282
+ """
283
+ iv_values = []
284
+ for feature in numerical_features:
285
+ iv = self._calculate_information_value(feature, target_column, bins)
286
+ iv_values.append(iv)
287
+
288
+ iv_df = pd.DataFrame(
289
+ iv_values, columns=[target_column], index=numerical_features
290
+ )
291
+
292
+ # Create the heatmap using Plotly Express
293
+ fig = px.imshow(
294
+ iv_df,
295
+ labels={"x": "Features", "y": target_column, "color": "Information Value"},
296
+ color_continuous_scale="Plasma",
297
+ text_auto=True,
298
+ aspect="auto",
299
+ width=fig_width,
300
+ height=fig_height,
301
+ )
302
+
303
+ # Update layout
304
+ fig.update_layout(
305
+ title="Information Value with Target Variable",
306
+ xaxis_title="Features",
307
+ yaxis_title=target_column,
308
+ )
309
+
310
+ # Display the figure
311
+ fig.show()
312
+
313
+ filtered_vars = iv_df[filter_threshold < iv_df[target_column]].index.tolist()
314
+
315
+ return iv_df, filtered_vars
316
+
317
+ def plot_distributions(
318
+ self,
319
+ numerical_columns: Optional[List[str]] = None,
320
+ grouped: bool = False,
321
+ fig_width: int = 1100,
322
+ fig_height: int = 600,
323
+ ) -> None:
324
+ """
325
+ Plot the distribution of numerical columns using Plotly Express.
326
+
327
+ Parameters
328
+ ----------
329
+ numerical_columns : Optional[List[str]], optional
330
+ List of numerical columns to plot. If None, all numerical features will be available for selection.
331
+ grouped : bool, optional
332
+ If True, plot distributions grouped by the target variable, by default False.
333
+ fig_width : int, optional
334
+ Width of the figure in pixels, by default 800.
335
+ fig_height : int, optional
336
+ Height of the figure in pixels, by default 500.
337
+ """
338
+ if numerical_columns is None:
339
+ numerical_columns = self.numerical_features
340
+
341
+ # Create the initial figure
342
+ fig = go.Figure()
343
+
344
+ # Add traces for each numerical column (initially hidden)
345
+ for col in numerical_columns:
346
+ if grouped:
347
+ for target_value in self.data[self.target_column].unique():
348
+ data = self.data[self.data[self.target_column] == target_value]
349
+ fig.add_trace(
350
+ go.Histogram(
351
+ x=data[col],
352
+ name=f"{col} ({self.target_column}={target_value})",
353
+ visible=False,
354
+ )
355
+ )
356
+ else:
357
+ fig.add_trace(go.Histogram(x=self.data[col], name=col, visible=False))
358
+
359
+ # Create buttons for dropdown
360
+ buttons = []
361
+ for i, col in enumerate(numerical_columns):
362
+ button = {
363
+ "method": "update",
364
+ "label": col,
365
+ "args": [
366
+ {"visible": [False] * len(fig.data)}, # type: ignore
367
+ {"title": f"Distribution of {col}"},
368
+ ],
369
+ }
370
+ if grouped:
371
+ for j in range(len(self.data[self.target_column].unique())):
372
+ button["args"][0]["visible"][
373
+ i * len(self.data[self.target_column].unique()) + j
374
+ ] = True
375
+ else:
376
+ button["args"][0]["visible"][i] = True
377
+ buttons.append(button)
378
+
379
+ # Update layout
380
+ fig.update_layout(
381
+ updatemenus=[
382
+ {
383
+ "active": 0,
384
+ "buttons": buttons,
385
+ "direction": "down",
386
+ "pad": {"r": 10, "t": 10},
387
+ "showactive": True,
388
+ "x": 0.1,
389
+ "xanchor": "left",
390
+ "y": 1.15,
391
+ "yanchor": "top",
392
+ },
393
+ ],
394
+ annotations=[
395
+ {
396
+ "text": "Select Feature:",
397
+ "showarrow": False,
398
+ "x": 0,
399
+ "y": 1.085,
400
+ "yref": "paper",
401
+ "align": "left",
402
+ }
403
+ ],
404
+ barmode="overlay" if grouped else "stack",
405
+ width=fig_width,
406
+ height=fig_height,
407
+ )
408
+
409
+ # Make the first trace visible
410
+ if len(fig.data) > 0: # type: ignore
411
+ fig.data[0].visible = True # type: ignore
412
+
413
+ fig.show()
414
+
415
+ def _make_partitions(self, number_of_partitions) -> pd.DataFrame:
416
+ """
417
+ Partition the numerical features into 4 bins.
418
+
419
+ Returns
420
+ -------
421
+ pd.DataFrame
422
+ DataFrame with partitioned numerical features.
423
+ """
424
+ partitioned = self.data.copy()
425
+ for num in self.numerical_features:
426
+ partitioned[num + "_bin"] = pd.qcut(
427
+ x=partitioned[num],
428
+ q=number_of_partitions,
429
+ labels=False,
430
+ duplicates="drop",
431
+ ).astype("category")
432
+ return partitioned
433
+
434
+ def get_num_feature_repartition(
435
+ self, time_col: str, number_of_partitions: int = 4, freq: str = "1ME"
436
+ ) -> Optional[pd.DataFrame]:
437
+ """
438
+ Plot the risk class repartition through time and return a table of the risk class repartition through time.
439
+
440
+ Parameters
441
+ ----------
442
+ time_col : str
443
+ Name of the column that contains the observation date.
444
+ freq : str, optional
445
+ The frequency at which the risk class is aggregated ('D', 'M', or 'Y'), by default '1ME'.
446
+
447
+ Returns
448
+ -------
449
+ Optional[pd.DataFrame]
450
+ DataFrame containing the risk class repartition through time.
451
+ """
452
+ partitioned = self._make_partitions(number_of_partitions)
453
+
454
+ # Drop rows where the time column is NaT
455
+ if partitioned[time_col].isnull().any():
456
+ print(
457
+ f"There are missing entries in the {time_col} column, dropping them for the functionality; however, try to mitigate the problem."
458
+ )
459
+ partitioned = partitioned.dropna(subset=[time_col])
460
+
461
+ row_nb = math.ceil(len(self.numerical_features) / 2)
462
+ fig, ax = plt.subplots(row_nb, 2, figsize=(20, number_of_partitions * row_nb))
463
+ i, j = 0, 0
464
+
465
+ for num in self.numerical_features:
466
+ axes = ax[j] if row_nb == 1 else ax[i, j]
467
+
468
+ # Group by and count
469
+ draw = (
470
+ partitioned.groupby(
471
+ [num + "_bin", pd.Grouper(key=time_col, freq=freq)], observed=False
472
+ )[self.target_column]
473
+ .count()
474
+ .reset_index()
475
+ )
476
+
477
+ # Create 'freq' column with aligned indices
478
+ draw["freq"] = draw.groupby([time_col])[self.target_column].transform(
479
+ lambda x: x / x.sum()
480
+ )
481
+
482
+ # Ensure that the indices align
483
+ draw_pivot = pd.pivot_table(
484
+ draw,
485
+ values="freq",
486
+ index=time_col,
487
+ columns=num + "_bin",
488
+ observed=False,
489
+ )
490
+
491
+ draw_pivot.plot.bar(stacked=True, ax=axes)
492
+
493
+ if j == 0:
494
+ j += 1
495
+ else:
496
+ i += 1
497
+ j = 0
498
+
499
+ plt.show()
500
+ return None
501
+
502
+ def _prepare_data_for_psi(
503
+ self,
504
+ number_of_partitions: int,
505
+ time_col: str,
506
+ start_date: Union[str, datetime, date],
507
+ end_date: Union[str, datetime, date],
508
+ ):
509
+ """
510
+ Prepare data for PSI calculation by filtering based on the time column.
511
+
512
+ Parameters
513
+ ----------
514
+ time_col : str
515
+ Name of the column that contains the observation date.
516
+ start_date : Union[str, datetime, date]
517
+ Start date for the PSI calculation.
518
+ end_date : Union[str, datetime, date]
519
+ End date for the PSI calculation.
520
+ """
521
+ self.data = self._make_partitions(number_of_partitions)
522
+
523
+ # Convert string dates to datetime if necessary
524
+ if isinstance(start_date, str):
525
+ start_date = pd.to_datetime(start_date)
526
+ if isinstance(end_date, str):
527
+ end_date = pd.to_datetime(end_date)
528
+
529
+ # Ensure time_col is in datetime format
530
+ self.data[time_col] = pd.to_datetime(self.data[time_col])
531
+
532
+ _filter_date = (self.data[time_col] >= start_date) & (
533
+ self.data[time_col] < end_date
534
+ )
535
+ self.data = self.data.loc[_filter_date]
536
+ self.data.reset_index(drop=True, inplace=True)
537
+
538
+ def _calculate_psi_single(
539
+ self,
540
+ time_col: str,
541
+ time_expected: Union[str, datetime, date],
542
+ col: str,
543
+ buckets: int,
544
+ bucket_type: Literal["quantile", "fixed_width"] = "quantile",
545
+ ):
546
+ """
547
+ Calculate the Population Stability Index (PSI) for a single feature.
548
+
549
+ Parameters
550
+ ----------
551
+ time_col : str
552
+ Name of the column that contains the observation date.
553
+ time_expected : Union[str, datetime, date]
554
+ The expected date for the PSI calculation.
555
+ col : str
556
+ Name of the column to calculate the PSI.
557
+ buckets : int
558
+ Number of buckets for binning.
559
+ bucket_type : Literal["quantile", "fixed_width"]
560
+ The type of binning to use.
561
+
562
+ Returns
563
+ -------
564
+ float
565
+ Population Stability Index for the feature.
566
+ """
567
+ if isinstance(time_expected, str):
568
+ time_expected = pd.to_datetime(time_expected)
569
+
570
+ _filter_expected = self.data[time_col] < time_expected
571
+ expected = self.data.loc[_filter_expected, col]
572
+ observed = self.data.loc[~_filter_expected, col]
573
+
574
+ bins = self._create_bins(self.data[col], buckets, bucket_type)
575
+
576
+ expected_counts, _ = np.histogram(expected, bins=bins)
577
+ observed_counts, _ = np.histogram(observed, bins=bins)
578
+
579
+ expected_percents = expected_counts / len(expected)
580
+ observed_percents = observed_counts / len(observed)
581
+
582
+ # Add a small value to prevent division by zero
583
+ epsilon = 1e-10
584
+ psi = np.sum(
585
+ (observed_percents - expected_percents)
586
+ * np.log((observed_percents + epsilon) / (expected_percents + epsilon))
587
+ )
588
+
589
+ return psi
590
+
591
+ def _create_bins(
592
+ self,
593
+ series: pd.Series,
594
+ buckets: int,
595
+ bucket_type: Literal["quantile", "fixed_width"],
596
+ ) -> np.ndarray:
597
+ if bucket_type == "quantile":
598
+ return pd.qcut(series, q=buckets, duplicates="drop", retbins=True)[1]
599
+ else: # fixed_width
600
+ return pd.cut(series, bins=buckets, retbins=True)[1]
601
+
602
+ def _create_buckets(self, series: pd.Series, bins: np.ndarray) -> pd.Series:
603
+ return pd.cut(
604
+ series,
605
+ bins=bins, # type: ignore
606
+ labels=range(len(bins) - 1),
607
+ include_lowest=True,
608
+ )
609
+
610
+ def _calculate_psi(
611
+ self,
612
+ expected: pd.Series,
613
+ observed: pd.Series,
614
+ bins: np.ndarray,
615
+ epsilon: float = 1e-5,
616
+ ) -> float:
617
+ expected_bucket = self._create_buckets(expected, bins)
618
+ observed_bucket = self._create_buckets(observed, bins)
619
+
620
+ all_buckets = range(len(bins) - 1)
621
+ expected_dist = expected_bucket.value_counts(normalize=True).reindex(
622
+ all_buckets, fill_value=epsilon
623
+ )
624
+ observed_dist = observed_bucket.value_counts(normalize=True).reindex(
625
+ all_buckets, fill_value=epsilon
626
+ )
627
+
628
+ # Avoid division by zero and log(0)
629
+ psi_values = (observed_dist - expected_dist) * np.log(
630
+ np.maximum(observed_dist, epsilon) / np.maximum(expected_dist, epsilon)
631
+ )
632
+ return psi_values.sum()
633
+
634
+ def calculate_psi(
635
+ self,
636
+ train: pd.DataFrame,
637
+ test: Optional[pd.DataFrame],
638
+ time_col: str,
639
+ time_expected: Union[str, datetime, date],
640
+ buckets: int = 10,
641
+ bucket_type: Literal["quantile", "fixed_width"] = "quantile",
642
+ epsilon: float = 1e-5,
643
+ ) -> pd.DataFrame:
644
+ psi_values = {}
645
+ for feature in self.numerical_features:
646
+ if test is None:
647
+ expected = train[train[time_col] < time_expected][feature]
648
+ observed = train[train[time_col] >= time_expected][feature]
649
+ else:
650
+ expected = train[feature]
651
+ observed = test[feature]
652
+
653
+ combined = pd.concat([expected, observed])
654
+ bins = self._create_bins(combined, buckets, bucket_type)
655
+ psi = self._calculate_psi(expected, observed, bins, epsilon)
656
+ psi_values[feature] = psi
657
+
658
+ return pd.DataFrame.from_dict(psi_values, orient="index", columns=["PSI"])
659
+
660
+ def plot_psi(
661
+ self, psi_df: pd.DataFrame, fig_width: int = 800, fig_height: int = 600
662
+ ) -> go.Figure:
663
+ fig = go.Figure()
664
+
665
+ fig.add_trace(
666
+ go.Bar(
667
+ x=psi_df.index,
668
+ y=psi_df["PSI"],
669
+ text=psi_df["PSI"].round(4),
670
+ textposition="auto",
671
+ )
672
+ )
673
+
674
+ fig.update_layout(
675
+ title="Population Stability Index (PSI) for Numerical Features",
676
+ xaxis_title="Features",
677
+ yaxis_title="PSI Value",
678
+ width=fig_width,
679
+ height=fig_height,
680
+ )
681
+
682
+ fig.add_hline(
683
+ y=0.1,
684
+ line_dash="dash",
685
+ line_color="orange",
686
+ annotation_text="Warning",
687
+ annotation_position="bottom right",
688
+ )
689
+ fig.add_hline(
690
+ y=0.25,
691
+ line_dash="dash",
692
+ line_color="red",
693
+ annotation_text="Critical",
694
+ annotation_position="bottom right",
695
+ )
696
+
697
+ return fig
698
+
699
+ def compute_psi(
700
+ self,
701
+ time_col: str,
702
+ time_expected: Union[str, datetime, date],
703
+ start_date: Union[str, datetime, date],
704
+ end_date: Union[str, datetime, date],
705
+ test_data: Optional[pd.DataFrame] = None,
706
+ buckets: int = 10,
707
+ bucket_type: Literal["quantile", "fixed_width"] = "quantile",
708
+ test_mode: bool = False,
709
+ ) -> pd.DataFrame:
710
+ if isinstance(start_date, str):
711
+ start_date = pd.to_datetime(start_date)
712
+ if isinstance(end_date, str):
713
+ end_date = pd.to_datetime(end_date)
714
+ if isinstance(time_expected, str):
715
+ time_expected = pd.to_datetime(time_expected)
716
+
717
+ self.data[time_col] = pd.to_datetime(self.data[time_col])
718
+ filtered_data = self.data[
719
+ (self.data[time_col] >= start_date) & (self.data[time_col] < end_date)
720
+ ]
721
+
722
+ if test_data is not None:
723
+ test_data[time_col] = pd.to_datetime(test_data[time_col])
724
+ test_filtered = test_data[
725
+ (test_data[time_col] >= start_date) & (test_data[time_col] < end_date)
726
+ ]
727
+ else:
728
+ test_filtered = None
729
+
730
+ psi_df = self.calculate_psi(
731
+ filtered_data, test_filtered, time_col, time_expected, buckets, bucket_type
732
+ )
733
+
734
+ fig = self.plot_psi(psi_df)
735
+ if not test_mode:
736
+ fig.show()
737
+
738
+ print("\nPopulation Stability Index (PSI) for Numerical Features:")
739
+ print("-" * 80)
740
+ print(f"{'Feature':<60} {'PSI':>15}")
741
+ print("-" * 80)
742
+ for feature, psi in psi_df.iterrows():
743
+ print(f"{feature:<60} {psi['PSI']:>15.6f}")
744
+ print("-" * 80)
745
+ return psi_df
746
+
747
+ def _calculate_psi_all(
748
+ self,
749
+ time_col: str,
750
+ time_expected: Union[str, datetime, date],
751
+ buckets: int,
752
+ bucket_type: Literal["quantile", "fixed_width"],
753
+ ) -> pd.Series:
754
+ """
755
+ Calculate the Population Stability Index (PSI) for all numerical features.
756
+
757
+ Parameters
758
+ ----------
759
+ time_col : str
760
+ Name of the column that contains the observation date.
761
+ time_expected : Union[str, datetime, date]
762
+ The expected date for the PSI calculation.
763
+ buckets : int
764
+ Number of buckets for binning.
765
+ bucket_type : Literal["quantile", "fixed_width"]
766
+ The type of binning to use.
767
+
768
+ Returns
769
+ -------
770
+ pd.Series
771
+ Series containing PSI values for all numerical features.
772
+ """
773
+ dict_psi = {}
774
+ for num in self.numerical_features:
775
+ dict_psi[num] = self._calculate_psi_single(
776
+ time_col, time_expected, num, buckets, bucket_type
777
+ )
778
+ return pd.Series(dict_psi)