mlfox 1.0.1__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.
- mlfox/__init__.py +52 -0
- mlfox/column_intelligence.py +315 -0
- mlfox/encoding.py +256 -0
- mlfox/evaluation.py +58 -0
- mlfox/explainability.py +149 -0
- mlfox/feature_engineering.py +184 -0
- mlfox/feature_selection.py +396 -0
- mlfox/ingestion.py +57 -0
- mlfox/missing_intelligence.py +267 -0
- mlfox/mlfox.py +249 -0
- mlfox/model_selection.py +139 -0
- mlfox/persistence.py +51 -0
- mlfox/pipeline.py +80 -0
- mlfox/profiler.py +431 -0
- mlfox/report_export.py +102 -0
- mlfox/splitter.py +149 -0
- mlfox/test.py +112 -0
- mlfox/tuning.py +98 -0
- mlfox-1.0.1.dist-info/METADATA +274 -0
- mlfox-1.0.1.dist-info/RECORD +23 -0
- mlfox-1.0.1.dist-info/WHEEL +5 -0
- mlfox-1.0.1.dist-info/licenses/LICENSE +21 -0
- mlfox-1.0.1.dist-info/top_level.txt +1 -0
mlfox/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from .ingestion import load_dataset
|
|
2
|
+
from .profiler import DatasetProfile, profile_dataset, detect_task, is_discrete_numeric_multiclass
|
|
3
|
+
from .splitter import DatasetSplit, split_dataset
|
|
4
|
+
from .feature_selection import (
|
|
5
|
+
FeatureSelectionIntelligence,
|
|
6
|
+
FeatureSelectionReport
|
|
7
|
+
)
|
|
8
|
+
from .column_intelligence import (
|
|
9
|
+
ColumnIntelligenceReport,
|
|
10
|
+
analyze_columns
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from .missing_intelligence import (
|
|
14
|
+
MissingValueIntelligence,
|
|
15
|
+
MissingValueReport
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from .feature_engineering import (
|
|
19
|
+
FeatureEngineeringIntelligence,
|
|
20
|
+
FeatureEngineeringReport
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from .encoding import (
|
|
24
|
+
EncodingScalingIntelligence,
|
|
25
|
+
EncodingReport
|
|
26
|
+
)
|
|
27
|
+
from .pipeline import FoldAwarePreprocessor
|
|
28
|
+
from .model_selection import ModelSelectionEngine
|
|
29
|
+
from .tuning import HyperparameterTuner
|
|
30
|
+
from .mlfox import MLFOX, mlfox
|
|
31
|
+
from .persistence import load_model
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"load_dataset",
|
|
35
|
+
"DatasetProfile",
|
|
36
|
+
"profile_dataset",
|
|
37
|
+
"detect_task",
|
|
38
|
+
"is_discrete_numeric_multiclass",
|
|
39
|
+
"DatasetSplit",
|
|
40
|
+
"split_dataset",
|
|
41
|
+
"ColumnIntelligenceReport",
|
|
42
|
+
"analyze_columns",
|
|
43
|
+
"MissingValueIntelligence",
|
|
44
|
+
"MissingValueReport",
|
|
45
|
+
"FeatureEngineeringIntelligence",
|
|
46
|
+
"FeatureEngineeringReport",
|
|
47
|
+
"EncodingScalingIntelligence",
|
|
48
|
+
"EncodingReport",
|
|
49
|
+
"FeatureSelectionIntelligence",
|
|
50
|
+
"FeatureSelectionReport"
|
|
51
|
+
, "FoldAwarePreprocessor", "ModelSelectionEngine", "HyperparameterTuner", "MLFOX", "mlfox", "load_model"
|
|
52
|
+
]
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
|
|
2
|
+
from dataclasses import dataclass, asdict
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ColumnIntelligenceReport:
|
|
10
|
+
numerical_columns: list
|
|
11
|
+
categorical_columns: list
|
|
12
|
+
datetime_columns: list
|
|
13
|
+
other_columns: list
|
|
14
|
+
constant_columns: list
|
|
15
|
+
duplicate_columns: list
|
|
16
|
+
possible_identifiers: list
|
|
17
|
+
high_missing_columns: list
|
|
18
|
+
high_cardinality_columns: list
|
|
19
|
+
high_correlation_pairs: list
|
|
20
|
+
possible_target_leakage: list
|
|
21
|
+
column_details: dict
|
|
22
|
+
warnings: list
|
|
23
|
+
|
|
24
|
+
def to_dict(self):
|
|
25
|
+
return asdict(self)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_identifier_name(column):
|
|
29
|
+
name = str(column).strip().lower()
|
|
30
|
+
|
|
31
|
+
# Pandas-generated CSV index columns
|
|
32
|
+
if re.fullmatch(r"unnamed(?::\s*\d+)?", name):
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
compact = re.sub(r"[^a-z0-9]", "", name)
|
|
36
|
+
tokens = re.split(r"[^a-z0-9]+", name)
|
|
37
|
+
|
|
38
|
+
identifier_tokens = {
|
|
39
|
+
"id", "uuid", "identifier", "index",
|
|
40
|
+
"record", "row", "serial"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
any(token in identifier_tokens for token in tokens)
|
|
45
|
+
or compact.endswith("id")
|
|
46
|
+
or compact in {
|
|
47
|
+
"index", "rownumber", "serialnumber"
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_one_to_one_mapping(feature, target, max_levels=20):
|
|
53
|
+
paired = pd.DataFrame({
|
|
54
|
+
"feature": feature,
|
|
55
|
+
"target": target
|
|
56
|
+
}).dropna()
|
|
57
|
+
|
|
58
|
+
if paired.empty:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
feature_count = paired["feature"].nunique()
|
|
62
|
+
target_count = paired["target"].nunique()
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
feature_count < 2
|
|
66
|
+
or feature_count > max_levels
|
|
67
|
+
or feature_count != target_count
|
|
68
|
+
):
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
feature_mapping = (
|
|
72
|
+
paired.groupby("feature")["target"].nunique()
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
target_mapping = (
|
|
76
|
+
paired.groupby("target")["feature"].nunique()
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
feature_mapping.max() == 1
|
|
81
|
+
and target_mapping.max() == 1
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def analyze_columns(
|
|
86
|
+
X_train,
|
|
87
|
+
y_train=None,
|
|
88
|
+
target=None,
|
|
89
|
+
missing_threshold=0.50,
|
|
90
|
+
max_categories=50,
|
|
91
|
+
correlation_threshold=0.98
|
|
92
|
+
):
|
|
93
|
+
if not isinstance(X_train, pd.DataFrame):
|
|
94
|
+
raise TypeError("X_train must be a pandas DataFrame.")
|
|
95
|
+
|
|
96
|
+
if X_train.empty:
|
|
97
|
+
raise ValueError("Training features are empty.")
|
|
98
|
+
|
|
99
|
+
if not 0 < missing_threshold <= 1:
|
|
100
|
+
raise ValueError("missing_threshold must be between 0 and 1.")
|
|
101
|
+
|
|
102
|
+
if max_categories < 2:
|
|
103
|
+
raise ValueError("max_categories must be at least 2.")
|
|
104
|
+
|
|
105
|
+
if not 0 < correlation_threshold <= 1:
|
|
106
|
+
raise ValueError("correlation_threshold must be between 0 and 1.")
|
|
107
|
+
|
|
108
|
+
if not X_train.columns.is_unique:
|
|
109
|
+
raise ValueError("Duplicate feature column names detected.")
|
|
110
|
+
|
|
111
|
+
if y_train is not None:
|
|
112
|
+
if not isinstance(y_train, pd.Series):
|
|
113
|
+
raise TypeError("y_train must be a pandas Series.")
|
|
114
|
+
|
|
115
|
+
if not X_train.index.equals(y_train.index):
|
|
116
|
+
raise ValueError("X_train and y_train indices must match.")
|
|
117
|
+
|
|
118
|
+
numerical = []
|
|
119
|
+
categorical = []
|
|
120
|
+
datetime = []
|
|
121
|
+
other = []
|
|
122
|
+
|
|
123
|
+
constant = []
|
|
124
|
+
duplicates = []
|
|
125
|
+
identifiers = []
|
|
126
|
+
high_missing = []
|
|
127
|
+
high_cardinality = []
|
|
128
|
+
leakage = []
|
|
129
|
+
correlation_pairs = []
|
|
130
|
+
details = {}
|
|
131
|
+
warnings = []
|
|
132
|
+
|
|
133
|
+
# Analyze individual columns
|
|
134
|
+
for column in X_train.columns:
|
|
135
|
+
series = X_train[column]
|
|
136
|
+
|
|
137
|
+
missing_count = int(series.isna().sum())
|
|
138
|
+
missing_ratio = missing_count / len(X_train)
|
|
139
|
+
|
|
140
|
+
unique_count = int(series.nunique(dropna=True))
|
|
141
|
+
non_missing_count = int(series.notna().sum())
|
|
142
|
+
|
|
143
|
+
unique_ratio = (
|
|
144
|
+
unique_count / non_missing_count
|
|
145
|
+
if non_missing_count else 0.0
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if pd.api.types.is_datetime64_any_dtype(series):
|
|
149
|
+
role = "datetime"
|
|
150
|
+
datetime.append(column)
|
|
151
|
+
|
|
152
|
+
elif pd.api.types.is_bool_dtype(series):
|
|
153
|
+
role = "categorical"
|
|
154
|
+
categorical.append(column)
|
|
155
|
+
|
|
156
|
+
elif pd.api.types.is_numeric_dtype(series):
|
|
157
|
+
role = "numerical"
|
|
158
|
+
numerical.append(column)
|
|
159
|
+
|
|
160
|
+
elif (
|
|
161
|
+
pd.api.types.is_object_dtype(series)
|
|
162
|
+
or pd.api.types.is_string_dtype(series)
|
|
163
|
+
or isinstance(series.dtype, pd.CategoricalDtype)
|
|
164
|
+
):
|
|
165
|
+
role = "categorical"
|
|
166
|
+
categorical.append(column)
|
|
167
|
+
|
|
168
|
+
else:
|
|
169
|
+
role = "other"
|
|
170
|
+
other.append(column)
|
|
171
|
+
|
|
172
|
+
details[str(column)] = {
|
|
173
|
+
"dtype": str(series.dtype),
|
|
174
|
+
"role": role,
|
|
175
|
+
"missing_count": missing_count,
|
|
176
|
+
"missing_percentage": round(missing_ratio * 100, 2),
|
|
177
|
+
"unique_count": unique_count,
|
|
178
|
+
"unique_ratio": round(unique_ratio, 4)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if unique_count <= 1:
|
|
182
|
+
constant.append(column)
|
|
183
|
+
|
|
184
|
+
if missing_ratio >= missing_threshold:
|
|
185
|
+
high_missing.append({
|
|
186
|
+
"column": column,
|
|
187
|
+
"missing_percentage": round(missing_ratio * 100, 2)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
if (
|
|
191
|
+
role == "categorical"
|
|
192
|
+
and unique_count > max_categories
|
|
193
|
+
):
|
|
194
|
+
high_cardinality.append({
|
|
195
|
+
"column": column,
|
|
196
|
+
"unique_count": unique_count,
|
|
197
|
+
"unique_ratio": round(unique_ratio, 4)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
# Possible identifiers
|
|
201
|
+
name_hint = _is_identifier_name(column)
|
|
202
|
+
|
|
203
|
+
sequential = (
|
|
204
|
+
non_missing_count > 1
|
|
205
|
+
and unique_count == non_missing_count
|
|
206
|
+
and (
|
|
207
|
+
series.is_monotonic_increasing
|
|
208
|
+
or series.is_monotonic_decreasing
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if name_hint or (
|
|
213
|
+
unique_ratio >= 0.98 and sequential
|
|
214
|
+
):
|
|
215
|
+
identifiers.append({
|
|
216
|
+
"column": column,
|
|
217
|
+
"unique_ratio": round(unique_ratio, 4),
|
|
218
|
+
"reasons": [
|
|
219
|
+
reason for condition, reason in [
|
|
220
|
+
(name_hint, "identifier-like column name"),
|
|
221
|
+
(sequential, "unique sequential values")
|
|
222
|
+
] if condition
|
|
223
|
+
]
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
# Potential target leakage
|
|
227
|
+
if y_train is not None:
|
|
228
|
+
paired = pd.concat(
|
|
229
|
+
[series.rename("feature"), y_train.rename("target")],
|
|
230
|
+
axis=1
|
|
231
|
+
).dropna()
|
|
232
|
+
|
|
233
|
+
exact_match = (
|
|
234
|
+
not paired.empty
|
|
235
|
+
and paired["feature"].reset_index(drop=True).equals(
|
|
236
|
+
paired["target"].reset_index(drop=True)
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
one_to_one = _is_one_to_one_mapping(
|
|
241
|
+
series, y_train
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if exact_match or one_to_one:
|
|
245
|
+
leakage.append({
|
|
246
|
+
"column": column,
|
|
247
|
+
"reason": (
|
|
248
|
+
"exact target match"
|
|
249
|
+
if exact_match
|
|
250
|
+
else "one-to-one mapping with target"
|
|
251
|
+
)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
# Detect duplicate columns
|
|
255
|
+
columns = list(X_train.columns)
|
|
256
|
+
|
|
257
|
+
for i, column in enumerate(columns):
|
|
258
|
+
for other_column in columns[i + 1:]:
|
|
259
|
+
left = X_train[column].reset_index(drop=True)
|
|
260
|
+
right = X_train[other_column].reset_index(drop=True)
|
|
261
|
+
|
|
262
|
+
if left.equals(right):
|
|
263
|
+
duplicates.append({
|
|
264
|
+
"column": other_column,
|
|
265
|
+
"duplicate_of": column
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
# Detect highly correlated numerical features
|
|
269
|
+
if len(numerical) > 1:
|
|
270
|
+
corr = X_train[numerical].corr()
|
|
271
|
+
|
|
272
|
+
for i, column_a in enumerate(numerical):
|
|
273
|
+
for column_b in numerical[i + 1:]:
|
|
274
|
+
value = corr.loc[column_a, column_b]
|
|
275
|
+
|
|
276
|
+
if (
|
|
277
|
+
pd.notna(value)
|
|
278
|
+
and abs(value) >= correlation_threshold
|
|
279
|
+
):
|
|
280
|
+
correlation_pairs.append({
|
|
281
|
+
"feature_1": column_a,
|
|
282
|
+
"feature_2": column_b,
|
|
283
|
+
"correlation": round(float(value), 4)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
if identifiers:
|
|
287
|
+
warnings.append("Possible identifier columns detected.")
|
|
288
|
+
|
|
289
|
+
if high_missing:
|
|
290
|
+
warnings.append("Columns with high missingness detected.")
|
|
291
|
+
|
|
292
|
+
if high_cardinality:
|
|
293
|
+
warnings.append("High-cardinality categorical columns detected.")
|
|
294
|
+
|
|
295
|
+
if leakage:
|
|
296
|
+
warnings.append("Possible target leakage detected.")
|
|
297
|
+
|
|
298
|
+
if correlation_pairs:
|
|
299
|
+
warnings.append("Highly correlated numerical features detected.")
|
|
300
|
+
|
|
301
|
+
return ColumnIntelligenceReport(
|
|
302
|
+
numerical_columns=numerical,
|
|
303
|
+
categorical_columns=categorical,
|
|
304
|
+
datetime_columns=datetime,
|
|
305
|
+
other_columns=other,
|
|
306
|
+
constant_columns=constant,
|
|
307
|
+
duplicate_columns=duplicates,
|
|
308
|
+
possible_identifiers=identifiers,
|
|
309
|
+
high_missing_columns=high_missing,
|
|
310
|
+
high_cardinality_columns=high_cardinality,
|
|
311
|
+
high_correlation_pairs=correlation_pairs,
|
|
312
|
+
possible_target_leakage=leakage,
|
|
313
|
+
column_details=details,
|
|
314
|
+
warnings=warnings
|
|
315
|
+
)
|
mlfox/encoding.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
|
|
2
|
+
from dataclasses import dataclass, asdict
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from sklearn.preprocessing import OneHotEncoder, StandardScaler
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class EncodingReport:
|
|
12
|
+
numerical_columns: list
|
|
13
|
+
categorical_columns: list
|
|
14
|
+
scaled_columns: list
|
|
15
|
+
encoded_columns: list
|
|
16
|
+
input_features: int
|
|
17
|
+
output_features: int
|
|
18
|
+
max_categories: int
|
|
19
|
+
warnings: list
|
|
20
|
+
|
|
21
|
+
def to_dict(self):
|
|
22
|
+
return asdict(self)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EncodingScalingIntelligence:
|
|
26
|
+
|
|
27
|
+
def __init__(self, max_categories=100):
|
|
28
|
+
if max_categories < 2:
|
|
29
|
+
raise ValueError("max_categories must be at least 2.")
|
|
30
|
+
|
|
31
|
+
self.max_categories = max_categories
|
|
32
|
+
self.fitted_ = False
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _create_encoder(max_categories):
|
|
36
|
+
try:
|
|
37
|
+
return OneHotEncoder(
|
|
38
|
+
handle_unknown="ignore",
|
|
39
|
+
sparse_output=False,
|
|
40
|
+
max_categories=max_categories
|
|
41
|
+
)
|
|
42
|
+
except TypeError:
|
|
43
|
+
try:
|
|
44
|
+
return OneHotEncoder(
|
|
45
|
+
handle_unknown="ignore",
|
|
46
|
+
sparse=False,
|
|
47
|
+
max_categories=max_categories
|
|
48
|
+
)
|
|
49
|
+
except TypeError:
|
|
50
|
+
return OneHotEncoder(
|
|
51
|
+
handle_unknown="ignore",
|
|
52
|
+
sparse=False
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def fit(self, X_train):
|
|
56
|
+
|
|
57
|
+
if not isinstance(X_train, pd.DataFrame):
|
|
58
|
+
raise TypeError("X_train must be a pandas DataFrame.")
|
|
59
|
+
|
|
60
|
+
if X_train.empty:
|
|
61
|
+
raise ValueError("Training dataset is empty.")
|
|
62
|
+
|
|
63
|
+
if not X_train.columns.is_unique:
|
|
64
|
+
raise ValueError("Duplicate feature names detected.")
|
|
65
|
+
|
|
66
|
+
if not all(isinstance(c, str) for c in X_train.columns):
|
|
67
|
+
raise TypeError("All feature column names must be strings.")
|
|
68
|
+
|
|
69
|
+
if X_train.isna().any().any():
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"Missing values detected. Run M5 before M7."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
self.feature_columns_ = list(X_train.columns)
|
|
75
|
+
|
|
76
|
+
self.numeric_columns_ = [
|
|
77
|
+
col for col in self.feature_columns_
|
|
78
|
+
if (
|
|
79
|
+
pd.api.types.is_numeric_dtype(X_train[col])
|
|
80
|
+
and not pd.api.types.is_bool_dtype(X_train[col])
|
|
81
|
+
)
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
self.categorical_columns_ = [
|
|
85
|
+
col for col in self.feature_columns_
|
|
86
|
+
if col not in self.numeric_columns_
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
# Datetime features should already be handled by M6.
|
|
90
|
+
datetime_columns = [
|
|
91
|
+
col for col in self.feature_columns_
|
|
92
|
+
if pd.api.types.is_datetime64_any_dtype(X_train[col])
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
if datetime_columns:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"Datetime columns remain. Run M6 first: "
|
|
98
|
+
f"{datetime_columns}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if not self.numeric_columns_ and not self.categorical_columns_:
|
|
102
|
+
raise ValueError("No usable features found.")
|
|
103
|
+
|
|
104
|
+
self.scaler_ = None
|
|
105
|
+
self.encoder_ = None
|
|
106
|
+
|
|
107
|
+
output_names = []
|
|
108
|
+
self.warnings_ = []
|
|
109
|
+
|
|
110
|
+
# Numerical scaling
|
|
111
|
+
if self.numeric_columns_:
|
|
112
|
+
numeric_data = X_train[
|
|
113
|
+
self.numeric_columns_
|
|
114
|
+
].astype("float64")
|
|
115
|
+
|
|
116
|
+
if not np.isfinite(numeric_data.to_numpy()).all():
|
|
117
|
+
raise ValueError(
|
|
118
|
+
"Numerical features contain infinite values."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
self.scaler_ = StandardScaler()
|
|
122
|
+
self.scaler_.fit(numeric_data)
|
|
123
|
+
|
|
124
|
+
output_names.extend([
|
|
125
|
+
f"num__{col}"
|
|
126
|
+
for col in self.numeric_columns_
|
|
127
|
+
])
|
|
128
|
+
|
|
129
|
+
# Categorical encoding
|
|
130
|
+
if self.categorical_columns_:
|
|
131
|
+
categorical_data = (
|
|
132
|
+
X_train[self.categorical_columns_]
|
|
133
|
+
.astype(str)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
self.encoder_ = self._create_encoder(
|
|
137
|
+
self.max_categories
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
self.encoder_.fit(categorical_data)
|
|
141
|
+
|
|
142
|
+
encoded_names = (
|
|
143
|
+
self.encoder_.get_feature_names_out(
|
|
144
|
+
self.categorical_columns_
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
output_names.extend([
|
|
149
|
+
f"cat__{name}"
|
|
150
|
+
for name in encoded_names
|
|
151
|
+
])
|
|
152
|
+
|
|
153
|
+
self.feature_names_out_ = output_names
|
|
154
|
+
|
|
155
|
+
if not self.categorical_columns_:
|
|
156
|
+
self.warnings_.append(
|
|
157
|
+
"No categorical columns required encoding."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if not self.numeric_columns_:
|
|
161
|
+
self.warnings_.append(
|
|
162
|
+
"No numerical columns required scaling."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
self.report_ = EncodingReport(
|
|
166
|
+
numerical_columns=self.numeric_columns_,
|
|
167
|
+
categorical_columns=self.categorical_columns_,
|
|
168
|
+
scaled_columns=self.numeric_columns_,
|
|
169
|
+
encoded_columns=self.categorical_columns_,
|
|
170
|
+
input_features=len(self.feature_columns_),
|
|
171
|
+
output_features=len(self.feature_names_out_),
|
|
172
|
+
max_categories=self.max_categories,
|
|
173
|
+
warnings=self.warnings_
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
self.fitted_ = True
|
|
177
|
+
return self
|
|
178
|
+
|
|
179
|
+
def transform(self, X):
|
|
180
|
+
|
|
181
|
+
if not self.fitted_:
|
|
182
|
+
raise RuntimeError("Call fit() before transform().")
|
|
183
|
+
|
|
184
|
+
if not isinstance(X, pd.DataFrame):
|
|
185
|
+
raise TypeError("X must be a pandas DataFrame.")
|
|
186
|
+
|
|
187
|
+
missing = [
|
|
188
|
+
col for col in self.feature_columns_
|
|
189
|
+
if col not in X.columns
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
if missing:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"Missing expected columns: {missing}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
data = X.loc[:, self.feature_columns_]
|
|
198
|
+
|
|
199
|
+
if data.isna().any().any():
|
|
200
|
+
raise ValueError(
|
|
201
|
+
"Missing values detected. Run M5 before M7."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
output_blocks = []
|
|
205
|
+
|
|
206
|
+
# Transform numerical features
|
|
207
|
+
if self.numeric_columns_:
|
|
208
|
+
numeric_data = data[
|
|
209
|
+
self.numeric_columns_
|
|
210
|
+
].astype("float64")
|
|
211
|
+
|
|
212
|
+
numeric_array = numeric_data.to_numpy()
|
|
213
|
+
|
|
214
|
+
if not np.isfinite(numeric_array).all():
|
|
215
|
+
raise ValueError(
|
|
216
|
+
"Numerical features contain infinite values."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
scaled = self.scaler_.transform(numeric_data)
|
|
220
|
+
output_blocks.append(scaled)
|
|
221
|
+
|
|
222
|
+
# Transform categorical features
|
|
223
|
+
if self.categorical_columns_:
|
|
224
|
+
categorical_data = (
|
|
225
|
+
data[self.categorical_columns_]
|
|
226
|
+
.astype(str)
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
encoded = self.encoder_.transform(
|
|
230
|
+
categorical_data
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
output_blocks.append(encoded)
|
|
234
|
+
|
|
235
|
+
output = np.hstack(output_blocks)
|
|
236
|
+
|
|
237
|
+
return pd.DataFrame(
|
|
238
|
+
output,
|
|
239
|
+
index=X.index,
|
|
240
|
+
columns=self.feature_names_out_
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def fit_transform(self, X_train):
|
|
244
|
+
return self.fit(X_train).transform(X_train)
|
|
245
|
+
|
|
246
|
+
def get_feature_names_out(self):
|
|
247
|
+
if not self.fitted_:
|
|
248
|
+
raise RuntimeError("The encoder has not been fitted.")
|
|
249
|
+
|
|
250
|
+
return list(self.feature_names_out_)
|
|
251
|
+
|
|
252
|
+
def to_dict(self):
|
|
253
|
+
if not self.fitted_:
|
|
254
|
+
raise RuntimeError("The encoder has not been fitted.")
|
|
255
|
+
|
|
256
|
+
return self.report_.to_dict()
|
mlfox/evaluation.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Cross-validation and one-time holdout metrics."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
from sklearn.base import clone
|
|
4
|
+
from sklearn.model_selection import StratifiedKFold, KFold
|
|
5
|
+
from sklearn.metrics import (accuracy_score, balanced_accuracy_score, precision_score, recall_score, f1_score, roc_auc_score, r2_score, mean_absolute_error, mean_squared_error)
|
|
6
|
+
|
|
7
|
+
def metric_values(model, X, y, task_type):
|
|
8
|
+
pred = model.predict(X)
|
|
9
|
+
if task_type == "regression":
|
|
10
|
+
return {"r2": float(r2_score(y, pred)), "mae": float(mean_absolute_error(y, pred)), "rmse": float(np.sqrt(mean_squared_error(y, pred)))}
|
|
11
|
+
result = {"accuracy": float(accuracy_score(y, pred)), "balanced_accuracy": float(balanced_accuracy_score(y, pred)), "precision": float(precision_score(y, pred, average="weighted", zero_division=0)), "recall": float(recall_score(y, pred, average="weighted", zero_division=0)), "f1": float(f1_score(y, pred, average="weighted", zero_division=0))}
|
|
12
|
+
try:
|
|
13
|
+
prob = model.predict_proba(X)
|
|
14
|
+
result["roc_auc"] = float(roc_auc_score(y, prob[:, 1] if prob.shape[1] == 2 else prob, multi_class="ovr" if prob.shape[1] > 2 else "raise", average="weighted"))
|
|
15
|
+
except (AttributeError, ValueError):
|
|
16
|
+
result["roc_auc"] = None
|
|
17
|
+
return result
|
|
18
|
+
|
|
19
|
+
def cross_validate_pipeline(pipeline, X, y, task_type, cv=5, random_state=42,
|
|
20
|
+
event_callback=None, candidate_name=None, diagnostics=None):
|
|
21
|
+
if diagnostics is None:
|
|
22
|
+
diagnostics = event_callback is not None
|
|
23
|
+
counts = y.value_counts() if task_type != "regression" else None
|
|
24
|
+
folds = min(cv, int(counts.min())) if counts is not None else min(cv, len(y))
|
|
25
|
+
if folds < 2: raise ValueError("At least two valid CV folds are required.")
|
|
26
|
+
splitter = StratifiedKFold(folds, shuffle=True, random_state=random_state) if counts is not None else KFold(folds, shuffle=True, random_state=random_state)
|
|
27
|
+
collected = {}
|
|
28
|
+
for fold_number, (train_idx, valid_idx) in enumerate(splitter.split(X, y), 1):
|
|
29
|
+
candidate = clone(pipeline)
|
|
30
|
+
pre_id = id(candidate.named_steps["preprocessing"])
|
|
31
|
+
def emit(event_type, details=None):
|
|
32
|
+
if event_callback:
|
|
33
|
+
event_callback({"event_type": event_type, "candidate": candidate_name,
|
|
34
|
+
"fold": fold_number, "preprocessor_id": pre_id,
|
|
35
|
+
"details": details or {}})
|
|
36
|
+
emit("fold_started", {"train_indices": X.index[train_idx].tolist(), "validation_indices": X.index[valid_idx].tolist()} if diagnostics else {})
|
|
37
|
+
try:
|
|
38
|
+
candidate.fit(X.iloc[train_idx], y.iloc[train_idx])
|
|
39
|
+
if diagnostics:
|
|
40
|
+
details = {"train_indices": X.index[train_idx].tolist(),
|
|
41
|
+
"imputer_statistics": candidate.named_steps["preprocessing"].imputer_.fill_values_.copy(),
|
|
42
|
+
"selector_input_indices": X.index[train_idx].tolist(),
|
|
43
|
+
"selector_labels": y.iloc[train_idx].tolist(),
|
|
44
|
+
"preprocessor": candidate.named_steps["preprocessing"],
|
|
45
|
+
"fitted_pipeline": candidate}
|
|
46
|
+
else:
|
|
47
|
+
details = {"training_rows": len(train_idx)}
|
|
48
|
+
emit("preprocessing_fitted", details)
|
|
49
|
+
emit("model_fitted")
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
emit("candidate_failed", {"exception": repr(exc)})
|
|
52
|
+
raise
|
|
53
|
+
scores = metric_values(candidate, X.iloc[valid_idx], y.iloc[valid_idx], task_type)
|
|
54
|
+
emit("fold_completed", {"metrics": scores})
|
|
55
|
+
for key, value in scores.items():
|
|
56
|
+
if value is not None: collected.setdefault(key, []).append(value)
|
|
57
|
+
summary = {key: {"mean": float(np.mean(vals)), "std": float(np.std(vals, ddof=1)) if len(vals)>1 else 0.0} for key, vals in collected.items()}
|
|
58
|
+
return summary, folds
|