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.
Files changed (46) hide show
  1. mlpipe/__init__.py +43 -0
  2. mlpipe/__main__.py +6 -0
  3. mlpipe/artifacts/__init__.py +23 -0
  4. mlpipe/artifacts/manager.py +246 -0
  5. mlpipe/artifacts/serialization.py +67 -0
  6. mlpipe/cli/__init__.py +5 -0
  7. mlpipe/cli/main.py +667 -0
  8. mlpipe/core/__init__.py +35 -0
  9. mlpipe/core/config.py +76 -0
  10. mlpipe/core/exceptions.py +65 -0
  11. mlpipe/core/pipeline.py +435 -0
  12. mlpipe/core/result.py +50 -0
  13. mlpipe/data/__init__.py +20 -0
  14. mlpipe/data/ingestion.py +138 -0
  15. mlpipe/data/profiling.py +227 -0
  16. mlpipe/data/splitting.py +130 -0
  17. mlpipe/data/validation.py +248 -0
  18. mlpipe/evaluation/__init__.py +11 -0
  19. mlpipe/evaluation/evaluator.py +146 -0
  20. mlpipe/evaluation/metrics.py +53 -0
  21. mlpipe/explainability/__init__.py +5 -0
  22. mlpipe/explainability/importance.py +65 -0
  23. mlpipe/models/__init__.py +13 -0
  24. mlpipe/models/classification.py +156 -0
  25. mlpipe/models/registry.py +32 -0
  26. mlpipe/models/regression.py +126 -0
  27. mlpipe/models/selection.py +24 -0
  28. mlpipe/preprocessing/__init__.py +21 -0
  29. mlpipe/preprocessing/builder.py +163 -0
  30. mlpipe/preprocessing/categorical.py +17 -0
  31. mlpipe/preprocessing/datetime.py +55 -0
  32. mlpipe/preprocessing/numeric.py +17 -0
  33. mlpipe/tuning/__init__.py +10 -0
  34. mlpipe/tuning/search.py +140 -0
  35. mlpipe/tuning/spaces.py +11 -0
  36. mlpipe/utils/__init__.py +13 -0
  37. mlpipe/utils/hashing.py +15 -0
  38. mlpipe/utils/logging.py +37 -0
  39. mlpipe/utils/timing.py +33 -0
  40. mlpipe/version.py +3 -0
  41. mlpipe_cli-0.1.0.dist-info/METADATA +264 -0
  42. mlpipe_cli-0.1.0.dist-info/RECORD +46 -0
  43. mlpipe_cli-0.1.0.dist-info/WHEEL +5 -0
  44. mlpipe_cli-0.1.0.dist-info/entry_points.txt +2 -0
  45. mlpipe_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  46. mlpipe_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -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
+ )
@@ -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
+ )
@@ -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
+ )