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.
- classifier_toolkit/eda/__init__.py +19 -0
- classifier_toolkit/eda/bivariate_analysis.py +413 -0
- classifier_toolkit/eda/eda_toolkit.py +549 -0
- classifier_toolkit/eda/feature_engineering.py +1310 -0
- classifier_toolkit/eda/first_glance.py +253 -0
- classifier_toolkit/eda/univariate_analysis.py +778 -0
- classifier_toolkit/eda/visualizations.py +1248 -0
- classifier_toolkit/eda/warnings/__init__.py +4 -0
- classifier_toolkit/eda/warnings/automated_warnings.py +20 -0
- classifier_toolkit/eda/warnings/default_warnings.py +286 -0
- classifier_toolkit/feature_selection/__init__.py +33 -0
- classifier_toolkit/feature_selection/base.py +182 -0
- classifier_toolkit/feature_selection/embedded_methods/__init__.py +7 -0
- classifier_toolkit/feature_selection/embedded_methods/elastic_net.py +178 -0
- classifier_toolkit/feature_selection/meta_selector.py +329 -0
- classifier_toolkit/feature_selection/utils/__init__.py +17 -0
- classifier_toolkit/feature_selection/utils/plottings.py +65 -0
- classifier_toolkit/feature_selection/utils/scoring.py +86 -0
- classifier_toolkit/feature_selection/wrapper_methods/__init__.py +11 -0
- classifier_toolkit/feature_selection/wrapper_methods/rfe.py +345 -0
- classifier_toolkit/feature_selection/wrapper_methods/sequential_selection.py +566 -0
- classifier_toolkit/model_fitting/__init__.py +0 -0
- classifier_toolkit/model_fitting/model_search.py +3 -0
- classifier_toolkit-0.1.0.dist-info/METADATA +99 -0
- classifier_toolkit-0.1.0.dist-info/RECORD +26 -0
- 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)
|