ml-experiment-framework 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 (51) hide show
  1. __init__.py +3 -0
  2. data/__init__.py +6 -0
  3. data/loader.py +84 -0
  4. data/profiler.py +182 -0
  5. data/splitter.py +70 -0
  6. data/validator.py +106 -0
  7. detection/__init__.py +5 -0
  8. detection/decision_engine.py +355 -0
  9. detection/feature_types.py +284 -0
  10. detection/problem_type.py +125 -0
  11. detection/target.py +113 -0
  12. evaluation/__init__.py +5 -0
  13. evaluation/error_analysis.py +101 -0
  14. evaluation/evaluator.py +43 -0
  15. evaluation/metrics.py +121 -0
  16. evaluation/plots.py +73 -0
  17. experiments/__init__.py +5 -0
  18. experiments/reporter.py +131 -0
  19. experiments/runner.py +393 -0
  20. experiments/tracker.py +72 -0
  21. explainability/__init__.py +3 -0
  22. explainability/explainer.py +81 -0
  23. features/__init__.py +12 -0
  24. features/engineering.py +135 -0
  25. features/importance.py +45 -0
  26. features/selection.py +60 -0
  27. ml_experiment_framework-0.1.0.dist-info/METADATA +273 -0
  28. ml_experiment_framework-0.1.0.dist-info/RECORD +51 -0
  29. ml_experiment_framework-0.1.0.dist-info/WHEEL +5 -0
  30. ml_experiment_framework-0.1.0.dist-info/entry_points.txt +2 -0
  31. ml_experiment_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
  32. ml_experiment_framework-0.1.0.dist-info/top_level.txt +12 -0
  33. models/__init__.py +3 -0
  34. models/registry.py +311 -0
  35. persistence/__init__.py +3 -0
  36. persistence/model_store.py +31 -0
  37. preprocessing/__init__.py +3 -0
  38. preprocessing/builder.py +158 -0
  39. preprocessing/categorical.py +39 -0
  40. preprocessing/numerical.py +33 -0
  41. preprocessing/target.py +80 -0
  42. preprocessing/transformers.py +61 -0
  43. training/__init__.py +13 -0
  44. training/baseline.py +54 -0
  45. training/cross_validation.py +141 -0
  46. training/trainer.py +72 -0
  47. training/tuning.py +129 -0
  48. utils/__init__.py +12 -0
  49. utils/config.py +60 -0
  50. utils/logging.py +34 -0
  51. utils/reproducibility.py +39 -0
