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 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