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,1248 @@
1
+ import colorsys
2
+ import math
3
+ from typing import List, Literal, Optional, Tuple
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
+ import statsmodels.api as sm
11
+ from pandas.core.indexes.base import Index as PandasIndex
12
+ from plotly.subplots import make_subplots
13
+ from sklearn.linear_model import LinearRegression
14
+ from sklearn.preprocessing import MinMaxScaler
15
+ from statsmodels.stats.outliers_influence import variance_inflation_factor
16
+ from tqdm import tqdm
17
+
18
+
19
+ class Visualizations:
20
+ PHI = (1 + 5**0.5) / 2
21
+
22
+ def __init__(
23
+ self,
24
+ dataframe: pd.DataFrame,
25
+ target_column: str,
26
+ numerical_columns: List[str],
27
+ categorical_columns: List[str],
28
+ ) -> None:
29
+ """
30
+ Initialize the Visualizations class with data and configuration.
31
+
32
+ Parameters
33
+ ----------
34
+ dataframe : pd.DataFrame
35
+ The data to be analyzed.
36
+ target_column : str
37
+ The target column for analysis.
38
+ numerical_columns : List[str]
39
+ List of numerical columns.
40
+ categorical_columns : List[str]
41
+ List of categorical columns.
42
+ """
43
+ self.data = dataframe
44
+ self.target_column = target_column
45
+ self.numerical_columns = numerical_columns
46
+ self.categorical_columns = categorical_columns
47
+
48
+ def plot_target_dist_by_category(self, column: str) -> None:
49
+ """
50
+ Plot the distribution of a categorical column with respect to the target column.
51
+
52
+ Parameters
53
+ ----------
54
+ column : str
55
+ Name of the column to plot.
56
+ """
57
+ aggregated = self.data[column].value_counts().reset_index()
58
+ aggregated.columns = PandasIndex([column, "total_count"])
59
+
60
+ positive_cases = (
61
+ self.data[self.data[self.target_column] == 1][column]
62
+ .value_counts()
63
+ .reset_index()
64
+ )
65
+ positive_cases.columns = PandasIndex([column, "positive_count"])
66
+
67
+ merged_data = pd.merge(aggregated, positive_cases, on=column, how="left")
68
+ merged_data["positive_count"] = merged_data["positive_count"].fillna(0)
69
+ merged_data["positive_info"] = (
70
+ merged_data["positive_count"].astype(int).astype(str)
71
+ + f" {self.target_column.capitalize()} Cases"
72
+ )
73
+
74
+ figure = px.pie(
75
+ merged_data,
76
+ values="total_count",
77
+ names=column,
78
+ hole=0.5,
79
+ title=f"Distribution of {column} with {self.target_column.capitalize()} Events",
80
+ hover_data=["positive_info"],
81
+ )
82
+
83
+ figure.show()
84
+
85
+ def plot_target_balance(self) -> None:
86
+ """
87
+ Plot the distribution of the target column.
88
+ """
89
+ value_counts = self.data[self.target_column].value_counts().reset_index()
90
+ value_counts.columns = PandasIndex(["Class", "Count"])
91
+
92
+ total = value_counts["Count"].sum()
93
+ value_counts["Percentage"] = value_counts["Count"] / total * 100
94
+
95
+ fig = px.pie(
96
+ value_counts,
97
+ values="Count",
98
+ names="Class",
99
+ title=f"Distribution of {self.target_column}",
100
+ labels={"Class": self.target_column, "Count": "Number of Instances"},
101
+ hover_data=["Percentage"],
102
+ hole=0,
103
+ )
104
+
105
+ fig.update_traces(
106
+ textposition="inside",
107
+ textinfo="percent+label",
108
+ insidetextorientation="radial",
109
+ )
110
+
111
+ fig.update_layout(
112
+ height=500,
113
+ legend_title=self.target_column,
114
+ legend={
115
+ "orientation": "h",
116
+ "yanchor": "bottom",
117
+ "y": 1.02,
118
+ "xanchor": "right",
119
+ "x": 1,
120
+ },
121
+ )
122
+ fig.show()
123
+
124
+ def plot_target_evolution(self, time_column: str, amount_column: str) -> None:
125
+ """
126
+ Plot the evolution of the target column over time.
127
+
128
+ Parameters
129
+ ----------
130
+ time_column : str
131
+ Name of the time column.
132
+ amount_column : str
133
+ Name of the amount column.
134
+ """
135
+ # Ensure the time_column exists in the DataFrame
136
+ if time_column not in self.data.columns:
137
+ raise ValueError(f"Column '{time_column}' does not exist in the DataFrame.")
138
+
139
+ # Try to convert the time_column to datetime using the correct format
140
+ try:
141
+ self.data[time_column] = pd.to_datetime(
142
+ self.data[time_column], format="%Y-%m-%d %H:%M:%S.%f%z"
143
+ )
144
+ except ValueError:
145
+ # If it fails, coerce errors to NaT and drop rows with NaT
146
+ self.data[time_column] = pd.to_datetime(
147
+ self.data[time_column], errors="coerce"
148
+ )
149
+ self.data.dropna(subset=[time_column], inplace=True)
150
+
151
+ # Ensure the target column is boolean
152
+ self.data[self.target_column] = self.data[self.target_column].astype(bool)
153
+
154
+ # Group by time and calculate the fraud rate and observation count
155
+ freq = "MS"
156
+ data = self.data.copy()
157
+
158
+ # 1. Population Risk Rate
159
+ risk_rate_df = data.groupby(
160
+ [pd.Grouper(key=time_column, freq=freq), self.target_column]
161
+ ).size()
162
+ assert isinstance(risk_rate_df, pd.Series)
163
+ risk_rate = risk_rate_df.reset_index(name="count")
164
+ risk_rate["rate"] = risk_rate.groupby(time_column)["count"].transform(
165
+ lambda x: x / x.sum()
166
+ )
167
+ risk_rate_fraud = risk_rate[risk_rate[self.target_column] == True]
168
+
169
+ # 2. Population's Observation Number
170
+ obs_count = (
171
+ data.groupby([pd.Grouper(key=time_column, freq=freq)])[self.target_column]
172
+ .count()
173
+ .reset_index(name="count")
174
+ )
175
+
176
+ # 3. Population Risk Rate in EUR
177
+ fraud_amount = data.groupby(
178
+ [self.target_column, pd.Grouper(key=time_column, freq=freq)]
179
+ )[amount_column].sum()
180
+ fraud_amount = (
181
+ fraud_amount / fraud_amount.groupby(level=1).sum()
182
+ ).reset_index()
183
+ fraud_amount_fraud = fraud_amount[fraud_amount[self.target_column] == True]
184
+
185
+ # 4. Population's Observation in EUR
186
+ amount_sum = (
187
+ data.groupby([pd.Grouper(key=time_column, freq=freq)])[amount_column]
188
+ .sum()
189
+ .reset_index()
190
+ )
191
+
192
+ # Create subplots
193
+ fig = make_subplots(
194
+ rows=2,
195
+ cols=2,
196
+ subplot_titles=(
197
+ "Population Risk Rate",
198
+ "Population's Observation Number",
199
+ "Population Risk Rate (in EUR)",
200
+ "Population's Observation (in EUR)",
201
+ ),
202
+ )
203
+
204
+ # Plot risk rate
205
+ fig.add_trace(
206
+ go.Scatter(
207
+ x=risk_rate_fraud[time_column],
208
+ y=risk_rate_fraud["rate"],
209
+ mode="lines",
210
+ name="Population Fraud Rate",
211
+ ),
212
+ row=1,
213
+ col=1,
214
+ )
215
+
216
+ # Plot observation count
217
+ fig.add_trace(
218
+ go.Bar(
219
+ x=obs_count[time_column],
220
+ y=obs_count["count"],
221
+ name="Population Observation Count",
222
+ ),
223
+ row=1,
224
+ col=2,
225
+ )
226
+
227
+ # Plot risk rate in EUR
228
+ fig.add_trace(
229
+ go.Scatter(
230
+ x=fraud_amount_fraud[time_column],
231
+ y=fraud_amount_fraud[amount_column],
232
+ mode="lines",
233
+ name="Population Fraud Rate (in EUR)",
234
+ ),
235
+ row=2,
236
+ col=1,
237
+ )
238
+
239
+ # Plot observation amount in EUR
240
+ fig.add_trace(
241
+ go.Bar(
242
+ x=amount_sum[time_column],
243
+ y=amount_sum[amount_column],
244
+ name="Population Observation (in EUR)",
245
+ ),
246
+ row=2,
247
+ col=2,
248
+ )
249
+
250
+ # Update layout
251
+ fig.update_layout(
252
+ title_text="Target Evolution Over Time", height=800, showlegend=True
253
+ )
254
+
255
+ fig.update_xaxes(title_text="Time", row=1, col=1)
256
+ fig.update_yaxes(title_text="Risk Rate", row=1, col=1)
257
+ fig.update_xaxes(title_text="Time", row=1, col=2)
258
+ fig.update_yaxes(title_text="Nb obs", row=1, col=2)
259
+ fig.update_xaxes(title_text="Time", row=2, col=1)
260
+ fig.update_yaxes(title_text="Risk Rate (in EUR)", row=2, col=1)
261
+ fig.update_xaxes(title_text="Time", row=2, col=2)
262
+ fig.update_yaxes(title_text="Sum of EUR", row=2, col=2)
263
+
264
+ fig.show()
265
+
266
+ def _check_cardinality(self, threshold: int = 10) -> Tuple[List[str], List[str]]:
267
+ """
268
+ Check the cardinality of categorical columns.
269
+
270
+ Parameters
271
+ ----------
272
+ threshold : int, optional
273
+ Threshold to determine high and low cardinality, by default 10.
274
+
275
+ Returns
276
+ -------
277
+ Tuple[List[str], List[str]]
278
+ Lists of high and low cardinality columns.
279
+ """
280
+ high_car = [
281
+ cat
282
+ for cat in self.categorical_columns
283
+ if len(self.data[cat].value_counts()) >= threshold
284
+ ]
285
+ low_car = [
286
+ cat
287
+ for cat in self.categorical_columns
288
+ if len(self.data[cat].value_counts()) < threshold
289
+ ]
290
+
291
+ return high_car, low_car
292
+
293
+ def visualize_high_cardinality(
294
+ self,
295
+ excluded_vars: List[str],
296
+ top_n: int = 10,
297
+ id_column: Optional[str] = None,
298
+ ) -> None:
299
+ """
300
+ Visualize high cardinality features.
301
+
302
+ Parameters
303
+ ----------
304
+ excluded_vars : List[str]
305
+ List of columns to exclude.
306
+ top_n : int, optional
307
+ Number of top categories to display, by default 10.
308
+ id_column : Optional[str], optional
309
+ Name of the id column, by default None.
310
+ """
311
+ high_car, _ = self._check_cardinality()
312
+
313
+ if id_column:
314
+ excluded_vars.append(id_column)
315
+
316
+ # Exclude specified variables from the high cardinality list
317
+ high_car = [cat for cat in high_car if cat not in excluded_vars]
318
+
319
+ if not high_car:
320
+ print("No high cardinality features found.")
321
+ return
322
+
323
+ fig = go.Figure()
324
+ # Create traces for each high cardinality column but initially hide them
325
+ for _i, cat in enumerate(high_car):
326
+ value_counts = self.data[cat].value_counts()
327
+
328
+ # Keep top N categories and group others
329
+ top_categories = value_counts.nlargest(top_n - 1).index
330
+ draw = self.data[cat].apply(
331
+ lambda x: x if x in top_categories else "Other" # noqa: B023
332
+ )
333
+
334
+ draw = draw.value_counts().sort_values(ascending=True)
335
+
336
+ fig.add_trace(
337
+ go.Bar(
338
+ y=draw.index,
339
+ x=draw.values,
340
+ orientation="h",
341
+ name=cat,
342
+ text=draw.values,
343
+ textposition="auto",
344
+ marker={"color": draw.values, "colorscale": "Plasma"},
345
+ visible=False, # Initially hide all traces
346
+ )
347
+ )
348
+
349
+ # Add dropdown menu to toggle between traces
350
+ buttons = []
351
+ for i, cat in enumerate(high_car):
352
+ buttons.append(
353
+ {
354
+ "method": "update",
355
+ "label": cat,
356
+ "args": [
357
+ {"visible": [j == i for j in range(len(high_car))]},
358
+ {
359
+ "title": f"Distribution of {cat}",
360
+ "xaxis": {"title": "Count"},
361
+ "yaxis": {"title": "Category"},
362
+ },
363
+ ],
364
+ }
365
+ )
366
+
367
+ fig.update_layout(
368
+ updatemenus=[
369
+ {
370
+ "active": 0,
371
+ "buttons": buttons,
372
+ "direction": "down",
373
+ "showactive": True,
374
+ }
375
+ ]
376
+ )
377
+ # if I assert, this create a bug in the example for no reason
378
+ if len(fig.data) > 0: # type: ignore
379
+ fig.data[0].visible = True # type: ignore
380
+ fig.update_layout(title=f"Distribution of {high_car[0]}", showlegend=False)
381
+
382
+ fig.show()
383
+
384
+ def visualize_low_cardinality(self) -> None:
385
+ """
386
+ Visualize low cardinality features.
387
+ """
388
+ _, low_car = self._check_cardinality()
389
+
390
+ if not low_car:
391
+ print("No low cardinality features found.")
392
+ return
393
+
394
+ # Calculate number of rows and columns
395
+ n_cols = 2
396
+ n_rows = math.ceil(len(low_car) / n_cols)
397
+
398
+ # Create subplots
399
+ fig = make_subplots(
400
+ rows=n_rows,
401
+ cols=n_cols,
402
+ subplot_titles=low_car,
403
+ vertical_spacing=0.1,
404
+ horizontal_spacing=0.05,
405
+ )
406
+
407
+ # Plot each low cardinality feature
408
+ for idx, cat in enumerate(low_car):
409
+ row = idx // n_cols + 1
410
+ col = idx % n_cols + 1
411
+
412
+ value_counts = self.data[cat].value_counts().sort_values(ascending=True)
413
+
414
+ fig.add_trace(
415
+ go.Bar(
416
+ x=value_counts.index,
417
+ y=value_counts.values,
418
+ name=cat,
419
+ text=value_counts.values,
420
+ textposition="auto",
421
+ ),
422
+ row=row,
423
+ col=col,
424
+ )
425
+
426
+ fig.update_xaxes(title_text=cat, row=row, col=col)
427
+ fig.update_yaxes(title_text="Count", row=row, col=col)
428
+
429
+ # Update layout
430
+ fig.update_layout(
431
+ title_text="Distribution of Low Cardinality Categorical Features",
432
+ showlegend=False,
433
+ height=300 * n_rows,
434
+ width=1000,
435
+ )
436
+
437
+ fig.show()
438
+
439
+ def calculate_vif(self, threshold: int = 5, show_plot: bool = True) -> pd.DataFrame:
440
+ """
441
+ Calculate the Variance Inflation Factor (VIF) for numerical features.
442
+
443
+ Parameters
444
+ ----------
445
+ threshold : int, optional
446
+ Threshold value to determine multicollinearity, by default 5.
447
+ show_plot : bool, optional
448
+ If True, the plot will be displayed, by default True.
449
+
450
+ Returns
451
+ -------
452
+ pd.DataFrame
453
+ DataFrame containing VIF values for features.
454
+ """
455
+ # Select numerical data from the DataFrame, excluding the target column
456
+ numerical_columns = [
457
+ col for col in self.numerical_columns if col != self.target_column
458
+ ]
459
+ numerical_data = self.data[numerical_columns].copy()
460
+
461
+ # Convert boolean columns to integers
462
+ for column in numerical_data.columns:
463
+ if numerical_data[column].dtype == "bool":
464
+ numerical_data[column] = numerical_data[column].astype(int)
465
+
466
+ # Check for missing values
467
+ missing_values = numerical_data.isnull().sum()
468
+ if missing_values.sum() > 0:
469
+ print("\nWARNING: Missing values detected in the following columns:")
470
+ print(missing_values[missing_values > 0])
471
+ print("\nImputing missing values with mean for VIF calculation.")
472
+ print(
473
+ "It is strongly recommended to properly handle missing values in your dataset."
474
+ )
475
+
476
+ # Impute missing values with mean
477
+ numerical_data = numerical_data.fillna(numerical_data.mean())
478
+
479
+ # Add a constant to the model (intercept)
480
+ numerical_data = sm.add_constant(numerical_data)
481
+ assert isinstance(numerical_data, pd.DataFrame)
482
+
483
+ # Calculate VIF for each feature
484
+ vif_data = pd.DataFrame()
485
+ vif_data["Feature"] = numerical_data.columns
486
+ vif_data["VIF"] = [
487
+ variance_inflation_factor(numerical_data.values, i)
488
+ for i in range(numerical_data.shape[1])
489
+ ]
490
+
491
+ # Handle infinite VIF values
492
+ vif_data["VIF"] = np.where(
493
+ np.isinf(vif_data["VIF"]), np.finfo(np.float64).max, vif_data["VIF"]
494
+ )
495
+
496
+ # Drop the constant column from the dataframe
497
+ vif_data = vif_data[vif_data["Feature"] != "const"]
498
+
499
+ # Sort VIF values in descending order
500
+ vif_data = vif_data.sort_values("VIF", ascending=False).reset_index(drop=True)
501
+
502
+ # Assign colors based on the threshold
503
+ vif_data["Color"] = [
504
+ "red" if vif > threshold else "green" for vif in vif_data["VIF"]
505
+ ]
506
+
507
+ # Create the bar plot
508
+ fig = go.Figure()
509
+ fig.add_trace(
510
+ go.Bar(
511
+ x=vif_data["Feature"],
512
+ y=vif_data["VIF"],
513
+ marker_color=vif_data["Color"],
514
+ text=vif_data["VIF"].round(2),
515
+ textposition="outside",
516
+ )
517
+ )
518
+
519
+ # Update layout
520
+ fig.update_layout(
521
+ title="Variance Inflation Factor (VIF) for Features",
522
+ xaxis_title="Features",
523
+ yaxis_title="VIF",
524
+ yaxis={
525
+ "range": [0, max(vif_data["VIF"]) * 1.1]
526
+ }, # Set y-axis range with some padding
527
+ showlegend=False,
528
+ )
529
+
530
+ # Add a horizontal line at the threshold value
531
+ fig.add_shape(
532
+ type="line",
533
+ x0=-0.5,
534
+ y0=threshold,
535
+ x1=len(vif_data["Feature"]) - 0.5,
536
+ y1=threshold,
537
+ line={"color": "black", "width": 2, "dash": "dash"},
538
+ )
539
+
540
+ if show_plot:
541
+ fig.show()
542
+
543
+ return vif_data
544
+
545
+ def _bin_and_target_freq(
546
+ self, variable_name: str, n_bins: int = 10, scale: bool = False
547
+ ) -> pd.DataFrame:
548
+ """
549
+ Bin a numerical variable and calculate target frequency.
550
+
551
+ Parameters
552
+ ----------
553
+ variable_name : str
554
+ Name of the numerical variable.
555
+ n_bins : int, optional
556
+ Number of bins to use, by default 10.
557
+ scale : bool, optional
558
+ Whether to scale the variable before binning, by default False.
559
+
560
+ Returns
561
+ -------
562
+ pd.DataFrame
563
+ DataFrame with binned data and target frequency.
564
+ """
565
+ data = self.data[[variable_name, self.target_column]].copy()
566
+
567
+ # Ensure target column is numeric (0 or 1)
568
+ data[self.target_column] = data[self.target_column].astype(int)
569
+
570
+ # Drop missing values and inform user
571
+ original_count = len(data)
572
+ data.dropna(subset=[variable_name], inplace=True)
573
+ dropped_count = original_count - len(data)
574
+ if dropped_count > 0:
575
+ print(
576
+ f"Dropped {dropped_count} rows with missing values for {variable_name}"
577
+ )
578
+
579
+ if scale:
580
+ # Scale the variable individually
581
+ min_val = data[variable_name].min()
582
+ max_val = data[variable_name].max()
583
+ if min_val != max_val:
584
+ data[variable_name] = (data[variable_name] - min_val) / (
585
+ max_val - min_val
586
+ )
587
+ else:
588
+ print(
589
+ f"Warning: {variable_name} has constant value. Scaling not applied."
590
+ )
591
+
592
+ # Use 'drop' option to handle duplicate bin edges
593
+ try:
594
+ data["bin"] = pd.qcut(
595
+ data[variable_name], q=n_bins, labels=False, duplicates="drop"
596
+ )
597
+ except ValueError as e:
598
+ print(f"Warning: {e}")
599
+ print(
600
+ f"Attempting to create {n_bins} bins for {variable_name}, but some may be merged due to duplicate values."
601
+ )
602
+ # If qcut fails, fall back to cut with equal-width bins
603
+ data["bin"] = pd.cut(data[variable_name], bins=n_bins, labels=False)
604
+
605
+ grouped = data.groupby("bin").agg(
606
+ {variable_name: "mean", self.target_column: ["count", "sum"]}
607
+ )
608
+ grouped.columns = PandasIndex([variable_name, "total_count", "target_sum"])
609
+ grouped["target_freq"] = grouped["target_sum"] / grouped["total_count"]
610
+ return grouped.reset_index()
611
+
612
+ def plot_hexagon_target_vs_numerical(
613
+ self, var1: str, var2: str, n_bins: int = 20, scale: bool = False
614
+ ) -> None:
615
+ """
616
+ Create a hexbin plot comparing two numerical variables and their relationship to the target variable.
617
+
618
+ Parameters
619
+ ----------
620
+ var1 : str
621
+ Name of the first numerical variable.
622
+ var2 : str
623
+ Name of the second numerical variable.
624
+ n_bins : int, optional
625
+ Number of bins to use, by default 20.
626
+ scale : bool, optional
627
+ Whether to scale variables before binning, by default False.
628
+ """
629
+ data = self.data[[var1, var2, self.target_column]].copy()
630
+
631
+ # Drop rows with missing values
632
+ data.dropna(subset=[var1, var2], inplace=True)
633
+
634
+ # Ensure target column is numeric (0 or 1)
635
+ data[self.target_column] = data[self.target_column].astype(int)
636
+
637
+ if scale:
638
+ scaler = MinMaxScaler()
639
+ data[[var1, var2]] = scaler.fit_transform(data[[var1, var2]])
640
+
641
+ # Create the hexbin plot
642
+ plt.figure(figsize=(10, 8))
643
+ hb = plt.hexbin(
644
+ data[var1],
645
+ data[var2],
646
+ C=data[self.target_column],
647
+ gridsize=n_bins,
648
+ cmap="YlOrRd",
649
+ reduce_C_function=np.mean,
650
+ )
651
+
652
+ plt.colorbar(hb, label="Target Frequency")
653
+ plt.xlabel(var1)
654
+ plt.ylabel(var2)
655
+ plt.title(f"Hexbin Plot of {var1} vs {var2} (Target Frequency)")
656
+
657
+ # Add a text annotation for the correlation
658
+ correlation = data[[var1, var2]].corr().iloc[0, 1]
659
+ plt.text(
660
+ 0.05,
661
+ 0.95,
662
+ f"Correlation: {correlation:.2f}",
663
+ transform=plt.gca().transAxes,
664
+ fontsize=10,
665
+ verticalalignment="top",
666
+ )
667
+
668
+ plt.tight_layout()
669
+ plt.show()
670
+
671
+ def plot_categorical_value_counts(
672
+ self, plot_type: Literal["bar", "pie"] = "bar"
673
+ ) -> None:
674
+ """
675
+ Generate value counts for categorical features and visualize them with a dropdown menu to select columns.
676
+ Includes a red line representing target percentages for bar plots.
677
+
678
+ Parameters
679
+ ----------
680
+ plot_type : {'bar', 'pie'}, optional
681
+ Type of plot to display, by default 'bar'.
682
+ """
683
+ fig = go.Figure()
684
+
685
+ for col in self.categorical_columns:
686
+ counts = self.data[col].value_counts().reset_index()
687
+ counts.columns = [col, "Frequency"]
688
+ total = counts["Frequency"].sum()
689
+ counts["Percentage"] = counts["Frequency"] / total * 100
690
+
691
+ # Calculate target percentage for each category
692
+ target_percentages = self.data.groupby(col, observed=False)[
693
+ self.target_column
694
+ ].mean()
695
+ counts["TargetPercentage"] = (
696
+ counts[col].map(target_percentages).astype(float)
697
+ )
698
+ counts["TargetPercentage_"] = counts["TargetPercentage"] / 100
699
+
700
+ if counts.empty:
701
+ print(f"No data to plot for column: {col}")
702
+ continue
703
+
704
+ if plot_type == "bar":
705
+ # Add bar trace
706
+ fig.add_trace(
707
+ go.Bar(
708
+ x=counts[col],
709
+ y=counts["Frequency"],
710
+ name=col,
711
+ text=counts["Percentage"].apply(lambda x: f"{x:.1f}%"),
712
+ textposition="inside",
713
+ hovertemplate=(
714
+ f"<b>{col}</b>: %{{x}}<br>"
715
+ "Frequency: %{y}<br>"
716
+ "Percentage: %{customdata[0]:.2f}%<br>"
717
+ f"{self.target_column} %: %{{customdata[1]:.2%}}"
718
+ ),
719
+ customdata=counts[["Percentage", "TargetPercentage_"]],
720
+ visible=False,
721
+ )
722
+ )
723
+
724
+ # Add red line trace for target percentages
725
+ fig.add_trace(
726
+ go.Scatter(
727
+ x=counts[col],
728
+ y=counts["TargetPercentage"],
729
+ mode="lines",
730
+ name=f"{self.target_column} %",
731
+ line={"color": "red", "width": 2},
732
+ yaxis="y2",
733
+ hovertemplate=f"{self.target_column} %: %{{customdata:.2%}}<extra></extra>",
734
+ customdata=counts["TargetPercentage_"],
735
+ visible=False,
736
+ )
737
+ )
738
+
739
+ elif plot_type == "pie":
740
+ fig.add_trace(
741
+ go.Pie(
742
+ labels=counts[col],
743
+ values=counts["Frequency"],
744
+ name=col,
745
+ text=counts["Percentage"].apply(lambda x: f"{x:.1f}%"),
746
+ hovertemplate=(
747
+ f"<b>{col}</b>: %{{label}}<br>"
748
+ "Frequency: %{value}<br>"
749
+ "Percentage: %{customdata[0]:.2f}%<br>"
750
+ f"{self.target_column} %: %{{customdata[1]:.2%}}"
751
+ ),
752
+ customdata=counts[["Percentage", "TargetPercentage_"]],
753
+ visible=False,
754
+ )
755
+ )
756
+
757
+ # Create dropdown menu
758
+ buttons = []
759
+ for i, col in enumerate(self.categorical_columns):
760
+ visible = [False] * len(fig.data) # type: ignore
761
+ if plot_type == "bar":
762
+ visible[i * 2] = True # Bar trace
763
+ visible[i * 2 + 1] = True # Line trace
764
+ else:
765
+ visible[i] = True
766
+ buttons.append(
767
+ {
768
+ "method": "update",
769
+ "label": col,
770
+ "args": [{"visible": visible}, {"title": f"Distribution of {col}"}],
771
+ }
772
+ )
773
+
774
+ # Update layout
775
+ fig.update_layout(
776
+ updatemenus=[
777
+ {
778
+ "active": 0,
779
+ "buttons": buttons,
780
+ "direction": "down",
781
+ "showactive": True,
782
+ "x": 1.0,
783
+ "xanchor": "left",
784
+ "y": 1.15,
785
+ "yanchor": "top",
786
+ }
787
+ ],
788
+ title=f"Distribution of {self.categorical_columns[0]}",
789
+ xaxis_title=self.categorical_columns[0] if plot_type == "bar" else None,
790
+ yaxis_title="Frequency" if plot_type == "bar" else None,
791
+ showlegend=True,
792
+ legend={
793
+ "orientation": "h",
794
+ "yanchor": "bottom",
795
+ "y": 1.02,
796
+ "xanchor": "right",
797
+ "x": 1,
798
+ },
799
+ )
800
+
801
+ if plot_type == "bar":
802
+ max_target_percentage = max(
803
+ self.data.groupby(col, observed=False)[self.target_column].mean().max()
804
+ for col in self.categorical_columns
805
+ )
806
+ fig.update_layout(
807
+ yaxis={"title": "Frequency", "side": "left"},
808
+ yaxis2={
809
+ "title": "",
810
+ "overlaying": "y",
811
+ "side": "right",
812
+ "range": [0, max_target_percentage],
813
+ "tickformat": ".0%",
814
+ "showticklabels": False, # Hide tick labels
815
+ "showgrid": False, # Hide grid lines
816
+ "zeroline": False, # Hide zero line
817
+ "showline": False, # Hide axis line
818
+ },
819
+ )
820
+
821
+ # Make the first plot visible
822
+ if len(fig.data) > 0: # type: ignore
823
+ fig.data[0].visible = True # type: ignore
824
+ if plot_type == "bar" and len(fig.data) > 1: # type: ignore
825
+ fig.data[1].visible = True # type: ignore
826
+
827
+ fig.show()
828
+
829
+ def _compute_categories_per_ring(self, n_categories: int) -> List[int]:
830
+ """
831
+ Determine the number of categories for each ring in the custom plot.
832
+
833
+ Parameters
834
+ ----------
835
+ n_categories : int
836
+ Total number of categories.
837
+
838
+ Returns
839
+ -------
840
+ List[int]
841
+ Number of categories for each ring.
842
+ """
843
+ power = 5
844
+ rings: List[int] = []
845
+ remaining = n_categories
846
+ while remaining > 0:
847
+ ring_size = math.ceil(Visualizations.PHI ** (power + 1)) - math.ceil(
848
+ Visualizations.PHI**power
849
+ )
850
+ if remaining <= ring_size:
851
+ # If this is the last ring or second to last ring
852
+ if len(rings) >= 1:
853
+ # Distribute remaining categories: 2/3 to second last, 1/3 to last
854
+ second_last = math.ceil(remaining * 2 / 3)
855
+ last = remaining - second_last
856
+ rings[-1] += second_last
857
+ if last > 0:
858
+ rings.append(last)
859
+ else:
860
+ rings.append(remaining)
861
+ break
862
+ rings.append(ring_size)
863
+ remaining -= ring_size
864
+ power += 1
865
+ return rings
866
+
867
+ def plot_stacked_donut(self, column: str) -> None:
868
+ """
869
+ Plot the distribution of a categorical column with respect to the target column using a custom plot.
870
+
871
+ Parameters
872
+ ----------
873
+ column : str
874
+ Name of the column to plot.
875
+ """
876
+ # Aggregate and prepare data
877
+ aggregated = self.data[column].value_counts().reset_index()
878
+ aggregated.columns = pd.Index([column, "total_count"])
879
+ total_obs = aggregated["total_count"].sum()
880
+ aggregated["percentage"] = aggregated["total_count"] / total_obs * 100
881
+ positive_cases = (
882
+ self.data[self.data[self.target_column] == 1][column]
883
+ .value_counts()
884
+ .reset_index()
885
+ )
886
+ positive_cases.columns = PandasIndex([column, "positive_count"])
887
+ merged_data = pd.merge(aggregated, positive_cases, on=column, how="left")
888
+ merged_data["positive_count"] = merged_data["positive_count"].fillna(0)
889
+ merged_data["positive_rate"] = (
890
+ merged_data["positive_count"] / merged_data["total_count"]
891
+ )
892
+
893
+ # Determine ring distribution
894
+ n_categories = len(merged_data)
895
+ ring_sizes = self._compute_categories_per_ring(n_categories)
896
+
897
+ # Assign categories to rings
898
+ merged_data["ring"] = 0
899
+ start_idx = 0
900
+ for ring, size in enumerate(ring_sizes):
901
+ end_idx = start_idx + size
902
+ merged_data.loc[start_idx : end_idx - 1, "ring"] = ring
903
+ start_idx = end_idx
904
+
905
+ # Adjust base hole size and growth rate
906
+ base_hole_size = 0.3
907
+ hole_growth_rate = 0.1
908
+
909
+ # Create traces for each ring
910
+ traces = []
911
+ for ring in range(len(ring_sizes)):
912
+ ring_data = merged_data[merged_data["ring"] == ring]
913
+
914
+ if ring_data.empty:
915
+ continue
916
+
917
+ # Generate distinct colors for this ring
918
+ n_colors = len(ring_data)
919
+ hue_start = ring * 0.25 # Shift hue for each ring
920
+ colors = [
921
+ f"rgb({int(r*255)},{int(g*255)},{int(b*255)})"
922
+ for r, g, b in [
923
+ colorsys.hsv_to_rgb((hue_start + j / n_colors) % 1, 0.7, 0.9)
924
+ for j in range(n_colors)
925
+ ]
926
+ ]
927
+
928
+ # Adjust hole size based on ring (ensure it doesn't exceed 0.9)
929
+ hole_size = min(0.9, base_hole_size + ring * hole_growth_rate)
930
+
931
+ # Prepare hover text
932
+ hover_text = [
933
+ f"<b>{label}</b><br>"
934
+ f"Count: {count}<br>"
935
+ f"Percentage: {percentage:.2f}%<br>"
936
+ f"Positive Rate: {positive_rate:.2%}"
937
+ for label, count, percentage, positive_rate in zip(
938
+ ring_data[column],
939
+ ring_data["total_count"],
940
+ ring_data["percentage"],
941
+ ring_data["positive_rate"],
942
+ )
943
+ ]
944
+
945
+ traces.append(
946
+ go.Pie(
947
+ values=ring_data["total_count"],
948
+ labels=ring_data[column],
949
+ name=f"Ring {ring+1}",
950
+ hole=hole_size,
951
+ domain={"x": [0, 1], "y": [0, 1]},
952
+ hoverinfo="text",
953
+ hovertext=hover_text,
954
+ marker={
955
+ "colors": colors,
956
+ "line": {"color": "white", "width": 2}, # Add white borders
957
+ },
958
+ textposition="none",
959
+ showlegend=True,
960
+ )
961
+ )
962
+
963
+ # Create the figure
964
+ fig = go.Figure(data=traces)
965
+
966
+ # Update layout
967
+ layout_annotations = []
968
+ if len(ring_sizes) > 1:
969
+ layout_annotations = [
970
+ {
971
+ "text": "Most<br>Frequent",
972
+ "x": 0.5,
973
+ "y": 0.5,
974
+ "font_size": 10,
975
+ "showarrow": False,
976
+ },
977
+ {
978
+ "text": "Least<br>Frequent",
979
+ "x": 0.5,
980
+ "y": 0.95,
981
+ "font_size": 10,
982
+ "showarrow": False,
983
+ },
984
+ ]
985
+
986
+ fig.update_layout(
987
+ title=f"Distribution of {column} with {self.target_column.capitalize()} Events",
988
+ annotations=layout_annotations,
989
+ legend={
990
+ "orientation": "v",
991
+ "yanchor": "top",
992
+ "y": 1,
993
+ "xanchor": "left",
994
+ "x": 1.05,
995
+ },
996
+ legend_title_text="Categories",
997
+ margin={"r": 150}, # Increase right margin to accommodate legend
998
+ )
999
+
1000
+ fig.show()
1001
+
1002
+ def plot_numerical_target_frequency_line(
1003
+ self, x_log_scale: bool = False, y_log_scale: bool = False, scale: bool = False
1004
+ ) -> None:
1005
+ """
1006
+ Plot the target frequency for all numerical columns in a single plot with different colored lines.
1007
+ All lines are initially invisible and can be toggled on/off interactively.
1008
+
1009
+ Parameters
1010
+ ----------
1011
+ x_log_scale : bool, optional
1012
+ If True, the x-axis will be plotted in log scale, by default False.
1013
+ y_log_scale : bool, optional
1014
+ If True, the y-axis will be plotted in log scale, by default False.
1015
+ scale : bool, optional
1016
+ If True, scale each column's values from 0-1 before plotting, by default False.
1017
+ """
1018
+ fig = go.Figure()
1019
+
1020
+ for col in self.numerical_columns:
1021
+ grouped = self._bin_and_target_freq(col, scale=scale)
1022
+ fig.add_trace(
1023
+ go.Scatter(
1024
+ x=grouped[col],
1025
+ y=grouped["target_freq"],
1026
+ mode="lines",
1027
+ name=col,
1028
+ visible="legendonly", # This makes the line invisible by default
1029
+ )
1030
+ )
1031
+
1032
+ fig.update_layout(
1033
+ title="Target Frequency for Numerical Columns",
1034
+ xaxis_title="Value" if not scale else "Scaled Value (0-1)",
1035
+ yaxis_title="Target Frequency",
1036
+ xaxis_type="log" if x_log_scale else "linear",
1037
+ yaxis_type="log" if y_log_scale else "linear",
1038
+ )
1039
+
1040
+ fig.show()
1041
+
1042
+ def calc_cat_target_freq(self) -> pd.DataFrame:
1043
+ """
1044
+ Calculate the target frequency for each category of all categorical columns.
1045
+
1046
+ Returns
1047
+ -------
1048
+ pd.DataFrame
1049
+ DataFrame with a multi-index structure for easy viewing and analysis.
1050
+ """
1051
+ data_list = []
1052
+
1053
+ for col in self.categorical_columns:
1054
+ grouped = (
1055
+ self.data.groupby(col, observed=False)
1056
+ .agg({self.target_column: ["mean", "count"], col: "count"})
1057
+ .reset_index()
1058
+ )
1059
+
1060
+ grouped.columns = PandasIndex(
1061
+ [
1062
+ "Category",
1063
+ "Target_Frequency",
1064
+ "Total_Count",
1065
+ "Category_Count",
1066
+ ]
1067
+ )
1068
+ grouped["Column"] = col
1069
+ grouped["Category_Percentage"] = (
1070
+ grouped["Category_Count"] / grouped["Total_Count"].sum() * 100
1071
+ )
1072
+
1073
+ data_list.append(grouped)
1074
+
1075
+ # Combine all dataframes
1076
+ result_df = pd.concat(data_list, ignore_index=True)
1077
+ # Create multi-index DataFrame
1078
+ result_df = result_df.set_index(["Column", "Category"])
1079
+ # Reorder columns
1080
+ result_df = result_df[
1081
+ ["Category_Count", "Category_Percentage", "Target_Frequency"]
1082
+ ]
1083
+ # Rename columns for clarity
1084
+ result_df.columns = PandasIndex(["Count", "Percentage", "Target_Frequency"])
1085
+
1086
+ # Sort by Column and then by Count (descending)
1087
+ result_df = result_df.sort_values(["Column", "Count"], ascending=[True, False])
1088
+
1089
+ return result_df
1090
+
1091
+ def _calculate_vif_manual(self, X, feature_idx):
1092
+ """
1093
+ Manually calculate VIF for a single feature.
1094
+ """
1095
+ other_features = [i for i in range(X.shape[1]) if i != feature_idx]
1096
+ X_other = X[:, other_features]
1097
+ y = X[:, feature_idx]
1098
+
1099
+ # Check if the feature has any variation
1100
+ if np.all(y == y[0]):
1101
+ return np.inf # or any large number to indicate perfect multicollinearity
1102
+
1103
+ # Fit linear regression
1104
+ model = LinearRegression()
1105
+ model.fit(X_other, y)
1106
+
1107
+ # Calculate R-squared
1108
+ y_pred = model.predict(X_other)
1109
+ ss_total = np.sum((y - np.mean(y)) ** 2)
1110
+ ss_residual = np.sum((y - y_pred) ** 2)
1111
+
1112
+ # Handle the case where ss_total is very close to zero
1113
+ if np.isclose(ss_total, 0):
1114
+ return np.inf # or any large number to indicate perfect multicollinearity
1115
+
1116
+ r_squared = 1 - (ss_residual / ss_total)
1117
+
1118
+ # Calculate VIF
1119
+ vif = 1 / (1 - r_squared)
1120
+
1121
+ return vif
1122
+
1123
+ def plot_vif_failsafe(self, threshold: int = 5, show_plot: bool = True) -> None:
1124
+ """
1125
+ Calculate the Variance Inflation Factor (VIF) for numerical features.
1126
+ """
1127
+ # Select numerical data from the DataFrame, excluding the target column
1128
+ numerical_columns = [
1129
+ col for col in self.numerical_columns if col != self.target_column
1130
+ ]
1131
+ numerical_data = self.data[numerical_columns].copy()
1132
+
1133
+ # Convert boolean columns to integers
1134
+ for column in numerical_data.columns:
1135
+ if numerical_data[column].dtype == "bool":
1136
+ numerical_data[column] = numerical_data[column].astype(int)
1137
+
1138
+ # Check for missing values
1139
+ missing_values = numerical_data.isnull().sum()
1140
+ if missing_values.sum() > 0:
1141
+ print("\nWARNING: Missing values detected in the following columns:")
1142
+ print(missing_values[missing_values > 0])
1143
+ print("\nImputing missing values with mean for VIF calculation.")
1144
+ print(
1145
+ "It is strongly recommended to properly handle missing values in your dataset."
1146
+ )
1147
+
1148
+ # Impute missing values with mean
1149
+ numerical_data = numerical_data.fillna(numerical_data.mean())
1150
+
1151
+ # Calculate VIF for each feature
1152
+ X = numerical_data.values
1153
+ vif_data = pd.DataFrame()
1154
+ vif_data["Feature"] = numerical_data.columns
1155
+
1156
+ # Use tqdm to create a progress bar
1157
+ vif_values = []
1158
+ for i in tqdm(range(X.shape[1]), desc="Calculating VIF", unit="feature"):
1159
+ vif_values.append(self._calculate_vif_manual(X, i))
1160
+ vif_data["VIF"] = vif_values
1161
+
1162
+ # Sort VIF values in descending order
1163
+ vif_data = vif_data.sort_values("VIF", ascending=False).reset_index(drop=True)
1164
+
1165
+ # Assign colors based on the threshold
1166
+ vif_data["Color"] = [
1167
+ "red" if vif > threshold else "green" for vif in vif_data["VIF"]
1168
+ ]
1169
+
1170
+ # Create the bar plot
1171
+ fig = go.Figure()
1172
+ fig.add_trace(
1173
+ go.Bar(
1174
+ x=vif_data["Feature"],
1175
+ y=vif_data["VIF"],
1176
+ marker_color=vif_data["Color"],
1177
+ text=vif_data["VIF"].round(2),
1178
+ textposition="outside",
1179
+ )
1180
+ )
1181
+
1182
+ # Update layout
1183
+ fig.update_layout(
1184
+ title="Variance Inflation Factor (VIF) for Features",
1185
+ xaxis_title="Features",
1186
+ yaxis_title="VIF",
1187
+ yaxis={
1188
+ "range": [0, min(max(vif_data["VIF"]), 100) * 1.1]
1189
+ }, # Set y-axis range with some padding and upper limit
1190
+ showlegend=False,
1191
+ xaxis={"tickangle": 45},
1192
+ )
1193
+
1194
+ # Add a horizontal line at the threshold value
1195
+ fig.add_shape(
1196
+ type="line",
1197
+ x0=-0.5,
1198
+ y0=threshold,
1199
+ x1=len(vif_data["Feature"]) - 0.5,
1200
+ y1=threshold,
1201
+ line={"color": "black", "width": 2, "dash": "dash"},
1202
+ )
1203
+
1204
+ if show_plot:
1205
+ fig.show()
1206
+
1207
+
1208
+ '''
1209
+ def plot_categorical_value_counts(
1210
+ self, plot_type: Literal["bar", "pie"] = "bar"
1211
+ ) -> None:
1212
+ """
1213
+ Generate value counts for categorical features and visualize them.
1214
+
1215
+ Parameters
1216
+ ----------
1217
+ plot_type : {'bar', 'pie'}, optional
1218
+ Type of plot to display, by default 'bar'.
1219
+ """
1220
+ value_counts = {
1221
+ col: self.data[col].value_counts() for col in self.categorical_columns
1222
+ }
1223
+
1224
+ for col in self.categorical_columns:
1225
+ counts = value_counts[col].reset_index()
1226
+ counts.columns = PandasIndex([col, "Frequency"])
1227
+
1228
+ if counts.empty:
1229
+ print(f"No data to plot for column: {col}")
1230
+ continue
1231
+
1232
+ if plot_type == "bar":
1233
+ fig = px.bar(
1234
+ counts, x=col, y="Frequency", title=f"Distribution of {col}"
1235
+ )
1236
+ elif plot_type == "pie":
1237
+ fig = px.pie(
1238
+ counts,
1239
+ names=col,
1240
+ values="Frequency",
1241
+ title=f"Distribution of {col}",
1242
+ )
1243
+ else:
1244
+ raise ValueError("plot_type should be either 'bar' or 'pie'")
1245
+
1246
+ fig.update_layout(title={"x": 0.5})
1247
+ fig.show()
1248
+ '''