__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Classic Machine Learning Framework - dynamic, leakage-safe AutoML-style experimentation."""
2
+
3
+ __version__ = "0.1.0"
data/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .loader import load_dataset
2
+ from .validator import validate_dataset
3
+ from .profiler import profile_dataset
4
+ from .splitter import split_data
5
+
6
+ __all__ = ["load_dataset", "validate_dataset", "profile_dataset", "split_data"]
data/loader.py ADDED
@@ -0,0 +1,84 @@
1
+ """Data loading with support for CSV and Parquet; extensible registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Callable, Dict, Optional, Union
7
+
8
+ import pandas as pd
9
+
10
+ from src.utils.logging import get_logger
11
+
12
+ logger = get_logger("data.loader")
13
+
14
+ LoaderFn = Callable[[Path], pd.DataFrame]
15
+
16
+ _LOADERS: Dict[str, LoaderFn] = {}
17
+
18
+
19
+ def register_loader(extension: str):
20
+ """Decorator to register a file-format loader."""
21
+
22
+ def decorator(fn: LoaderFn):
23
+ _LOADERS[extension.lower().lstrip(".")] = fn
24
+ return fn
25
+
26
+ return decorator
27
+
28
+
29
+ @register_loader("csv")
30
+ def _load_csv(path: Path) -> pd.DataFrame:
31
+ return pd.read_csv(path)
32
+
33
+
34
+ @register_loader("parquet")
35
+ @register_loader("pq")
36
+ def _load_parquet(path: Path) -> pd.DataFrame:
37
+ return pd.read_parquet(path)
38
+
39
+
40
+ def load_dataset(
41
+ path: Union[str, Path],
42
+ **kwargs,
43
+ ) -> pd.DataFrame:
44
+ """
45
+ Load a tabular dataset from disk.
46
+
47
+ Supported formats (by extension): csv, parquet.
48
+ Additional loaders can be registered via @register_loader.
49
+ """
50
+ path = Path(path)
51
+ if not path.exists():
52
+ raise FileNotFoundError(f"Dataset file not found: {path}")
53
+ if not path.is_file():
54
+ raise ValueError(f"Path is not a file: {path}")
55
+
56
+ ext = path.suffix.lower().lstrip(".")
57
+ if ext not in _LOADERS:
58
+ raise ValueError(
59
+ f"Unsupported file format '.{ext}'. "
60
+ f"Supported: {sorted(_LOADERS.keys())}. "
61
+ "Register a new loader with @register_loader if needed."
62
+ )
63
+
64
+ logger.info(f"Loading dataset from {path}")
65
+ try:
66
+ df = _LOADERS[ext](path)
67
+ except Exception as e:
68
+ raise RuntimeError(f"Failed to read dataset {path}: {e}") from e
69
+
70
+ if df is None or len(df) == 0:
71
+ raise ValueError(f"Dataset is empty: {path}")
72
+
73
+ logger.info(f"Dataset shape: {df.shape[0]} rows × {df.shape[1]} columns")
74
+ return df
75
+
76
+
77
+ def load_train_test(
78
+ train_path: Union[str, Path],
79
+ test_path: Optional[Union[str, Path]] = None,
80
+ ) -> tuple[pd.DataFrame, Optional[pd.DataFrame]]:
81
+ """Load train and optional external test datasets."""
82
+ train = load_dataset(train_path)
83
+ test = load_dataset(test_path) if test_path else None
84
+ return train, test
data/profiler.py ADDED
@@ -0,0 +1,182 @@
1
+ """EDA / profiling (exploratory only — no fit that affects final pipeline)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from src.utils.logging import get_logger
12
+
13
+ logger = get_logger("data.profiler")
14
+
15
+
16
+ def _safe_skew(s: pd.Series) -> float:
17
+ try:
18
+ return float(s.dropna().skew())
19
+ except Exception:
20
+ return float("nan")
21
+
22
+
23
+ def profile_dataset(
24
+ df: pd.DataFrame,
25
+ target: Optional[str] = None,
26
+ feature_types: Optional[Dict[str, str]] = None,
27
+ artifacts_dir: Optional[Path] = None,
28
+ max_cat_levels: int = 20,
29
+ ) -> Dict[str, Any]:
30
+ """
31
+ Produce a structured EDA profile.
32
+
33
+ Plots are optional (written under artifacts_dir/plots if provided).
34
+ This function must never fit transformers used by the final model.
35
+ """
36
+ logger.info("Running EDA / profiling...")
37
+ profile: Dict[str, Any] = {
38
+ "overview": {
39
+ "n_rows": int(df.shape[0]),
40
+ "n_cols": int(df.shape[1]),
41
+ "memory_mb": float(df.memory_usage(deep=True).sum() / 1e6),
42
+ "dtypes": {c: str(t) for c, t in df.dtypes.items()},
43
+ },
44
+ "missing": {},
45
+ "numeric": {},
46
+ "categorical": {},
47
+ "target": {},
48
+ "duplicates": int(df.duplicated().sum()),
49
+ }
50
+
51
+ # Missing
52
+ miss = df.isna().sum()
53
+ miss_pct = (miss / len(df) * 100).round(2)
54
+ profile["missing"] = {
55
+ c: {"count": int(miss[c]), "pct": float(miss_pct[c])}
56
+ for c in df.columns
57
+ if miss[c] > 0
58
+ }
59
+
60
+ # Infer rough types if not provided
61
+ if feature_types is None:
62
+ feature_types = {}
63
+ for c in df.columns:
64
+ if c == target:
65
+ continue
66
+ if pd.api.types.is_numeric_dtype(df[c]):
67
+ feature_types[c] = "numeric"
68
+ else:
69
+ feature_types[c] = "categorical"
70
+
71
+ numeric_cols = [c for c, t in feature_types.items() if t == "numeric" and c in df.columns]
72
+ cat_cols = [c for c, t in feature_types.items() if t in ("categorical", "boolean") and c in df.columns]
73
+
74
+ for c in numeric_cols:
75
+ s = df[c]
76
+ profile["numeric"][c] = {
77
+ "mean": float(s.mean()) if s.notna().any() else None,
78
+ "median": float(s.median()) if s.notna().any() else None,
79
+ "std": float(s.std()) if s.notna().any() else None,
80
+ "min": float(s.min()) if s.notna().any() else None,
81
+ "max": float(s.max()) if s.notna().any() else None,
82
+ "skew": _safe_skew(s),
83
+ "n_unique": int(s.nunique(dropna=True)),
84
+ "n_missing": int(s.isna().sum()),
85
+ }
86
+
87
+ for c in cat_cols:
88
+ s = df[c]
89
+ vc = s.value_counts(dropna=False).head(max_cat_levels)
90
+ profile["categorical"][c] = {
91
+ "n_unique": int(s.nunique(dropna=True)),
92
+ "n_missing": int(s.isna().sum()),
93
+ "top_values": {str(k): int(v) for k, v in vc.items()},
94
+ }
95
+
96
+ if target and target in df.columns:
97
+ y = df[target]
98
+ if pd.api.types.is_numeric_dtype(y) and y.nunique(dropna=True) > 20:
99
+ profile["target"] = {
100
+ "type": "regression",
101
+ "mean": float(y.mean()),
102
+ "median": float(y.median()),
103
+ "std": float(y.std()),
104
+ "min": float(y.min()),
105
+ "max": float(y.max()),
106
+ "skew": _safe_skew(y),
107
+ "n_missing": int(y.isna().sum()),
108
+ }
109
+ else:
110
+ vc = y.value_counts(dropna=False)
111
+ profile["target"] = {
112
+ "type": "classification",
113
+ "n_classes": int(y.nunique(dropna=True)),
114
+ "distribution": {str(k): int(v) for k, v in vc.items()},
115
+ "n_missing": int(y.isna().sum()),
116
+ }
117
+
118
+ # Optional plots
119
+ if artifacts_dir is not None:
120
+ try:
121
+ _write_basic_plots(df, target, numeric_cols, cat_cols, Path(artifacts_dir))
122
+ except Exception as e:
123
+ logger.warning(f"Could not write EDA plots: {e}")
124
+
125
+ logger.info(
126
+ f"EDA complete — numeric: {len(numeric_cols)}, categorical: {len(cat_cols)}, "
127
+ f"columns with missing: {len(profile['missing'])}"
128
+ )
129
+ return profile
130
+
131
+
132
+ def _write_basic_plots(
133
+ df: pd.DataFrame,
134
+ target: Optional[str],
135
+ numeric_cols: List[str],
136
+ cat_cols: List[str],
137
+ artifacts_dir: Path,
138
+ ) -> None:
139
+ import matplotlib
140
+ matplotlib.use("Agg")
141
+ import matplotlib.pyplot as plt
142
+ import seaborn as sns
143
+
144
+ plot_dir = artifacts_dir / "plots"
145
+ plot_dir.mkdir(parents=True, exist_ok=True)
146
+
147
+ # Missing heatmap (sample columns if many)
148
+ miss_cols = [c for c in df.columns if df[c].isna().any()][:30]
149
+ if miss_cols:
150
+ fig, ax = plt.subplots(figsize=(10, 4))
151
+ sns.heatmap(df[miss_cols].isna(), cbar=False, ax=ax, yticklabels=False)
152
+ ax.set_title("Missing values (subset of columns)")
153
+ fig.tight_layout()
154
+ fig.savefig(plot_dir / "missing_heatmap.png", dpi=100)
155
+ plt.close(fig)
156
+
157
+ # Target distribution
158
+ if target and target in df.columns:
159
+ fig, ax = plt.subplots(figsize=(6, 4))
160
+ y = df[target]
161
+ if pd.api.types.is_numeric_dtype(y) and y.nunique() > 20:
162
+ ax.hist(y.dropna(), bins=40, edgecolor="black", alpha=0.7)
163
+ ax.set_title(f"Target distribution: {target}")
164
+ else:
165
+ y.value_counts().plot(kind="bar", ax=ax)
166
+ ax.set_title(f"Target class distribution: {target}")
167
+ fig.tight_layout()
168
+ fig.savefig(plot_dir / "target_distribution.png", dpi=100)
169
+ plt.close(fig)
170
+
171
+ # Numeric correlations (sample)
172
+ num = [c for c in numeric_cols if c != target][:15]
173
+ if len(num) >= 2:
174
+ fig, ax = plt.subplots(figsize=(8, 6))
175
+ corr = df[num].corr(numeric_only=True)
176
+ sns.heatmap(corr, ax=ax, cmap="RdBu_r", center=0, annot=False)
177
+ ax.set_title("Numeric feature correlations (subset)")
178
+ fig.tight_layout()
179
+ fig.savefig(plot_dir / "correlation_heatmap.png", dpi=100)
180
+ plt.close(fig)
181
+
182
+ logger.info(f"EDA plots saved to {plot_dir}")
data/splitter.py ADDED
@@ -0,0 +1,70 @@
1
+ """Train/test split — always before any learned preprocessing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional, Tuple
6
+
7
+ import pandas as pd
8
+ from sklearn.model_selection import train_test_split
9
+
10
+ from src.utils.logging import get_logger
11
+
12
+ logger = get_logger("data.splitter")
13
+
14
+
15
+ def split_data(
16
+ df: pd.DataFrame,
17
+ target: str,
18
+ test_size: float = 0.2,
19
+ random_state: int = 42,
20
+ stratify: bool | str = "auto",
21
+ problem_type: Optional[str] = None,
22
+ ) -> Tuple[pd.DataFrame, pd.DataFrame, pd.Series, pd.Series]:
23
+ """
24
+ Split into X_train, X_test, y_train, y_test.
25
+
26
+ Stratification is used for classification when appropriate.
27
+ """
28
+ if target not in df.columns:
29
+ raise ValueError(f"Target '{target}' not in dataframe columns.")
30
+
31
+ # Drop rows with missing target
32
+ mask = df[target].notna()
33
+ n_drop = int((~mask).sum())
34
+ if n_drop > 0:
35
+ logger.warning(f"Dropping {n_drop} rows with missing target before split.")
36
+ df = df.loc[mask].copy()
37
+
38
+ y = df[target]
39
+ X = df.drop(columns=[target])
40
+
41
+ use_stratify = False
42
+ if stratify == "auto":
43
+ if problem_type and "classification" in problem_type:
44
+ # Stratify only if every class has enough samples
45
+ min_count = y.value_counts().min()
46
+ if min_count >= 2 and test_size * len(y) >= y.nunique():
47
+ use_stratify = True
48
+ else:
49
+ logger.warning(
50
+ "Stratification disabled: some classes have too few samples for stratified split."
51
+ )
52
+ use_stratify = use_stratify
53
+ elif stratify is True:
54
+ use_stratify = True
55
+
56
+ stratify_arr = y if use_stratify else None
57
+
58
+ X_train, X_test, y_train, y_test = train_test_split(
59
+ X,
60
+ y,
61
+ test_size=test_size,
62
+ random_state=random_state,
63
+ stratify=stratify_arr,
64
+ )
65
+
66
+ logger.info(
67
+ f"Split — train: {len(X_train)} rows, test: {len(X_test)} rows "
68
+ f"(test_size={test_size}, stratify={use_stratify})"
69
+ )
70
+ return X_train, X_test, y_train, y_test
data/validator.py ADDED
@@ -0,0 +1,106 @@
1
+ """Dataset validation before any modeling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import pandas as pd
9
+
10
+ from src.utils.logging import get_logger
11
+
12
+ logger = get_logger("data.validator")
13
+
14
+
15
+ @dataclass
16
+ class ValidationReport:
17
+ n_rows: int
18
+ n_cols: int
19
+ n_duplicates: int
20
+ target_present: bool
21
+ target_missing: int
22
+ issues: List[str] = field(default_factory=list)
23
+ warnings: List[str] = field(default_factory=list)
24
+ dtypes: Dict[str, str] = field(default_factory=dict)
25
+
26
+ def to_dict(self) -> Dict[str, Any]:
27
+ return {
28
+ "n_rows": self.n_rows,
29
+ "n_cols": self.n_cols,
30
+ "n_duplicates": self.n_duplicates,
31
+ "target_present": self.target_present,
32
+ "target_missing": self.target_missing,
33
+ "issues": self.issues,
34
+ "warnings": self.warnings,
35
+ "dtypes": self.dtypes,
36
+ }
37
+
38
+
39
+ def validate_dataset(
40
+ df: pd.DataFrame,
41
+ target: Optional[str] = None,
42
+ require_target: bool = True,
43
+ ) -> ValidationReport:
44
+ """
45
+ Validate basic integrity of a dataset.
46
+
47
+ Raises ValueError on critical issues (empty, missing target when required).
48
+ """
49
+ issues: List[str] = []
50
+ warnings: List[str] = []
51
+
52
+ n_rows, n_cols = df.shape
53
+ if n_rows == 0:
54
+ raise ValueError("Dataset has zero rows.")
55
+ if n_cols == 0:
56
+ raise ValueError("Dataset has zero columns.")
57
+
58
+ n_duplicates = int(df.duplicated().sum())
59
+ if n_duplicates > 0:
60
+ warnings.append(f"Found {n_duplicates} duplicate rows ({100 * n_duplicates / n_rows:.1f}%).")
61
+
62
+ target_present = False
63
+ target_missing = 0
64
+ if target is not None:
65
+ if target not in df.columns:
66
+ if require_target:
67
+ raise ValueError(
68
+ f"Target column '{target}' was not found. "
69
+ f"Available columns: {list(df.columns)[:20]}{'...' if len(df.columns) > 20 else ''}"
70
+ )
71
+ issues.append(f"Target column '{target}' not found.")
72
+ else:
73
+ target_present = True
74
+ target_missing = int(df[target].isna().sum())
75
+ if target_missing > 0:
76
+ warnings.append(
77
+ f"Target '{target}' has {target_missing} missing values "
78
+ f"({100 * target_missing / n_rows:.1f}%). Rows with missing target will be dropped."
79
+ )
80
+ n_unique = df[target].nunique(dropna=True)
81
+ if n_unique < 2 and require_target:
82
+ raise ValueError(
83
+ f"Target '{target}' has fewer than 2 unique values (n_unique={n_unique}). "
84
+ "Cannot train a supervised model."
85
+ )
86
+
87
+ dtypes = {c: str(df[c].dtype) for c in df.columns}
88
+
89
+ report = ValidationReport(
90
+ n_rows=n_rows,
91
+ n_cols=n_cols,
92
+ n_duplicates=n_duplicates,
93
+ target_present=target_present,
94
+ target_missing=target_missing,
95
+ issues=issues,
96
+ warnings=warnings,
97
+ dtypes=dtypes,
98
+ )
99
+
100
+ for w in warnings:
101
+ logger.warning(w)
102
+ for i in issues:
103
+ logger.error(i)
104
+
105
+ logger.info(f"Validation OK — {n_rows} rows, {n_cols} columns, {n_duplicates} duplicates")
106
+ return report
detection/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .problem_type import detect_problem_type
2
+ from .feature_types import detect_feature_types
3
+ from .target import analyze_target
4
+
5
+ __all__ = ["detect_problem_type", "detect_feature_types", "analyze_target"]