mlpipe-cli 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.
- mlpipe/__init__.py +43 -0
- mlpipe/__main__.py +6 -0
- mlpipe/artifacts/__init__.py +23 -0
- mlpipe/artifacts/manager.py +246 -0
- mlpipe/artifacts/serialization.py +67 -0
- mlpipe/cli/__init__.py +5 -0
- mlpipe/cli/main.py +667 -0
- mlpipe/core/__init__.py +35 -0
- mlpipe/core/config.py +76 -0
- mlpipe/core/exceptions.py +65 -0
- mlpipe/core/pipeline.py +435 -0
- mlpipe/core/result.py +50 -0
- mlpipe/data/__init__.py +20 -0
- mlpipe/data/ingestion.py +138 -0
- mlpipe/data/profiling.py +227 -0
- mlpipe/data/splitting.py +130 -0
- mlpipe/data/validation.py +248 -0
- mlpipe/evaluation/__init__.py +11 -0
- mlpipe/evaluation/evaluator.py +146 -0
- mlpipe/evaluation/metrics.py +53 -0
- mlpipe/explainability/__init__.py +5 -0
- mlpipe/explainability/importance.py +65 -0
- mlpipe/models/__init__.py +13 -0
- mlpipe/models/classification.py +156 -0
- mlpipe/models/registry.py +32 -0
- mlpipe/models/regression.py +126 -0
- mlpipe/models/selection.py +24 -0
- mlpipe/preprocessing/__init__.py +21 -0
- mlpipe/preprocessing/builder.py +163 -0
- mlpipe/preprocessing/categorical.py +17 -0
- mlpipe/preprocessing/datetime.py +55 -0
- mlpipe/preprocessing/numeric.py +17 -0
- mlpipe/tuning/__init__.py +10 -0
- mlpipe/tuning/search.py +140 -0
- mlpipe/tuning/spaces.py +11 -0
- mlpipe/utils/__init__.py +13 -0
- mlpipe/utils/hashing.py +15 -0
- mlpipe/utils/logging.py +37 -0
- mlpipe/utils/timing.py +33 -0
- mlpipe/version.py +3 -0
- mlpipe_cli-0.1.0.dist-info/METADATA +264 -0
- mlpipe_cli-0.1.0.dist-info/RECORD +46 -0
- mlpipe_cli-0.1.0.dist-info/WHEEL +5 -0
- mlpipe_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mlpipe_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- mlpipe_cli-0.1.0.dist-info/top_level.txt +1 -0
mlpipe/data/ingestion.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data ingestion module for MLPipe.
|
|
3
|
+
|
|
4
|
+
Provides a structured Dataset abstraction and safe CSV loading with validation,
|
|
5
|
+
hashing, and descriptive error messages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional, Union
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from mlpipe.core.exceptions import DatasetError
|
|
15
|
+
from mlpipe.utils.hashing import compute_file_hash
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Dataset:
|
|
20
|
+
"""Structured representation of an ingested dataset."""
|
|
21
|
+
|
|
22
|
+
filepath: Path
|
|
23
|
+
df: pd.DataFrame
|
|
24
|
+
num_rows: int
|
|
25
|
+
num_cols: int
|
|
26
|
+
memory_bytes: int
|
|
27
|
+
sha256_hash: str
|
|
28
|
+
column_names: List[str]
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def filename(self) -> str:
|
|
32
|
+
return self.filepath.name
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def data(self) -> pd.DataFrame:
|
|
36
|
+
"""Alias for df."""
|
|
37
|
+
return self.df
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def memory_mb(self) -> float:
|
|
41
|
+
return round(self.memory_bytes / (1024 * 1024), 2)
|
|
42
|
+
|
|
43
|
+
def summary(self) -> Dict[str, Any]:
|
|
44
|
+
"""Return a clean dictionary summary of the dataset."""
|
|
45
|
+
return {
|
|
46
|
+
"filename": self.filename,
|
|
47
|
+
"filepath": str(self.filepath),
|
|
48
|
+
"rows": self.num_rows,
|
|
49
|
+
"columns": self.num_cols,
|
|
50
|
+
"memory_mb": self.memory_mb,
|
|
51
|
+
"sha256": self.sha256_hash,
|
|
52
|
+
"column_names": self.column_names,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_dataset(path: Union[str, Path]) -> Dataset:
|
|
57
|
+
"""
|
|
58
|
+
Safely load a CSV dataset from disk and return a structured Dataset object.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
path: Path to the CSV file.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Dataset object containing metadata and dataframe.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
DatasetError: If the file does not exist, has an invalid format, or is empty.
|
|
68
|
+
"""
|
|
69
|
+
file_path = Path(path).resolve()
|
|
70
|
+
|
|
71
|
+
if not file_path.exists():
|
|
72
|
+
raise DatasetError(
|
|
73
|
+
f"Dataset file not found at '{file_path}'.",
|
|
74
|
+
"Verify that the file path is correct and accessible."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if not file_path.is_file():
|
|
78
|
+
raise DatasetError(
|
|
79
|
+
f"Path '{file_path}' is a directory, not a file.",
|
|
80
|
+
"Provide the path to a valid CSV file."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if file_path.suffix.lower() not in [".csv"]:
|
|
84
|
+
raise DatasetError(
|
|
85
|
+
f"Unsupported file format '{file_path.suffix}'. MLPipe currently supports '.csv'.",
|
|
86
|
+
"Please convert your dataset to CSV format."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Check file size (e.g. empty file)
|
|
90
|
+
if file_path.stat().st_size == 0:
|
|
91
|
+
raise DatasetError(
|
|
92
|
+
f"Dataset file '{file_path.name}' is completely empty (0 bytes).",
|
|
93
|
+
"Ensure the file contains valid CSV data with a header and rows."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
df = pd.read_csv(file_path, low_memory=False)
|
|
98
|
+
except pd.errors.EmptyDataError:
|
|
99
|
+
raise DatasetError(
|
|
100
|
+
f"Dataset file '{file_path.name}' contains no headers or data.",
|
|
101
|
+
"Check that the CSV has column names and at least one data row."
|
|
102
|
+
)
|
|
103
|
+
except pd.errors.ParserError as e:
|
|
104
|
+
raise DatasetError(
|
|
105
|
+
f"Failed to parse CSV file '{file_path.name}': {e}",
|
|
106
|
+
"Check for delimiter issues, unescaped quotes, or inconsistent row lengths."
|
|
107
|
+
)
|
|
108
|
+
except Exception as e:
|
|
109
|
+
raise DatasetError(
|
|
110
|
+
f"Unexpected error while reading '{file_path.name}': {e}",
|
|
111
|
+
"Verify file permissions and that the file is not locked by another application."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if df.empty or len(df) == 0:
|
|
115
|
+
raise DatasetError(
|
|
116
|
+
f"Dataset '{file_path.name}' contains headers but zero data rows.",
|
|
117
|
+
"Provide a dataset with at least a few sample rows."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if len(df.columns) == 0:
|
|
121
|
+
raise DatasetError(
|
|
122
|
+
f"Dataset '{file_path.name}' contains no columns.",
|
|
123
|
+
"Check the delimiter of your CSV file."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Compute SHA-256
|
|
127
|
+
file_hash = compute_file_hash(file_path)
|
|
128
|
+
memory_usage = int(df.memory_usage(deep=True).sum())
|
|
129
|
+
|
|
130
|
+
return Dataset(
|
|
131
|
+
filepath=file_path,
|
|
132
|
+
df=df,
|
|
133
|
+
num_rows=len(df),
|
|
134
|
+
num_cols=len(df.columns),
|
|
135
|
+
memory_bytes=memory_usage,
|
|
136
|
+
sha256_hash=file_hash,
|
|
137
|
+
column_names=list(df.columns),
|
|
138
|
+
)
|
mlpipe/data/profiling.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data profiling module for MLPipe.
|
|
3
|
+
|
|
4
|
+
Calculates comprehensive statistics on datasets and columns without hardcoded values.
|
|
5
|
+
Detects column types, distributions, missingness, and structural issues.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any, Dict, List, Optional, Union
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from mlpipe.data.ingestion import Dataset
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _safe_val(val: Any) -> Any:
|
|
21
|
+
"""Convert numpy/pandas types to JSON-serializable standard Python types."""
|
|
22
|
+
if val is None or pd.isna(val):
|
|
23
|
+
return None
|
|
24
|
+
if isinstance(val, (np.integer, int)):
|
|
25
|
+
return int(val)
|
|
26
|
+
if isinstance(val, (np.floating, float)):
|
|
27
|
+
if math.isnan(val) or math.isinf(val):
|
|
28
|
+
return None
|
|
29
|
+
return round(float(val), 4)
|
|
30
|
+
if isinstance(val, (np.bool_, bool)):
|
|
31
|
+
return bool(val)
|
|
32
|
+
return str(val)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def detect_column_type(series: pd.Series) -> str:
|
|
36
|
+
"""Infer column type: 'numeric', 'categorical', 'boolean', or 'datetime'."""
|
|
37
|
+
if pd.api.types.is_bool_dtype(series):
|
|
38
|
+
return "boolean"
|
|
39
|
+
|
|
40
|
+
if pd.api.types.is_datetime64_any_dtype(series):
|
|
41
|
+
return "datetime"
|
|
42
|
+
|
|
43
|
+
if pd.api.types.is_numeric_dtype(series):
|
|
44
|
+
# Even if numeric, check if it's strictly binary 0/1 with boolean meaning
|
|
45
|
+
unique_vals = set(series.dropna().unique())
|
|
46
|
+
if unique_vals.issubset({0, 1}) and len(unique_vals) <= 2:
|
|
47
|
+
# Let boolean be inferred if column name or values suggest it
|
|
48
|
+
name_lower = str(series.name).lower()
|
|
49
|
+
if any(p in name_lower for p in ["is_", "has_", "flag", "active", "churn"]):
|
|
50
|
+
return "categorical" # target or binary categorical
|
|
51
|
+
return "numeric"
|
|
52
|
+
|
|
53
|
+
# For object/string columns, try datetime detection
|
|
54
|
+
if series.dtype == object or pd.api.types.is_string_dtype(series):
|
|
55
|
+
non_null = series.dropna()
|
|
56
|
+
if len(non_null) > 0:
|
|
57
|
+
sample = non_null.head(min(50, len(non_null)))
|
|
58
|
+
try:
|
|
59
|
+
pd.to_datetime(sample, format="mixed")
|
|
60
|
+
# Also check full or larger sample if small sample succeeded
|
|
61
|
+
return "datetime"
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
# Check boolean-like strings
|
|
66
|
+
lower_sample = set(str(v).strip().lower() for v in sample.unique())
|
|
67
|
+
if lower_sample.issubset({"true", "false", "yes", "no", "t", "f", "1", "0", "y", "n"}):
|
|
68
|
+
return "boolean"
|
|
69
|
+
|
|
70
|
+
return "categorical"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class ColumnProfile:
|
|
75
|
+
"""Profile of an individual dataset column."""
|
|
76
|
+
|
|
77
|
+
name: str
|
|
78
|
+
detected_type: str
|
|
79
|
+
missing_count: int
|
|
80
|
+
missing_pct: float
|
|
81
|
+
unique_count: int
|
|
82
|
+
examples: List[Any]
|
|
83
|
+
# Numeric stats
|
|
84
|
+
min_val: Optional[float] = None
|
|
85
|
+
max_val: Optional[float] = None
|
|
86
|
+
mean_val: Optional[float] = None
|
|
87
|
+
median_val: Optional[float] = None
|
|
88
|
+
std_val: Optional[float] = None
|
|
89
|
+
# Categorical stats
|
|
90
|
+
top_values: List[Dict[str, Any]] = field(default_factory=list)
|
|
91
|
+
# Datetime stats
|
|
92
|
+
min_date: Optional[str] = None
|
|
93
|
+
max_date: Optional[str] = None
|
|
94
|
+
# Warnings/Flags
|
|
95
|
+
flags: List[str] = field(default_factory=list)
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
98
|
+
return asdict(self)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class DatasetProfile:
|
|
103
|
+
"""Complete dataset profile including dataset-level and column-level information."""
|
|
104
|
+
|
|
105
|
+
num_rows: int
|
|
106
|
+
num_cols: int
|
|
107
|
+
memory_bytes: int
|
|
108
|
+
memory_mb: float
|
|
109
|
+
duplicate_rows: int
|
|
110
|
+
total_missing_values: int
|
|
111
|
+
columns: List[ColumnProfile]
|
|
112
|
+
warnings: List[str]
|
|
113
|
+
|
|
114
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
115
|
+
return asdict(self)
|
|
116
|
+
|
|
117
|
+
def to_json(self, indent: int = 2) -> str:
|
|
118
|
+
return json.dumps(self.to_dict(), indent=indent)
|
|
119
|
+
|
|
120
|
+
def get_column(self, name: str) -> Optional[ColumnProfile]:
|
|
121
|
+
for col in self.columns:
|
|
122
|
+
if col.name == name:
|
|
123
|
+
return col
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def profile_dataset(dataset: Union[Dataset, pd.DataFrame]) -> DatasetProfile:
|
|
128
|
+
"""
|
|
129
|
+
Profile a dataset and generate accurate, computed descriptive statistics.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
dataset: Ingested Dataset or pandas DataFrame.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
DatasetProfile object.
|
|
136
|
+
"""
|
|
137
|
+
df = dataset.df if isinstance(dataset, Dataset) else dataset
|
|
138
|
+
|
|
139
|
+
num_rows = len(df)
|
|
140
|
+
num_cols = len(df.columns)
|
|
141
|
+
memory_bytes = int(df.memory_usage(deep=True).sum())
|
|
142
|
+
memory_mb = round(memory_bytes / (1024 * 1024), 2)
|
|
143
|
+
duplicate_rows = int(df.duplicated().sum())
|
|
144
|
+
total_missing = int(df.isna().sum().sum())
|
|
145
|
+
|
|
146
|
+
column_profiles: List[ColumnProfile] = []
|
|
147
|
+
dataset_warnings: List[str] = []
|
|
148
|
+
|
|
149
|
+
if duplicate_rows > 0:
|
|
150
|
+
dataset_warnings.append(f"Found {duplicate_rows} duplicate rows ({round(duplicate_rows/num_rows*100, 1)}%).")
|
|
151
|
+
|
|
152
|
+
id_regex = re.compile(r"(^id$|_id$|^id_|^index$|^guid$|^uuid$)", re.IGNORECASE)
|
|
153
|
+
|
|
154
|
+
for col_name in df.columns:
|
|
155
|
+
series = df[col_name]
|
|
156
|
+
col_type = detect_column_type(series)
|
|
157
|
+
missing_count = int(series.isna().sum())
|
|
158
|
+
missing_pct = round((missing_count / num_rows) * 100, 2) if num_rows > 0 else 0.0
|
|
159
|
+
unique_count = int(series.nunique(dropna=True))
|
|
160
|
+
|
|
161
|
+
examples = [_safe_val(x) for x in series.dropna().unique()[:5].tolist()]
|
|
162
|
+
flags = []
|
|
163
|
+
|
|
164
|
+
# Check flags
|
|
165
|
+
if missing_pct > 50.0:
|
|
166
|
+
flags.append(f"High missingness: {missing_pct}% missing")
|
|
167
|
+
if unique_count <= 1:
|
|
168
|
+
flags.append("Constant column (only 1 unique value)")
|
|
169
|
+
elif unique_count == num_rows and col_type in ("numeric", "categorical"):
|
|
170
|
+
flags.append("Suspicious unique/ID column (every value is distinct)")
|
|
171
|
+
elif id_regex.search(str(col_name)) and unique_count > num_rows * 0.8:
|
|
172
|
+
flags.append("Possible identifier/index column")
|
|
173
|
+
|
|
174
|
+
if col_type == "categorical" and unique_count > 100 and unique_count > num_rows * 0.5:
|
|
175
|
+
flags.append(f"Extremely high cardinality: {unique_count} distinct categories")
|
|
176
|
+
|
|
177
|
+
min_val = max_val = mean_val = median_val = std_val = None
|
|
178
|
+
top_values: List[Dict[str, Any]] = []
|
|
179
|
+
min_date = max_date = None
|
|
180
|
+
|
|
181
|
+
if col_type == "numeric":
|
|
182
|
+
desc = series.describe()
|
|
183
|
+
min_val = _safe_val(desc.get("min"))
|
|
184
|
+
max_val = _safe_val(desc.get("max"))
|
|
185
|
+
mean_val = _safe_val(desc.get("mean"))
|
|
186
|
+
median_val = _safe_val(series.median())
|
|
187
|
+
std_val = _safe_val(desc.get("std"))
|
|
188
|
+
elif col_type == "categorical":
|
|
189
|
+
vc = series.value_counts(dropna=True).head(5)
|
|
190
|
+
top_values = [{"value": str(k), "count": int(v)} for k, v in vc.items()]
|
|
191
|
+
elif col_type == "datetime":
|
|
192
|
+
try:
|
|
193
|
+
dt_series = pd.to_datetime(series, errors="coerce")
|
|
194
|
+
min_date = _safe_val(dt_series.min())
|
|
195
|
+
max_date = _safe_val(dt_series.max())
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
col_prof = ColumnProfile(
|
|
200
|
+
name=str(col_name),
|
|
201
|
+
detected_type=col_type,
|
|
202
|
+
missing_count=missing_count,
|
|
203
|
+
missing_pct=missing_pct,
|
|
204
|
+
unique_count=unique_count,
|
|
205
|
+
examples=examples,
|
|
206
|
+
min_val=min_val,
|
|
207
|
+
max_val=max_val,
|
|
208
|
+
mean_val=mean_val,
|
|
209
|
+
median_val=median_val,
|
|
210
|
+
std_val=std_val,
|
|
211
|
+
top_values=top_values,
|
|
212
|
+
min_date=min_date,
|
|
213
|
+
max_date=max_date,
|
|
214
|
+
flags=flags,
|
|
215
|
+
)
|
|
216
|
+
column_profiles.append(col_prof)
|
|
217
|
+
|
|
218
|
+
return DatasetProfile(
|
|
219
|
+
num_rows=num_rows,
|
|
220
|
+
num_cols=num_cols,
|
|
221
|
+
memory_bytes=memory_bytes,
|
|
222
|
+
memory_mb=memory_mb,
|
|
223
|
+
duplicate_rows=duplicate_rows,
|
|
224
|
+
total_missing_values=total_missing,
|
|
225
|
+
columns=column_profiles,
|
|
226
|
+
warnings=dataset_warnings,
|
|
227
|
+
)
|
mlpipe/data/splitting.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Train/test splitting for MLPipe.
|
|
3
|
+
|
|
4
|
+
Implements leakage-free splitting with stratification for classification when appropriate,
|
|
5
|
+
and random splitting for regression.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Tuple, Union
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
from sklearn.model_selection import train_test_split
|
|
14
|
+
|
|
15
|
+
from mlpipe.utils.logging import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger("splitting")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SplitData:
|
|
22
|
+
"""Container for train/test data splits."""
|
|
23
|
+
|
|
24
|
+
X_train: pd.DataFrame
|
|
25
|
+
X_test: pd.DataFrame
|
|
26
|
+
y_train: pd.Series
|
|
27
|
+
y_test: pd.Series
|
|
28
|
+
train_size: int
|
|
29
|
+
test_size: int
|
|
30
|
+
is_stratified: bool
|
|
31
|
+
target_name: str = "target"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def train_df(self) -> pd.DataFrame:
|
|
35
|
+
"""Combined DataFrame of training features and target."""
|
|
36
|
+
df = self.X_train.copy()
|
|
37
|
+
col_name = self.target_name or (self.y_train.name if self.y_train.name else "target")
|
|
38
|
+
df[col_name] = self.y_train.values
|
|
39
|
+
return df
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def test_df(self) -> pd.DataFrame:
|
|
43
|
+
"""Combined DataFrame of test features and target."""
|
|
44
|
+
df = self.X_test.copy()
|
|
45
|
+
col_name = self.target_name or (self.y_test.name if self.y_test.name else "target")
|
|
46
|
+
df[col_name] = self.y_test.values
|
|
47
|
+
return df
|
|
48
|
+
|
|
49
|
+
def export(self, output_dir: Union[str, Path] = ".") -> Tuple[Path, Path]:
|
|
50
|
+
"""Save train.csv and test.csv to an output directory."""
|
|
51
|
+
out_dir = Path(output_dir)
|
|
52
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
train_path = out_dir / "train.csv"
|
|
54
|
+
test_path = out_dir / "test.csv"
|
|
55
|
+
self.train_df.to_csv(train_path, index=False)
|
|
56
|
+
self.test_df.to_csv(test_path, index=False)
|
|
57
|
+
return train_path, test_path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def split_data(
|
|
61
|
+
df: pd.DataFrame,
|
|
62
|
+
target_column: str,
|
|
63
|
+
task_type: str,
|
|
64
|
+
test_size: float = 0.20,
|
|
65
|
+
random_seed: int = 42,
|
|
66
|
+
) -> SplitData:
|
|
67
|
+
"""
|
|
68
|
+
Split a DataFrame into train and test sets without data leakage.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
df: Raw input DataFrame.
|
|
72
|
+
target_column: Name of the target column.
|
|
73
|
+
task_type: 'classification' or 'regression'.
|
|
74
|
+
test_size: Fraction of samples to allocate to test set.
|
|
75
|
+
random_seed: Random state for reproducibility.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
SplitData container with X_train, X_test, y_train, y_test.
|
|
79
|
+
"""
|
|
80
|
+
# 1. Drop rows where target is NaN/null
|
|
81
|
+
clean_df = df.dropna(subset=[target_column]).copy()
|
|
82
|
+
if len(clean_df) < len(df):
|
|
83
|
+
logger.info("Dropped %d rows with missing target values prior to splitting.", len(df) - len(clean_df))
|
|
84
|
+
|
|
85
|
+
X = clean_df.drop(columns=[target_column])
|
|
86
|
+
y = clean_df[target_column]
|
|
87
|
+
|
|
88
|
+
stratify = None
|
|
89
|
+
is_stratified = False
|
|
90
|
+
|
|
91
|
+
if task_type == "classification":
|
|
92
|
+
# Check if every class has at least 2 samples for stratification
|
|
93
|
+
# and test set has at least as many samples as classes
|
|
94
|
+
class_counts = y.value_counts()
|
|
95
|
+
test_samples = int(len(clean_df) * test_size)
|
|
96
|
+
if (class_counts >= 2).all() and len(class_counts) > 1 and test_samples >= len(class_counts):
|
|
97
|
+
stratify = y
|
|
98
|
+
is_stratified = True
|
|
99
|
+
else:
|
|
100
|
+
logger.warning(
|
|
101
|
+
"Cannot stratify: either classes have <2 samples or test set size (%d) < classes (%d). Using random split.",
|
|
102
|
+
test_samples,
|
|
103
|
+
len(class_counts),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
X_train, X_test, y_train, y_test = train_test_split(
|
|
108
|
+
X,
|
|
109
|
+
y,
|
|
110
|
+
test_size=test_size,
|
|
111
|
+
random_state=random_seed,
|
|
112
|
+
stratify=stratify,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Reset indices to ensure clean indexing during transformations
|
|
116
|
+
X_train = X_train.reset_index(drop=True)
|
|
117
|
+
X_test = X_test.reset_index(drop=True)
|
|
118
|
+
y_train = y_train.reset_index(drop=True)
|
|
119
|
+
y_test = y_test.reset_index(drop=True)
|
|
120
|
+
|
|
121
|
+
return SplitData(
|
|
122
|
+
X_train=X_train,
|
|
123
|
+
X_test=X_test,
|
|
124
|
+
y_train=y_train,
|
|
125
|
+
y_test=y_test,
|
|
126
|
+
train_size=len(X_train),
|
|
127
|
+
test_size=len(X_test),
|
|
128
|
+
is_stratified=is_stratified,
|
|
129
|
+
target_name=target_column,
|
|
130
|
+
)
|