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,1310 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from typing import List, Literal, NamedTuple, Optional
|
|
3
|
+
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
import numpy as np
|
|
6
|
+
import numpy.typing as npt
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from category_encoders import CatBoostEncoder
|
|
9
|
+
from scipy.stats import anderson, probplot
|
|
10
|
+
from sklearn.cluster import KMeans
|
|
11
|
+
from sklearn.decomposition import PCA, TruncatedSVD
|
|
12
|
+
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis as LDA
|
|
13
|
+
from sklearn.impute import KNNImputer, SimpleImputer
|
|
14
|
+
from sklearn.model_selection import cross_val_score
|
|
15
|
+
from sklearn.preprocessing import (
|
|
16
|
+
OneHotEncoder,
|
|
17
|
+
OrdinalEncoder,
|
|
18
|
+
PowerTransformer,
|
|
19
|
+
StandardScaler,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AndersonResult(NamedTuple):
|
|
24
|
+
statistic: float
|
|
25
|
+
critical_values: npt.NDArray
|
|
26
|
+
significance_level: npt.NDArray
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FeatureEngineering:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
dataframe: pd.DataFrame,
|
|
33
|
+
target_column: str,
|
|
34
|
+
numerical_columns: List[str],
|
|
35
|
+
categorical_columns: List[str],
|
|
36
|
+
to_be_encoded: Optional[List[str]] = None,
|
|
37
|
+
transformations: Optional[List[dict]] = None,
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
Initialize the FeatureEngineering class with data and configuration.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
dataframe : pd.DataFrame
|
|
45
|
+
The data to be processed.
|
|
46
|
+
target_column : str
|
|
47
|
+
The target column for analysis.
|
|
48
|
+
numerical_columns : List[str]
|
|
49
|
+
List of numerical columns in the data.
|
|
50
|
+
categorical_columns : List[str]
|
|
51
|
+
List of categorical columns in the data.
|
|
52
|
+
to_be_encoded : Optional[List[str]], optional
|
|
53
|
+
Columns to be encoded, by default None.
|
|
54
|
+
transformations : Optional[List[dict]], optional
|
|
55
|
+
List of transformations to be applied, by default None.
|
|
56
|
+
"""
|
|
57
|
+
self.data = dataframe.copy()
|
|
58
|
+
self.target_column = target_column
|
|
59
|
+
self.numerical_columns = numerical_columns
|
|
60
|
+
self.categorical_columns = categorical_columns
|
|
61
|
+
self.encode_list = to_be_encoded if to_be_encoded else categorical_columns
|
|
62
|
+
self.transformations = (
|
|
63
|
+
transformations if isinstance(transformations, list) else []
|
|
64
|
+
)
|
|
65
|
+
self.transformed_data: Optional[pd.DataFrame] = None
|
|
66
|
+
|
|
67
|
+
def _log_transformation(self, method_name: str, params: dict):
|
|
68
|
+
"""
|
|
69
|
+
Log the transformation applied to the data.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
method_name : str
|
|
74
|
+
The name of the transformation method.
|
|
75
|
+
params : dict
|
|
76
|
+
Parameters used in the transformation.
|
|
77
|
+
"""
|
|
78
|
+
self.transformations.append({"method": method_name, "params": params})
|
|
79
|
+
|
|
80
|
+
def apply_stored_transformations(self, new_data: pd.DataFrame) -> pd.DataFrame:
|
|
81
|
+
"""
|
|
82
|
+
Apply stored transformations to new data.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
new_data : pd.DataFrame
|
|
87
|
+
The new data to apply transformations to.
|
|
88
|
+
|
|
89
|
+
Returns
|
|
90
|
+
-------
|
|
91
|
+
pd.DataFrame
|
|
92
|
+
The transformed data.
|
|
93
|
+
"""
|
|
94
|
+
backup = self.data.copy()
|
|
95
|
+
self.data = new_data
|
|
96
|
+
|
|
97
|
+
for transformation in self.transformations:
|
|
98
|
+
method = transformation["method"]
|
|
99
|
+
params = transformation["params"]
|
|
100
|
+
|
|
101
|
+
if method == "handle_missing_values":
|
|
102
|
+
new_data = self.handle_missing_values(**params)
|
|
103
|
+
elif method == "normality_transform":
|
|
104
|
+
new_data = self.apply_normality_transformations(**params)
|
|
105
|
+
elif method == "winsorization":
|
|
106
|
+
new_data = self.apply_winsorization(**params)
|
|
107
|
+
elif method == "encoding_categorical":
|
|
108
|
+
new_data = self.encoding_categorical(**params)
|
|
109
|
+
else:
|
|
110
|
+
print(f"Transformation method '{method}' not found.")
|
|
111
|
+
|
|
112
|
+
self.data = backup
|
|
113
|
+
return new_data
|
|
114
|
+
|
|
115
|
+
def handle_missing_values(
|
|
116
|
+
self,
|
|
117
|
+
method: Literal["knn", "mean", "median", "most_frequent", "quantile"] = "knn",
|
|
118
|
+
n_neighbors: Optional[int] = 5,
|
|
119
|
+
) -> pd.DataFrame:
|
|
120
|
+
"""
|
|
121
|
+
Handle missing values in the DataFrame using the specified method.
|
|
122
|
+
|
|
123
|
+
Parameters
|
|
124
|
+
----------
|
|
125
|
+
method : {'knn', 'mean', 'median', 'most_frequent', 'quantile'}, optional
|
|
126
|
+
The imputation method to use, by default 'knn'.
|
|
127
|
+
n_neighbors : Optional[int], optional
|
|
128
|
+
Number of neighbors to use for KNN imputation, by default 5.
|
|
129
|
+
|
|
130
|
+
Returns
|
|
131
|
+
-------
|
|
132
|
+
pd.DataFrame
|
|
133
|
+
DataFrame with imputed values.
|
|
134
|
+
"""
|
|
135
|
+
# Get all columns except the target
|
|
136
|
+
features = self.data.drop(columns=[self.target_column])
|
|
137
|
+
numeric_columns = self.numerical_columns.copy()
|
|
138
|
+
if self.target_column in numeric_columns:
|
|
139
|
+
numeric_columns.remove(self.target_column)
|
|
140
|
+
|
|
141
|
+
# Validate numerical columns
|
|
142
|
+
missing_numerical_columns = [
|
|
143
|
+
col for col in numeric_columns if col not in features.columns
|
|
144
|
+
]
|
|
145
|
+
if missing_numerical_columns:
|
|
146
|
+
raise KeyError(
|
|
147
|
+
f"Numerical columns not found in DataFrame: {missing_numerical_columns}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
numerical_features = features[numeric_columns]
|
|
151
|
+
|
|
152
|
+
# Validate categorical columns
|
|
153
|
+
missing_categorical_columns = [
|
|
154
|
+
col for col in self.categorical_columns if col not in features.columns
|
|
155
|
+
]
|
|
156
|
+
if missing_categorical_columns:
|
|
157
|
+
raise KeyError(
|
|
158
|
+
f"Categorical columns not found in DataFrame: {missing_categorical_columns}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
categorical_features = features[self.categorical_columns].copy()
|
|
162
|
+
encoders = {}
|
|
163
|
+
for column in self.categorical_columns:
|
|
164
|
+
encoder = OrdinalEncoder(
|
|
165
|
+
handle_unknown="use_encoded_value", unknown_value=-1
|
|
166
|
+
)
|
|
167
|
+
categorical_features[column] = encoder.fit_transform(
|
|
168
|
+
categorical_features[[column]]
|
|
169
|
+
).ravel()
|
|
170
|
+
encoders[column] = encoder
|
|
171
|
+
|
|
172
|
+
# Combine numeric and encoded categorical features
|
|
173
|
+
combined_features = pd.concat(
|
|
174
|
+
[numerical_features, categorical_features], axis=1
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Check if the method is one of the SimpleImputer strategies
|
|
178
|
+
simple_imputer_methods = ["mean", "median", "most_frequent", "quantile"]
|
|
179
|
+
|
|
180
|
+
if method.lower() == "knn":
|
|
181
|
+
# Initialize the KNN imputer
|
|
182
|
+
if n_neighbors is None:
|
|
183
|
+
raise ValueError("n_neighbors must be specified for KNN imputation.")
|
|
184
|
+
imputer = KNNImputer(n_neighbors=n_neighbors)
|
|
185
|
+
elif method.lower() in simple_imputer_methods:
|
|
186
|
+
# Initialize the SimpleImputer based on the method
|
|
187
|
+
if method.lower() == "quantile":
|
|
188
|
+
imputer = SimpleImputer(strategy="constant", fill_value=0.5)
|
|
189
|
+
else:
|
|
190
|
+
imputer = SimpleImputer(strategy=method.lower())
|
|
191
|
+
else:
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"Method '{method}' is not supported. Choose from 'knn', 'mean', 'median', 'most_frequent', or 'quantile'."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Fit and transform the combined features
|
|
197
|
+
imputed_features = imputer.fit_transform(combined_features)
|
|
198
|
+
|
|
199
|
+
# Convert the imputed array back to a DataFrame
|
|
200
|
+
imputed_df = pd.DataFrame(
|
|
201
|
+
imputed_features, columns=combined_features.columns, index=self.data.index
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Decode categorical columns
|
|
205
|
+
for column in self.categorical_columns:
|
|
206
|
+
imputed_df[column] = (
|
|
207
|
+
encoders[column].inverse_transform(imputed_df[[column]]).ravel()
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Add the target column back
|
|
211
|
+
imputed_df[self.target_column] = self.data[self.target_column]
|
|
212
|
+
|
|
213
|
+
# Update the current DataFrame with the imputed values
|
|
214
|
+
self.data[imputed_df.columns] = imputed_df
|
|
215
|
+
|
|
216
|
+
print(f"\nMissing values have been imputed using {method} imputation.")
|
|
217
|
+
print("Note: The target column was excluded from imputation.")
|
|
218
|
+
print("Categorical columns were encoded before imputation and then decoded.")
|
|
219
|
+
|
|
220
|
+
# Log the transformation
|
|
221
|
+
self._log_transformation(
|
|
222
|
+
"handle_missing", {"method": method, "n_neighbors": n_neighbors}
|
|
223
|
+
)
|
|
224
|
+
return self.data
|
|
225
|
+
|
|
226
|
+
def diagnose_normality_transform(
|
|
227
|
+
self,
|
|
228
|
+
method: Literal["power", "standard"] = "power",
|
|
229
|
+
alpha: float = 0.05,
|
|
230
|
+
test_mode: bool = False,
|
|
231
|
+
) -> dict:
|
|
232
|
+
transformed_data = self.data.copy()
|
|
233
|
+
results = {}
|
|
234
|
+
for column in self.numerical_columns:
|
|
235
|
+
if column == self.target_column:
|
|
236
|
+
print(
|
|
237
|
+
f"{column} is the target column. Skipping normality check and transformation."
|
|
238
|
+
)
|
|
239
|
+
continue
|
|
240
|
+
# Perform Anderson-Darling test before transformation
|
|
241
|
+
ad_result: AndersonResult = anderson(self.data[column].dropna()) # type: ignore
|
|
242
|
+
ad_statistic = ad_result.statistic
|
|
243
|
+
ad_critical_values = ad_result.critical_values
|
|
244
|
+
ad_significance_level = ad_result.significance_level
|
|
245
|
+
is_normal_ad = (
|
|
246
|
+
ad_statistic
|
|
247
|
+
< ad_critical_values[np.searchsorted(ad_significance_level, alpha)]
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
results[column] = {
|
|
251
|
+
"ad_statistic": ad_statistic,
|
|
252
|
+
"ad_critical_values": ad_critical_values,
|
|
253
|
+
"is_normal_ad": is_normal_ad,
|
|
254
|
+
"transformation": "None",
|
|
255
|
+
"lambda": None,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
# Create Q-Q plot before transformation
|
|
259
|
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
|
|
260
|
+
probplot(self.data[column], dist="norm", plot=ax1)
|
|
261
|
+
ax1.set_title(f"{column} Before Transformation")
|
|
262
|
+
|
|
263
|
+
# If not normal, apply selected transformation
|
|
264
|
+
if not is_normal_ad:
|
|
265
|
+
if method.lower() == "power":
|
|
266
|
+
transformer = PowerTransformer(method="yeo-johnson")
|
|
267
|
+
elif method.lower() == "standard":
|
|
268
|
+
transformer = StandardScaler()
|
|
269
|
+
|
|
270
|
+
# Mask for non-missing values
|
|
271
|
+
non_missing_mask = ~self.data[column].isna()
|
|
272
|
+
|
|
273
|
+
# Fit and transform only non-missing values
|
|
274
|
+
transformed_data_col = transformer.fit_transform(
|
|
275
|
+
self.data.loc[non_missing_mask, [column]]
|
|
276
|
+
)
|
|
277
|
+
transformed_data_col = transformed_data_col.flatten()
|
|
278
|
+
|
|
279
|
+
# Perform Anderson-Darling test after transformation
|
|
280
|
+
ad_result_after: AndersonResult = anderson(transformed_data_col) # type: ignore
|
|
281
|
+
ad_statistic_after = ad_result_after.statistic
|
|
282
|
+
ad_critical_values_after = ad_result_after.critical_values
|
|
283
|
+
is_normal_ad_after = (
|
|
284
|
+
ad_statistic_after
|
|
285
|
+
< ad_critical_values_after[
|
|
286
|
+
np.searchsorted(ad_significance_level, alpha)
|
|
287
|
+
]
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Create Q-Q plot after transformation
|
|
291
|
+
probplot(transformed_data_col, dist="norm", plot=ax2)
|
|
292
|
+
ax2.set_title(f"{column} After Transformation")
|
|
293
|
+
|
|
294
|
+
# Update the column with transformed data in the transformed DataFrame
|
|
295
|
+
transformed_data[column] = self.data[column].astype(float)
|
|
296
|
+
transformed_data.loc[non_missing_mask, column] = transformed_data_col
|
|
297
|
+
results[column]["transformation"] = method.capitalize() + "Transformer"
|
|
298
|
+
if method == "power":
|
|
299
|
+
results[column]["lambda"] = transformer.lambdas_[0] # type: ignore
|
|
300
|
+
results[column]["ad_statistic_after"] = ad_statistic_after
|
|
301
|
+
results[column]["ad_critical_values_after"] = ad_critical_values_after
|
|
302
|
+
results[column]["is_normal_ad_after"] = is_normal_ad_after
|
|
303
|
+
else:
|
|
304
|
+
ax2.axis("off")
|
|
305
|
+
|
|
306
|
+
plt.tight_layout()
|
|
307
|
+
if not test_mode:
|
|
308
|
+
plt.show()
|
|
309
|
+
|
|
310
|
+
# Store the transformed data
|
|
311
|
+
self.transformed_data = transformed_data
|
|
312
|
+
|
|
313
|
+
# Print results
|
|
314
|
+
print("\nNormality Test Results and Transformations:")
|
|
315
|
+
for column, result in results.items():
|
|
316
|
+
print(f"\n{column}:")
|
|
317
|
+
print(f" Anderson-Darling statistic: {result['ad_statistic']:.4f}")
|
|
318
|
+
print(f" Initially normal (Anderson-Darling): {result['is_normal_ad']}")
|
|
319
|
+
if result["transformation"] != "None":
|
|
320
|
+
print(f" Transformation applied: {result['transformation']}")
|
|
321
|
+
if result["transformation"] == "PowerTransformer":
|
|
322
|
+
print(f" Lambda value: {result['lambda']:.4f}")
|
|
323
|
+
print(
|
|
324
|
+
f" Anderson-Darling statistic after transformation: {result['ad_statistic_after']:.4f}"
|
|
325
|
+
)
|
|
326
|
+
print(
|
|
327
|
+
f" Normal after transformation (Anderson-Darling): {result['is_normal_ad_after']}"
|
|
328
|
+
)
|
|
329
|
+
else:
|
|
330
|
+
print(" No transformation applied")
|
|
331
|
+
|
|
332
|
+
self._log_transformation(
|
|
333
|
+
"normality_transform", {"method": method, "alpha": alpha}
|
|
334
|
+
)
|
|
335
|
+
return results
|
|
336
|
+
|
|
337
|
+
def apply_normality_transformations(
|
|
338
|
+
self, columns_to_keep: List[str]
|
|
339
|
+
) -> pd.DataFrame:
|
|
340
|
+
"""
|
|
341
|
+
Apply the transformations to the specified columns and keep them in the DataFrame.
|
|
342
|
+
|
|
343
|
+
Parameters
|
|
344
|
+
----------
|
|
345
|
+
columns_to_keep : List[str]
|
|
346
|
+
List of columns to keep after applying transformations.
|
|
347
|
+
|
|
348
|
+
Returns
|
|
349
|
+
-------
|
|
350
|
+
pd.DataFrame
|
|
351
|
+
DataFrame with applied transformations.
|
|
352
|
+
"""
|
|
353
|
+
if self.transformed_data is None:
|
|
354
|
+
raise ValueError(
|
|
355
|
+
"No transformations have been applied yet. Please run normality_transform first."
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# Create a new DataFrame with the selected columns
|
|
359
|
+
updated_data = self.data.copy()
|
|
360
|
+
for column in columns_to_keep:
|
|
361
|
+
if column in self.transformed_data.columns:
|
|
362
|
+
updated_data[column] = self.transformed_data[column]
|
|
363
|
+
else:
|
|
364
|
+
raise ValueError(f"Column {column} not found in transformed data")
|
|
365
|
+
|
|
366
|
+
self.data = updated_data
|
|
367
|
+
self._log_transformation(
|
|
368
|
+
"apply_transformations", {"columns_to_keep": columns_to_keep}
|
|
369
|
+
)
|
|
370
|
+
return self.data
|
|
371
|
+
|
|
372
|
+
def handle_high_cardinality(
|
|
373
|
+
self,
|
|
374
|
+
high_cardinality_columns: List[str],
|
|
375
|
+
method: Literal["gathering", "woe", "perlich"] = "gathering",
|
|
376
|
+
) -> None:
|
|
377
|
+
"""
|
|
378
|
+
Handle high cardinality in categorical columns using the specified method.
|
|
379
|
+
|
|
380
|
+
Parameters
|
|
381
|
+
----------
|
|
382
|
+
high_cardinality_columns : List[str]
|
|
383
|
+
List of columns with high cardinality.
|
|
384
|
+
method : {'gathering', 'woe', 'perlich'}, optional
|
|
385
|
+
The method to use for handling high cardinality, by default 'gathering'.
|
|
386
|
+
"""
|
|
387
|
+
self.high_cardinality_columns = high_cardinality_columns
|
|
388
|
+
self.method = method.lower()
|
|
389
|
+
|
|
390
|
+
for column in high_cardinality_columns:
|
|
391
|
+
if self.method == "gathering":
|
|
392
|
+
self._gathering_method(column)
|
|
393
|
+
elif self.method == "woe":
|
|
394
|
+
self._woe_method(column)
|
|
395
|
+
elif self.method == "perlich":
|
|
396
|
+
pass # To be implemented later
|
|
397
|
+
else:
|
|
398
|
+
print("Please choose the correct method: gathering | woe | perlich")
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
def _gathering_method(self, column: str, threshold: float = 0.01) -> None:
|
|
402
|
+
"""
|
|
403
|
+
Gather all categories representing less than a specified percentage of the population into 'other'.
|
|
404
|
+
|
|
405
|
+
Parameters
|
|
406
|
+
----------
|
|
407
|
+
column : str
|
|
408
|
+
The column to apply the method to.
|
|
409
|
+
threshold : float, optional
|
|
410
|
+
The threshold for the percentage of the population, by default 0.01.
|
|
411
|
+
"""
|
|
412
|
+
value_counts = self.data[column].value_counts(normalize=True)
|
|
413
|
+
small_categories = value_counts[value_counts < threshold].index # type: ignore
|
|
414
|
+
self.data[column] = self.data[column].apply(
|
|
415
|
+
lambda x: "other" if x in small_categories else x
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def _woe_method(self, column: str) -> None:
|
|
419
|
+
"""
|
|
420
|
+
Calculate the Weight of Evidence (WOE) for high-cardinality columns.
|
|
421
|
+
|
|
422
|
+
Parameters
|
|
423
|
+
----------
|
|
424
|
+
column : str
|
|
425
|
+
The column to apply the method to.
|
|
426
|
+
"""
|
|
427
|
+
if self.data[self.target_column].dtype.name == "category":
|
|
428
|
+
self.data[self.target_column] = self.data[self.target_column].cat.codes
|
|
429
|
+
elif not np.issubdtype(self.data[self.target_column].dtype, np.number): # type: ignore
|
|
430
|
+
self.data[self.target_column] = self.data[self.target_column].astype(float)
|
|
431
|
+
|
|
432
|
+
target = self.data[self.target_column]
|
|
433
|
+
df = pd.DataFrame({column: self.data[column], "target": target})
|
|
434
|
+
|
|
435
|
+
total_good = df["target"].sum()
|
|
436
|
+
total_bad = df["target"].count() - total_good
|
|
437
|
+
|
|
438
|
+
grouped = df.groupby(column)["target"].agg(["sum", "count"])
|
|
439
|
+
grouped["good"] = grouped["sum"]
|
|
440
|
+
grouped["bad"] = grouped["count"] - grouped["sum"]
|
|
441
|
+
|
|
442
|
+
epsilon = 1e-10
|
|
443
|
+
grouped["good_pct"] = (grouped["good"] + epsilon) / (total_good + epsilon)
|
|
444
|
+
grouped["bad_pct"] = (grouped["bad"] + epsilon) / (total_bad + epsilon)
|
|
445
|
+
|
|
446
|
+
grouped["woe"] = np.log(grouped["good_pct"] / grouped["bad_pct"])
|
|
447
|
+
|
|
448
|
+
woe_dict = grouped["woe"].to_dict()
|
|
449
|
+
self.data[column] = (
|
|
450
|
+
self.data[column].map(woe_dict).fillna(0)
|
|
451
|
+
) # Fill missing values with 0
|
|
452
|
+
|
|
453
|
+
def transform_and_plot_high_cardinality_histograms(
|
|
454
|
+
self,
|
|
455
|
+
high_cardinality_columns: List[str],
|
|
456
|
+
method: Literal["gathering", "woe", "perlich"] = "gathering",
|
|
457
|
+
) -> Optional[pd.DataFrame]:
|
|
458
|
+
"""
|
|
459
|
+
Transform high cardinality columns and plot histograms before and after transformation.
|
|
460
|
+
|
|
461
|
+
Parameters
|
|
462
|
+
----------
|
|
463
|
+
high_cardinality_columns : List[str]
|
|
464
|
+
List of columns with high cardinality.
|
|
465
|
+
method : {'gathering', 'woe', 'perlich'}, optional
|
|
466
|
+
The method to use for handling high cardinality, by default 'gathering'.
|
|
467
|
+
|
|
468
|
+
Returns
|
|
469
|
+
-------
|
|
470
|
+
pd.DataFrame
|
|
471
|
+
DataFrame with transformed columns.
|
|
472
|
+
"""
|
|
473
|
+
self.high_cardinality_columns = high_cardinality_columns
|
|
474
|
+
self.method = method.lower()
|
|
475
|
+
for column in high_cardinality_columns:
|
|
476
|
+
if column not in self.data.columns:
|
|
477
|
+
print(f"Column '{column}' does not exist in the DataFrame.")
|
|
478
|
+
continue
|
|
479
|
+
|
|
480
|
+
original_data = self.data[column].copy()
|
|
481
|
+
|
|
482
|
+
plt.figure(figsize=(12, 6))
|
|
483
|
+
plt.subplot(1, 2, 1)
|
|
484
|
+
original_data.value_counts().plot(kind="bar")
|
|
485
|
+
plt.title(f"{column} - Before Transformation")
|
|
486
|
+
plt.xlabel(column)
|
|
487
|
+
plt.ylabel("Frequency")
|
|
488
|
+
|
|
489
|
+
if self.method == "gathering":
|
|
490
|
+
self._gathering_method(column)
|
|
491
|
+
elif self.method == "woe":
|
|
492
|
+
self._woe_method(column)
|
|
493
|
+
elif self.method == "perlich":
|
|
494
|
+
pass # To be implemented later
|
|
495
|
+
else:
|
|
496
|
+
print("Please choose the correct method: gathering | woe | perlich")
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
plt.subplot(1, 2, 2)
|
|
500
|
+
if self.method == "woe":
|
|
501
|
+
transformed_data = self.data[column]
|
|
502
|
+
transformed_data.plot(kind="kde", color="#1e78b5")
|
|
503
|
+
plt.title(f"{column} - After Transformation ({method.capitalize()})")
|
|
504
|
+
plt.xlabel(f"{column} (WOE)")
|
|
505
|
+
plt.ylabel("Density")
|
|
506
|
+
else:
|
|
507
|
+
transformed_data = self.data[column].value_counts()
|
|
508
|
+
colors = [
|
|
509
|
+
"#23eb1c" if x == "other" else "#1e78b5"
|
|
510
|
+
for x in transformed_data.index
|
|
511
|
+
]
|
|
512
|
+
transformed_data.plot(kind="bar", color=colors)
|
|
513
|
+
plt.title(f"{column} - After Transformation ({method.capitalize()})")
|
|
514
|
+
plt.xlabel(column)
|
|
515
|
+
plt.ylabel("Frequency")
|
|
516
|
+
|
|
517
|
+
plt.tight_layout()
|
|
518
|
+
plt.show()
|
|
519
|
+
|
|
520
|
+
return self.data
|
|
521
|
+
|
|
522
|
+
def perform_low_cardinality_checks_numerical(
|
|
523
|
+
self, threshold: int = 10
|
|
524
|
+
) -> List[str]:
|
|
525
|
+
"""
|
|
526
|
+
Check for low cardinality numerical columns.
|
|
527
|
+
|
|
528
|
+
Parameters
|
|
529
|
+
----------
|
|
530
|
+
threshold : int, optional
|
|
531
|
+
The threshold for low cardinality, by default 10.
|
|
532
|
+
|
|
533
|
+
Returns
|
|
534
|
+
-------
|
|
535
|
+
List[str]
|
|
536
|
+
List of low cardinality numerical columns.
|
|
537
|
+
"""
|
|
538
|
+
low_cardinality_numeric = [
|
|
539
|
+
col
|
|
540
|
+
for col in self.numerical_columns
|
|
541
|
+
if self.data[col].nunique() < threshold
|
|
542
|
+
]
|
|
543
|
+
return low_cardinality_numeric
|
|
544
|
+
|
|
545
|
+
def _kmeans_binning(self, column: str, n_bins: int) -> pd.Series:
|
|
546
|
+
"""
|
|
547
|
+
Apply k-means binning to the specified column.
|
|
548
|
+
|
|
549
|
+
Parameters
|
|
550
|
+
----------
|
|
551
|
+
column : str
|
|
552
|
+
The column to bin.
|
|
553
|
+
n_bins : int
|
|
554
|
+
The number of bins to create.
|
|
555
|
+
|
|
556
|
+
Returns
|
|
557
|
+
-------
|
|
558
|
+
pd.Series
|
|
559
|
+
Series with binned data.
|
|
560
|
+
"""
|
|
561
|
+
kmeans = KMeans(n_clusters=n_bins)
|
|
562
|
+
self.data[column + "_binned"] = kmeans.fit_predict(self.data[[column]])
|
|
563
|
+
print(f"KMeans Bins for {column}: {self.data[column + '_binned'].unique()}")
|
|
564
|
+
print(
|
|
565
|
+
f"KMeans Cluster Centers for {column}: {kmeans.cluster_centers_.flatten()}"
|
|
566
|
+
)
|
|
567
|
+
return self.data[column + "_binned"]
|
|
568
|
+
|
|
569
|
+
def _fixed_width_binning(self, column: str, n_bins: int) -> pd.Series:
|
|
570
|
+
"""
|
|
571
|
+
Apply fixed-width binning to the specified column.
|
|
572
|
+
|
|
573
|
+
Parameters
|
|
574
|
+
----------
|
|
575
|
+
column : str
|
|
576
|
+
The column to bin.
|
|
577
|
+
n_bins : int
|
|
578
|
+
The number of bins to create.
|
|
579
|
+
|
|
580
|
+
Returns
|
|
581
|
+
-------
|
|
582
|
+
pd.Series
|
|
583
|
+
Series with binned data.
|
|
584
|
+
"""
|
|
585
|
+
min_val = self.data[column].min()
|
|
586
|
+
max_val = self.data[column].max()
|
|
587
|
+
|
|
588
|
+
bin_edges = np.linspace(min_val, max_val, n_bins + 1)
|
|
589
|
+
bin_edges = np.ceil(bin_edges).astype(int)
|
|
590
|
+
|
|
591
|
+
print(f"Fixed Width Bins for {column}: {bin_edges}")
|
|
592
|
+
|
|
593
|
+
self.data[column + "_binned"] = np.digitize(
|
|
594
|
+
self.data[column], bin_edges, right=False
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
print(
|
|
598
|
+
f"Bin counts for {column} with fixed_width binning: {self.data[column + '_binned'].value_counts()}"
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
return self.data[column + "_binned"]
|
|
602
|
+
|
|
603
|
+
def _exponential_binning(self, column: str, n_bins: int) -> pd.Series:
|
|
604
|
+
"""
|
|
605
|
+
Apply exponential binning to the specified column.
|
|
606
|
+
|
|
607
|
+
Parameters
|
|
608
|
+
----------
|
|
609
|
+
column : str
|
|
610
|
+
The column to bin.
|
|
611
|
+
n_bins : int
|
|
612
|
+
The number of bins to create.
|
|
613
|
+
|
|
614
|
+
Returns
|
|
615
|
+
-------
|
|
616
|
+
pd.Series
|
|
617
|
+
Series with binned data.
|
|
618
|
+
"""
|
|
619
|
+
min_val = self.data[column].min()
|
|
620
|
+
max_val = self.data[column].max()
|
|
621
|
+
bins = [min_val]
|
|
622
|
+
for i in range(1, n_bins):
|
|
623
|
+
bins.append(
|
|
624
|
+
min_val
|
|
625
|
+
+ (math.exp(i) - 1) * (max_val - min_val) / (math.exp(n_bins) - 1)
|
|
626
|
+
)
|
|
627
|
+
bins.append(max_val)
|
|
628
|
+
self.data[column + "_binned"] = (
|
|
629
|
+
np.digitize(self.data[column], bins, right=True) - 1
|
|
630
|
+
)
|
|
631
|
+
print(f"Exponential Bins for {column}: {bins}")
|
|
632
|
+
return self.data[column + "_binned"]
|
|
633
|
+
|
|
634
|
+
def _quantile_binning(self, column: str, n_bins: int) -> pd.Series:
|
|
635
|
+
"""
|
|
636
|
+
Apply quantile binning to the specified column.
|
|
637
|
+
|
|
638
|
+
Parameters
|
|
639
|
+
----------
|
|
640
|
+
column : str
|
|
641
|
+
The column to bin.
|
|
642
|
+
n_bins : int
|
|
643
|
+
The number of bins to create.
|
|
644
|
+
|
|
645
|
+
Returns
|
|
646
|
+
-------
|
|
647
|
+
pd.Series
|
|
648
|
+
Series with binned data.
|
|
649
|
+
"""
|
|
650
|
+
try:
|
|
651
|
+
self.data[column + "_binned"] = pd.qcut(
|
|
652
|
+
self.data[column], q=n_bins, labels=False, duplicates="drop"
|
|
653
|
+
)
|
|
654
|
+
print(
|
|
655
|
+
f"Quantile Bins for {column}: {pd.qcut(self.data[column], q=n_bins, duplicates='drop').unique()}"
|
|
656
|
+
)
|
|
657
|
+
except ValueError:
|
|
658
|
+
self.data[column + "_binned"] = pd.qcut(
|
|
659
|
+
self.data[column].rank(method="first"),
|
|
660
|
+
q=n_bins,
|
|
661
|
+
labels=False,
|
|
662
|
+
duplicates="drop",
|
|
663
|
+
)
|
|
664
|
+
print(
|
|
665
|
+
f"Quantile Bins (with ranking) for {column}: {pd.qcut(self.data[column].rank(method='first'), q=n_bins, duplicates='drop').unique()}"
|
|
666
|
+
)
|
|
667
|
+
return self.data[column + "_binned"]
|
|
668
|
+
|
|
669
|
+
def _equal_frequency_binning(self, column: str, n_bins: int) -> pd.Series:
|
|
670
|
+
"""
|
|
671
|
+
Apply equal frequency binning to the specified column.
|
|
672
|
+
|
|
673
|
+
Parameters
|
|
674
|
+
----------
|
|
675
|
+
column : str
|
|
676
|
+
The column to bin.
|
|
677
|
+
n_bins : int
|
|
678
|
+
The number of bins to create.
|
|
679
|
+
|
|
680
|
+
Returns
|
|
681
|
+
-------
|
|
682
|
+
pd.Series
|
|
683
|
+
Series with binned data.
|
|
684
|
+
"""
|
|
685
|
+
try:
|
|
686
|
+
self.data[column + "_binned"] = pd.qcut(
|
|
687
|
+
self.data[column], q=n_bins, labels=False, duplicates="drop"
|
|
688
|
+
)
|
|
689
|
+
print(
|
|
690
|
+
f"Equal Frequency Bins for {column}: {pd.qcut(self.data[column], q=n_bins, duplicates='drop').unique()}"
|
|
691
|
+
)
|
|
692
|
+
except ValueError:
|
|
693
|
+
self.data[column + "_binned"] = pd.qcut(
|
|
694
|
+
self.data[column].rank(method="first"),
|
|
695
|
+
q=n_bins,
|
|
696
|
+
labels=False,
|
|
697
|
+
duplicates="drop",
|
|
698
|
+
)
|
|
699
|
+
print(
|
|
700
|
+
f"Equal Frequency Bins (with ranking) for {column}: {pd.qcut(self.data[column].rank(method='first'), q=n_bins, duplicates='drop').unique()}"
|
|
701
|
+
)
|
|
702
|
+
return self.data[column + "_binned"]
|
|
703
|
+
|
|
704
|
+
def binnings(
|
|
705
|
+
self,
|
|
706
|
+
methods: Literal[
|
|
707
|
+
"kmeans", "fixed_width", "exponential", "quantile", "equal_frequency"
|
|
708
|
+
] = "equal_frequency",
|
|
709
|
+
n_bins: int = 5,
|
|
710
|
+
state: Literal["prod", "dev"] = "prod",
|
|
711
|
+
) -> pd.DataFrame:
|
|
712
|
+
"""
|
|
713
|
+
Apply binning to low cardinality numerical columns using the specified methods.
|
|
714
|
+
|
|
715
|
+
Parameters
|
|
716
|
+
----------
|
|
717
|
+
methods : {'kmeans', 'fixed_width', 'exponential', 'quantile', 'equal_frequency'}, optional
|
|
718
|
+
The binning methods to use, by default 'equal_frequency'.
|
|
719
|
+
n_bins : int, optional
|
|
720
|
+
The number of bins to create, by default 5.
|
|
721
|
+
state : {'prod', 'dev'}, optional
|
|
722
|
+
The state is to indicate whether we test the function or not, if testing, plots are not shown.
|
|
723
|
+
Returns
|
|
724
|
+
-------
|
|
725
|
+
pd.DataFrame
|
|
726
|
+
DataFrame with binned columns.
|
|
727
|
+
"""
|
|
728
|
+
low_cardinality_numeric = self.perform_low_cardinality_checks_numerical()
|
|
729
|
+
print(f"Low cardinality numerical features: {low_cardinality_numeric}")
|
|
730
|
+
|
|
731
|
+
for col in low_cardinality_numeric:
|
|
732
|
+
if state == "prod":
|
|
733
|
+
self.plot_distribution(col, "before transform", binned=False)
|
|
734
|
+
|
|
735
|
+
print(f"\nApplying {methods} binning for column: {col}")
|
|
736
|
+
|
|
737
|
+
if methods == "kmeans":
|
|
738
|
+
self.data[col + "_binned"] = self._kmeans_binning(col, n_bins)
|
|
739
|
+
elif methods == "fixed_width":
|
|
740
|
+
self.data[col + "_binned"] = self._fixed_width_binning(col, n_bins)
|
|
741
|
+
elif methods == "exponential":
|
|
742
|
+
self.data[col + "_binned"] = self._exponential_binning(col, n_bins)
|
|
743
|
+
elif methods == "quantile":
|
|
744
|
+
self.data[col + "_binned"] = self._quantile_binning(col, n_bins)
|
|
745
|
+
elif methods == "equal_frequency":
|
|
746
|
+
self.data[col + "_binned"] = self._equal_frequency_binning(col, n_bins)
|
|
747
|
+
else:
|
|
748
|
+
raise ValueError("Please check the name of the method")
|
|
749
|
+
|
|
750
|
+
print(
|
|
751
|
+
f"Bin counts for {col} with {methods} binning: {self.data[col + '_binned'].value_counts()}"
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
if state == "prod":
|
|
755
|
+
self.plot_distribution(col, method_name=methods, binned=True)
|
|
756
|
+
|
|
757
|
+
return self.data
|
|
758
|
+
|
|
759
|
+
def plot_distribution(
|
|
760
|
+
self,
|
|
761
|
+
column: str,
|
|
762
|
+
method_name: str,
|
|
763
|
+
binned: bool = False,
|
|
764
|
+
log_scale: bool = False,
|
|
765
|
+
) -> None:
|
|
766
|
+
"""
|
|
767
|
+
Plot the distribution of the specified column.
|
|
768
|
+
|
|
769
|
+
Parameters
|
|
770
|
+
----------
|
|
771
|
+
column : str
|
|
772
|
+
The column to plot.
|
|
773
|
+
method_name : str
|
|
774
|
+
The name of the method used for binning.
|
|
775
|
+
binned : bool, optional
|
|
776
|
+
Whether the data has been binned, by default False.
|
|
777
|
+
log_scale : bool, optional
|
|
778
|
+
Whether to use a log scale for the y-axis, by default False.
|
|
779
|
+
"""
|
|
780
|
+
plt.figure(figsize=(10, 6))
|
|
781
|
+
if binned:
|
|
782
|
+
binned_data = self.data[column + "_binned"]
|
|
783
|
+
bin_counts = binned_data.value_counts().sort_index()
|
|
784
|
+
|
|
785
|
+
bars = plt.bar(
|
|
786
|
+
bin_counts.index,
|
|
787
|
+
list(bin_counts.values),
|
|
788
|
+
color="blue",
|
|
789
|
+
alpha=0.7,
|
|
790
|
+
label="Binned",
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
plt.xticks(bin_counts.index, bin_counts.index.to_list(), rotation=45)
|
|
794
|
+
|
|
795
|
+
plt.title(
|
|
796
|
+
f"Distribution of {column} (Binned) with {method_name.capitalize()} Method"
|
|
797
|
+
)
|
|
798
|
+
plt.xlabel(f"{column}")
|
|
799
|
+
|
|
800
|
+
for bar, count in zip(bars, bin_counts.values):
|
|
801
|
+
plt.text(
|
|
802
|
+
bar.get_x() + bar.get_width() / 2,
|
|
803
|
+
bar.get_height() / 2,
|
|
804
|
+
f"{count}",
|
|
805
|
+
ha="center",
|
|
806
|
+
va="center",
|
|
807
|
+
color="white",
|
|
808
|
+
fontsize=10,
|
|
809
|
+
weight="bold",
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
else:
|
|
813
|
+
plt.hist(
|
|
814
|
+
self.data[column], bins="auto", alpha=0.7, color="red", label="Original"
|
|
815
|
+
)
|
|
816
|
+
plt.title(f"Distribution of {column}")
|
|
817
|
+
plt.xlabel(column)
|
|
818
|
+
|
|
819
|
+
plt.ylabel("Frequency")
|
|
820
|
+
if log_scale:
|
|
821
|
+
plt.yscale("log")
|
|
822
|
+
plt.legend()
|
|
823
|
+
plt.tight_layout()
|
|
824
|
+
plt.show()
|
|
825
|
+
|
|
826
|
+
def _encoding_categorical_one_hot(self, data: pd.DataFrame) -> pd.DataFrame:
|
|
827
|
+
"""
|
|
828
|
+
Perform one-hot encoding for the specified categorical columns.
|
|
829
|
+
|
|
830
|
+
Parameters
|
|
831
|
+
----------
|
|
832
|
+
data : pd.DataFrame
|
|
833
|
+
The data containing the categorical columns to encode.
|
|
834
|
+
|
|
835
|
+
Returns
|
|
836
|
+
-------
|
|
837
|
+
pd.DataFrame
|
|
838
|
+
DataFrame with one-hot encoded columns.
|
|
839
|
+
"""
|
|
840
|
+
encoder = OneHotEncoder(
|
|
841
|
+
sparse_output=False, drop="first", handle_unknown="ignore"
|
|
842
|
+
)
|
|
843
|
+
encoded_array = encoder.fit_transform(data[self.encode_list])
|
|
844
|
+
|
|
845
|
+
encoded_df = pd.DataFrame(
|
|
846
|
+
encoded_array,
|
|
847
|
+
columns=encoder.get_feature_names_out(self.encode_list),
|
|
848
|
+
index=data.index,
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
data = data.drop(columns=self.encode_list)
|
|
852
|
+
data = pd.concat([data, encoded_df], axis=1)
|
|
853
|
+
|
|
854
|
+
return data
|
|
855
|
+
|
|
856
|
+
def _encoding_categorical_distribution(self, data: pd.DataFrame) -> pd.DataFrame:
|
|
857
|
+
"""
|
|
858
|
+
Perform distribution encoding for the specified categorical columns.
|
|
859
|
+
|
|
860
|
+
Parameters
|
|
861
|
+
----------
|
|
862
|
+
data : pd.DataFrame
|
|
863
|
+
The data containing the categorical columns to encode.
|
|
864
|
+
|
|
865
|
+
Returns
|
|
866
|
+
-------
|
|
867
|
+
pd.DataFrame
|
|
868
|
+
DataFrame with distribution encoded columns.
|
|
869
|
+
"""
|
|
870
|
+
for column in self.encode_list:
|
|
871
|
+
counts = data[column].value_counts(normalize=True)
|
|
872
|
+
data[column] = data[column].map(counts)
|
|
873
|
+
return data
|
|
874
|
+
|
|
875
|
+
def _encoding_categorical_woe(self, data: pd.DataFrame) -> pd.DataFrame:
|
|
876
|
+
"""
|
|
877
|
+
Perform Weight of Evidence (WOE) encoding for the specified categorical columns.
|
|
878
|
+
|
|
879
|
+
Parameters
|
|
880
|
+
----------
|
|
881
|
+
data : pd.DataFrame
|
|
882
|
+
The data containing the categorical columns to encode.
|
|
883
|
+
|
|
884
|
+
Returns
|
|
885
|
+
-------
|
|
886
|
+
pd.DataFrame
|
|
887
|
+
DataFrame with WOE encoded columns.
|
|
888
|
+
"""
|
|
889
|
+
for column in self.encode_list:
|
|
890
|
+
target = data[self.target_column]
|
|
891
|
+
df = pd.DataFrame({column: data[column], "target": target})
|
|
892
|
+
|
|
893
|
+
total_good = df["target"].sum()
|
|
894
|
+
total_bad = df["target"].count() - total_good
|
|
895
|
+
|
|
896
|
+
grouped = df.groupby(column, observed=False)["target"].agg(["sum", "count"])
|
|
897
|
+
grouped["good"] = grouped["sum"]
|
|
898
|
+
grouped["bad"] = grouped["count"] - grouped["sum"]
|
|
899
|
+
|
|
900
|
+
epsilon = 1e-10
|
|
901
|
+
grouped["good_pct"] = (grouped["good"] + epsilon) / (total_good + epsilon)
|
|
902
|
+
grouped["bad_pct"] = (grouped["bad"] + epsilon) / (total_bad + epsilon)
|
|
903
|
+
|
|
904
|
+
grouped["woe"] = np.log(grouped["good_pct"] / grouped["bad_pct"])
|
|
905
|
+
|
|
906
|
+
woe_dict = grouped["woe"].to_dict()
|
|
907
|
+
|
|
908
|
+
data[column] = data[column].astype(str).map(woe_dict).fillna(0)
|
|
909
|
+
return data
|
|
910
|
+
|
|
911
|
+
def _encoding_categorical_catboost(
|
|
912
|
+
self, data: pd.DataFrame, sigma_value: float = 0.05
|
|
913
|
+
) -> pd.DataFrame:
|
|
914
|
+
"""
|
|
915
|
+
Perform CatBoost encoding for the specified categorical columns.
|
|
916
|
+
|
|
917
|
+
Parameters
|
|
918
|
+
----------
|
|
919
|
+
data : pd.DataFrame
|
|
920
|
+
The DataFrame containing the data to encode.
|
|
921
|
+
sigma_value : float, optional
|
|
922
|
+
The regularization parameter for the encoding, by default 0.05.
|
|
923
|
+
|
|
924
|
+
Returns
|
|
925
|
+
-------
|
|
926
|
+
pd.DataFrame
|
|
927
|
+
DataFrame with CatBoost encoded columns.
|
|
928
|
+
"""
|
|
929
|
+
encoder = CatBoostEncoder(cols=self.encode_list, sigma=sigma_value)
|
|
930
|
+
data[self.encode_list] = encoder.fit_transform(
|
|
931
|
+
data[self.encode_list], data[self.target_column]
|
|
932
|
+
)
|
|
933
|
+
return data
|
|
934
|
+
|
|
935
|
+
def encoding_categorical(
|
|
936
|
+
self,
|
|
937
|
+
method: Literal["one_hot", "distribution", "woe", "catboost"] = "one_hot",
|
|
938
|
+
) -> pd.DataFrame:
|
|
939
|
+
"""
|
|
940
|
+
Perform encoding for the specified categorical columns using the chosen method.
|
|
941
|
+
|
|
942
|
+
The columns to be encoded are specified during the initialization of the FeatureEngineering class.
|
|
943
|
+
|
|
944
|
+
Parameters
|
|
945
|
+
----------
|
|
946
|
+
method : {'one_hot', 'distribution', 'woe', 'catboost'}, optional
|
|
947
|
+
The encoding method to use, by default 'one_hot'.
|
|
948
|
+
"""
|
|
949
|
+
if self.encode_list is None:
|
|
950
|
+
print("No columns specified for encoding.")
|
|
951
|
+
|
|
952
|
+
missing_columns = [
|
|
953
|
+
col for col in self.encode_list if col not in self.data.columns
|
|
954
|
+
]
|
|
955
|
+
if missing_columns:
|
|
956
|
+
raise ValueError(
|
|
957
|
+
f"The following columns are not in the DataFrame: {missing_columns}"
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
data = self.data.copy()
|
|
961
|
+
|
|
962
|
+
if method == "one_hot":
|
|
963
|
+
data = self._encoding_categorical_one_hot(data)
|
|
964
|
+
print(f"One-hot encoding performed on columns: {self.encode_list}")
|
|
965
|
+
# Get the names of the new encoded columns
|
|
966
|
+
new_columns = [
|
|
967
|
+
col for col in data.columns if col.startswith(tuple(self.encode_list))
|
|
968
|
+
]
|
|
969
|
+
print("\nHead of encoded columns:")
|
|
970
|
+
print(data[new_columns].head())
|
|
971
|
+
elif method == "distribution":
|
|
972
|
+
data = self._encoding_categorical_distribution(data)
|
|
973
|
+
print(f"Distribution encoding performed on columns: {self.encode_list}")
|
|
974
|
+
print("\nHead of encoded columns:")
|
|
975
|
+
print(data[self.encode_list].head())
|
|
976
|
+
elif method == "woe":
|
|
977
|
+
data = self._encoding_categorical_woe(data)
|
|
978
|
+
print(f"WOE encoding performed on columns: {self.encode_list}")
|
|
979
|
+
print("\nHead of encoded columns:")
|
|
980
|
+
print(data[self.encode_list].head())
|
|
981
|
+
elif method == "catboost":
|
|
982
|
+
data = self._encoding_categorical_catboost(data)
|
|
983
|
+
print(f"CatBoost encoding performed on columns: {self.encode_list}")
|
|
984
|
+
print("\nHead of encoded columns:")
|
|
985
|
+
print(data[self.encode_list].head())
|
|
986
|
+
else:
|
|
987
|
+
raise ValueError(
|
|
988
|
+
f"Encoding method '{method}' is not supported. Choose from 'one_hot', 'distribution', 'woe', or 'catboost'."
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
self.data = data
|
|
992
|
+
self.data.info()
|
|
993
|
+
self._log_transformation("encoding_categorical", {"method": method})
|
|
994
|
+
return self.data
|
|
995
|
+
|
|
996
|
+
####### FEATURE ENGINEERING: OPTIONAL FUNCTIONS #######
|
|
997
|
+
def search_pca_components(self):
|
|
998
|
+
"""
|
|
999
|
+
Search for the optimal number of principal components to retain.
|
|
1000
|
+
"""
|
|
1001
|
+
numerical_data = self.data[self.numerical_columns]
|
|
1002
|
+
|
|
1003
|
+
pca = PCA()
|
|
1004
|
+
pca.fit(numerical_data)
|
|
1005
|
+
|
|
1006
|
+
explained_variance = pca.explained_variance_ratio_
|
|
1007
|
+
cumulative_variance = explained_variance.cumsum()
|
|
1008
|
+
|
|
1009
|
+
plt.figure(figsize=(10, 6))
|
|
1010
|
+
plt.plot(
|
|
1011
|
+
range(1, len(explained_variance) + 1),
|
|
1012
|
+
explained_variance,
|
|
1013
|
+
marker="o",
|
|
1014
|
+
label="Explained Variance",
|
|
1015
|
+
)
|
|
1016
|
+
plt.plot(
|
|
1017
|
+
range(1, len(cumulative_variance) + 1),
|
|
1018
|
+
cumulative_variance,
|
|
1019
|
+
marker="o",
|
|
1020
|
+
label="Cumulative Variance",
|
|
1021
|
+
)
|
|
1022
|
+
plt.title("Explained Variance by Principal Components")
|
|
1023
|
+
plt.xlabel("Number of Principal Components")
|
|
1024
|
+
plt.ylabel("Variance Explained")
|
|
1025
|
+
plt.legend()
|
|
1026
|
+
plt.grid(True)
|
|
1027
|
+
plt.show()
|
|
1028
|
+
|
|
1029
|
+
for i, (ev, cv) in enumerate(zip(explained_variance, cumulative_variance), 1):
|
|
1030
|
+
print(
|
|
1031
|
+
f"Principal Component {i}: Explained Variance = {ev:.4f}, Cumulative Variance = {cv:.4f}"
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
print(
|
|
1035
|
+
"Use the cumulative variance to decide the number of components to retain and then call implement_pca with the desired number of components."
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
def implement_pca(self, n_components: int):
|
|
1039
|
+
"""
|
|
1040
|
+
Implement PCA with the specified number of components.
|
|
1041
|
+
|
|
1042
|
+
Parameters
|
|
1043
|
+
----------
|
|
1044
|
+
n_components : int
|
|
1045
|
+
The number of principal components to retain.
|
|
1046
|
+
"""
|
|
1047
|
+
numerical_data = self.data[self.numerical_columns]
|
|
1048
|
+
|
|
1049
|
+
pca = PCA(n_components=n_components)
|
|
1050
|
+
principal_components = pca.fit_transform(numerical_data)
|
|
1051
|
+
|
|
1052
|
+
pca_columns = [f"PC{i+1}" for i in range(n_components)]
|
|
1053
|
+
pca_df = pd.DataFrame(
|
|
1054
|
+
principal_components, columns=pca_columns, index=self.data.index
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
self.data.drop(columns=self.numerical_columns, inplace=True)
|
|
1058
|
+
self.data[pca_columns] = pca_df
|
|
1059
|
+
|
|
1060
|
+
print(
|
|
1061
|
+
f"PCA has been applied, reducing the data to {n_components} principal components."
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
def search_svd_components(self, max_components: int):
|
|
1065
|
+
"""
|
|
1066
|
+
Search for the optimal number of singular value components to retain.
|
|
1067
|
+
|
|
1068
|
+
Parameters
|
|
1069
|
+
----------
|
|
1070
|
+
max_components : int
|
|
1071
|
+
The maximum number of components to evaluate.
|
|
1072
|
+
"""
|
|
1073
|
+
numerical_data = self.data[self.numerical_columns]
|
|
1074
|
+
|
|
1075
|
+
if max_components <= 0:
|
|
1076
|
+
max_components = min(numerical_data.shape[1], numerical_data.shape[0])
|
|
1077
|
+
|
|
1078
|
+
explained_variances = []
|
|
1079
|
+
cumulative_variances = []
|
|
1080
|
+
|
|
1081
|
+
for n in range(1, max_components + 1):
|
|
1082
|
+
svd = TruncatedSVD(n_components=n)
|
|
1083
|
+
svd.fit(numerical_data)
|
|
1084
|
+
explained_variance = svd.explained_variance_ratio_
|
|
1085
|
+
cumulative_variance = explained_variance.sum()
|
|
1086
|
+
explained_variances.append(explained_variance[-1])
|
|
1087
|
+
cumulative_variances.append(cumulative_variance)
|
|
1088
|
+
print(
|
|
1089
|
+
f"Number of components: {n}, Explained Variance: {explained_variance[-1]:.4f}, Cumulative Variance: {cumulative_variance:.4f}"
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
plt.figure(figsize=(10, 6))
|
|
1093
|
+
plt.plot(
|
|
1094
|
+
range(1, max_components + 1),
|
|
1095
|
+
explained_variances,
|
|
1096
|
+
marker="o",
|
|
1097
|
+
label="Explained Variance",
|
|
1098
|
+
)
|
|
1099
|
+
plt.plot(
|
|
1100
|
+
range(1, max_components + 1),
|
|
1101
|
+
cumulative_variances,
|
|
1102
|
+
marker="o",
|
|
1103
|
+
label="Cumulative Variance",
|
|
1104
|
+
)
|
|
1105
|
+
plt.title("Explained Variance by SVD Components")
|
|
1106
|
+
plt.xlabel("Number of SVD Components")
|
|
1107
|
+
plt.ylabel("Variance Explained")
|
|
1108
|
+
plt.legend()
|
|
1109
|
+
plt.grid(True)
|
|
1110
|
+
plt.show()
|
|
1111
|
+
|
|
1112
|
+
print(
|
|
1113
|
+
"Use the explained and cumulative variance to decide the number of components to retain, then call implement_svd with the desired number of components."
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
def implement_svd(self, n_components: int):
|
|
1117
|
+
"""
|
|
1118
|
+
Implement Singular Value Decomposition (SVD) with the specified number of components.
|
|
1119
|
+
|
|
1120
|
+
Parameters
|
|
1121
|
+
----------
|
|
1122
|
+
n_components : int
|
|
1123
|
+
The number of singular value components to retain.
|
|
1124
|
+
"""
|
|
1125
|
+
numerical_data = self.data[self.numerical_columns]
|
|
1126
|
+
|
|
1127
|
+
svd = TruncatedSVD(n_components=n_components)
|
|
1128
|
+
svd_components = svd.fit_transform(numerical_data)
|
|
1129
|
+
|
|
1130
|
+
svd_columns = [f"SVD{i+1}" for i in range(n_components)]
|
|
1131
|
+
svd_df = pd.DataFrame(
|
|
1132
|
+
svd_components, columns=svd_columns, index=self.data.index
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
self.data.drop(columns=self.numerical_columns, inplace=True)
|
|
1136
|
+
self.data[svd_columns] = svd_df
|
|
1137
|
+
|
|
1138
|
+
print(
|
|
1139
|
+
f"SVD has been applied, reducing the data to {n_components} singular value components."
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
def evaluate_lda_components(self):
|
|
1143
|
+
"""
|
|
1144
|
+
Evaluate the performance of Linear Discriminant Analysis (LDA) with different numbers of components.
|
|
1145
|
+
"""
|
|
1146
|
+
numerical_data = self.data[self.numerical_columns]
|
|
1147
|
+
target_data = self.data[self.target_column]
|
|
1148
|
+
|
|
1149
|
+
num_classes = len(target_data.unique())
|
|
1150
|
+
num_features = numerical_data.shape[1]
|
|
1151
|
+
max_components = min(num_classes - 1, num_features)
|
|
1152
|
+
|
|
1153
|
+
scores = []
|
|
1154
|
+
for n in range(1, max_components + 1):
|
|
1155
|
+
lda = LDA(n_components=n)
|
|
1156
|
+
score = cross_val_score(lda, numerical_data, target_data, cv=5).mean()
|
|
1157
|
+
scores.append(score)
|
|
1158
|
+
print(f"Number of components: {n}, Cross-validation score: {score:.4f}")
|
|
1159
|
+
|
|
1160
|
+
plt.figure(figsize=(10, 6))
|
|
1161
|
+
plt.plot(range(1, max_components + 1), scores, marker="o")
|
|
1162
|
+
plt.title("LDA Components vs. Cross-Validation Score")
|
|
1163
|
+
plt.xlabel("Number of LDA Components")
|
|
1164
|
+
plt.ylabel("Cross-Validation Score")
|
|
1165
|
+
plt.grid(True)
|
|
1166
|
+
plt.show()
|
|
1167
|
+
|
|
1168
|
+
print(
|
|
1169
|
+
"Use the plot to decide the optimal number of components and then call implement_lda with the desired number of components."
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
def implement_lda(self, n_components: int):
|
|
1173
|
+
"""
|
|
1174
|
+
Implement Linear Discriminant Analysis (LDA) with the specified number of components.
|
|
1175
|
+
|
|
1176
|
+
Parameters
|
|
1177
|
+
----------
|
|
1178
|
+
n_components : int
|
|
1179
|
+
The number of LDA components to retain.
|
|
1180
|
+
"""
|
|
1181
|
+
numerical_data = self.data[self.numerical_columns]
|
|
1182
|
+
target_data = self.data[self.target_column]
|
|
1183
|
+
|
|
1184
|
+
lda = LDA(n_components=n_components)
|
|
1185
|
+
lda_components = lda.fit_transform(numerical_data, target_data)
|
|
1186
|
+
|
|
1187
|
+
lda_columns = [f"LD{i+1}" for i in range(n_components)]
|
|
1188
|
+
lda_df = pd.DataFrame(
|
|
1189
|
+
lda_components, columns=lda_columns, index=self.data.index
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
self.data.drop(columns=self.numerical_columns, inplace=True)
|
|
1193
|
+
self.data[lda_columns] = lda_df
|
|
1194
|
+
|
|
1195
|
+
print(
|
|
1196
|
+
f"LDA has been applied, reducing the data to {n_components} linear discriminants."
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
def apply_winsorization(
|
|
1200
|
+
self, selected_columns: List[str], percentile: float = 0.05
|
|
1201
|
+
) -> pd.DataFrame:
|
|
1202
|
+
"""
|
|
1203
|
+
Apply winsorization to numerical columns to limit extreme values.
|
|
1204
|
+
|
|
1205
|
+
Parameters
|
|
1206
|
+
----------
|
|
1207
|
+
selected_columns : List[str]
|
|
1208
|
+
List of columns to apply winsorization to.
|
|
1209
|
+
percentile : float, optional
|
|
1210
|
+
The percentage of data to be considered as extreme values on each side, by default 0.05.
|
|
1211
|
+
|
|
1212
|
+
Returns
|
|
1213
|
+
-------
|
|
1214
|
+
pd.DataFrame
|
|
1215
|
+
DataFrame with winsorized columns.
|
|
1216
|
+
"""
|
|
1217
|
+
if len(selected_columns) == 0:
|
|
1218
|
+
selected_columns = self.numerical_columns
|
|
1219
|
+
|
|
1220
|
+
for column in selected_columns:
|
|
1221
|
+
if column == self.target_column:
|
|
1222
|
+
print(f"{column} is the target column. Skipping winsorization.")
|
|
1223
|
+
continue
|
|
1224
|
+
|
|
1225
|
+
lower_bound = np.percentile(self.data[column], percentile * 100)
|
|
1226
|
+
upper_bound = np.percentile(self.data[column], (1 - percentile) * 100)
|
|
1227
|
+
self.data[column] = np.clip(self.data[column], lower_bound, upper_bound)
|
|
1228
|
+
|
|
1229
|
+
print(f"\n{' WINSORIZATION RESULTS '.center(100, '=')}")
|
|
1230
|
+
print(
|
|
1231
|
+
f"Winsorization applied with {percentile * 100:.1f}% capping on each side."
|
|
1232
|
+
)
|
|
1233
|
+
print("=" * 100)
|
|
1234
|
+
|
|
1235
|
+
# Prepare the summary statistics
|
|
1236
|
+
summary = self.data[selected_columns].describe()
|
|
1237
|
+
|
|
1238
|
+
# Determine the width for each column based on the longest column name
|
|
1239
|
+
col_width = max(max(len(col) for col in selected_columns), 15) + 2
|
|
1240
|
+
|
|
1241
|
+
# Print column names
|
|
1242
|
+
print(
|
|
1243
|
+
f"{'Statistic':<15}"
|
|
1244
|
+
+ "".join(f"{col:^{col_width}}" for col in selected_columns)
|
|
1245
|
+
)
|
|
1246
|
+
print("-" * (15 + col_width * len(selected_columns)))
|
|
1247
|
+
|
|
1248
|
+
# Print the summary statistics
|
|
1249
|
+
for stat in summary.index:
|
|
1250
|
+
print(
|
|
1251
|
+
f"{stat:<15}"
|
|
1252
|
+
+ "".join(
|
|
1253
|
+
f"{summary.loc[stat, col]:^{col_width}.6f}"
|
|
1254
|
+
for col in selected_columns
|
|
1255
|
+
)
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
print("=" * (15 + col_width * len(selected_columns)))
|
|
1259
|
+
|
|
1260
|
+
self._log_transformation(
|
|
1261
|
+
"winsorization",
|
|
1262
|
+
{"selected_columns": selected_columns, "percentile": percentile},
|
|
1263
|
+
)
|
|
1264
|
+
return self.data
|
|
1265
|
+
|
|
1266
|
+
percentile_capping = apply_winsorization
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
'''
|
|
1270
|
+
def apply_winsorization(
|
|
1271
|
+
self, selected_columns: List[str], percentile: float = 0.05
|
|
1272
|
+
) -> pd.DataFrame:
|
|
1273
|
+
"""
|
|
1274
|
+
Apply winsorization to numerical columns to limit extreme values.
|
|
1275
|
+
|
|
1276
|
+
Parameters
|
|
1277
|
+
----------
|
|
1278
|
+
selected_columns : List[str]
|
|
1279
|
+
List of columns to apply winsorization to.
|
|
1280
|
+
percentile : float, optional
|
|
1281
|
+
The percentage of data to be considered as extreme values on each side, by default 0.05.
|
|
1282
|
+
|
|
1283
|
+
Returns
|
|
1284
|
+
-------
|
|
1285
|
+
pd.DataFrame
|
|
1286
|
+
DataFrame with winsorized columns.
|
|
1287
|
+
"""
|
|
1288
|
+
if len(selected_columns) == 0:
|
|
1289
|
+
selected_columns = self.numerical_columns
|
|
1290
|
+
|
|
1291
|
+
for column in selected_columns:
|
|
1292
|
+
if column == self.target_column:
|
|
1293
|
+
print(f"{column} is the target column. Skipping winsorization.")
|
|
1294
|
+
continue
|
|
1295
|
+
|
|
1296
|
+
lower_bound = np.percentile(self.data[column], percentile * 100)
|
|
1297
|
+
upper_bound = np.percentile(self.data[column], (1 - percentile) * 100)
|
|
1298
|
+
self.data[column] = np.clip(self.data[column], lower_bound, upper_bound)
|
|
1299
|
+
|
|
1300
|
+
print(f"\nWinsorization applied with {percentile * 100}% capping on each side.")
|
|
1301
|
+
print(self.data[selected_columns].describe())
|
|
1302
|
+
|
|
1303
|
+
self._log_transformation(
|
|
1304
|
+
"winsorization",
|
|
1305
|
+
{"selected_columns": selected_columns, "percentile": percentile},
|
|
1306
|
+
)
|
|
1307
|
+
return self.data
|
|
1308
|
+
|
|
1309
|
+
percentile_capping = apply_winsorization
|
|
1310
|
+
'''
|