devray-datalab-mcp 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.
- devray_datalab/__init__.py +3 -0
- devray_datalab/config.py +72 -0
- devray_datalab/core/__init__.py +1 -0
- devray_datalab/core/evaluator.py +145 -0
- devray_datalab/core/hardware.py +104 -0
- devray_datalab/core/loader.py +71 -0
- devray_datalab/core/preprocessing.py +115 -0
- devray_datalab/core/trainer.py +384 -0
- devray_datalab/server.py +364 -0
- devray_datalab/tools/__init__.py +1 -0
- devray_datalab/tools/analysis.py +212 -0
- devray_datalab/tools/datasets.py +133 -0
- devray_datalab/tools/models.py +254 -0
- devray_datalab/tools/storage.py +53 -0
- devray_datalab/tools/system.py +83 -0
- devray_datalab/tools/training.py +334 -0
- devray_datalab/utils/__init__.py +1 -0
- devray_datalab/utils/errors.py +59 -0
- devray_datalab/utils/validation.py +142 -0
- devray_datalab_mcp-0.1.0.dist-info/METADATA +476 -0
- devray_datalab_mcp-0.1.0.dist-info/RECORD +25 -0
- devray_datalab_mcp-0.1.0.dist-info/WHEEL +5 -0
- devray_datalab_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- devray_datalab_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- devray_datalab_mcp-0.1.0.dist-info/top_level.txt +1 -0
devray_datalab/config.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Server configuration.
|
|
2
|
+
|
|
3
|
+
Settings are read from environment variables with sensible defaults so the
|
|
4
|
+
server runs on modest hardware without any cloud credentials.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _env_str(name: str, default: str) -> str:
|
|
15
|
+
return os.environ.get(name, default)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _env_int(name: str, default: int) -> int:
|
|
19
|
+
try:
|
|
20
|
+
return int(os.environ.get(name, default))
|
|
21
|
+
except (TypeError, ValueError):
|
|
22
|
+
return default
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _env_float(name: str, default: float) -> float:
|
|
26
|
+
try:
|
|
27
|
+
return float(os.environ.get(name, default))
|
|
28
|
+
except (TypeError, ValueError):
|
|
29
|
+
return default
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
33
|
+
return os.environ.get(name, str(default)).lower() in ("1", "true", "yes", "on")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class DevRayConfig:
|
|
38
|
+
"""Runtime configuration for the DevRay DataLab MCP server."""
|
|
39
|
+
|
|
40
|
+
# Filesystem locations.
|
|
41
|
+
workspace_dir: Path = field(
|
|
42
|
+
default_factory=lambda: Path(_env_devray("DEVRAY_WORKSPACE_DIR", "datasets"))
|
|
43
|
+
)
|
|
44
|
+
artifacts_dir: Path = field(
|
|
45
|
+
default_factory=lambda: Path(_env_devray("DEVRAY_ARTIFACTS_DIR", "artifacts"))
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Dataset limits.
|
|
49
|
+
max_dataset_size_bytes: int = _env_int("DEVRAY_MAX_DATASET_SIZE_BYTES", 200 * 1024 * 1024)
|
|
50
|
+
allowed_extensions: tuple[str, ...] = (".csv",)
|
|
51
|
+
|
|
52
|
+
# Preview limits.
|
|
53
|
+
max_preview_rows: int = _env_int("DEVRAY_MAX_PREVIEW_ROWS", 25)
|
|
54
|
+
|
|
55
|
+
# Training / resource limits.
|
|
56
|
+
max_cpu_threads: int | None = _env_int("DEVRAY_MAX_CPU_THREADS", 0) or None
|
|
57
|
+
training_memory_budget_mb: int | None = _env_int("DEVRAY_TRAINING_MEMORY_BUDGET_MB", 0) or None
|
|
58
|
+
max_concurrent_jobs: int = _env_int("DEVRAY_MAX_CONCURRENT_JOBS", 1)
|
|
59
|
+
training_timeout_seconds: int = _env_int("DEVRAY_TRAINING_TIMEOUT_SECONDS", 1800)
|
|
60
|
+
|
|
61
|
+
# Logging.
|
|
62
|
+
log_level: str = _env_str("DEVRAY_LOG_LEVEL", "INFO")
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
self.workspace_dir = self.workspace_dir.resolve()
|
|
66
|
+
self.artifacts_dir = self.artifacts_dir.resolve()
|
|
67
|
+
self.workspace_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
self.artifacts_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _env_devray(name: str, default: str) -> str:
|
|
72
|
+
return os.environ.get(name, default)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core ML utilities package."""
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Evaluation metrics for classification and regression.
|
|
2
|
+
|
|
3
|
+
Metrics are computed with scikit-learn and include clear explanations so
|
|
4
|
+
clients can interpret results without guessing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from sklearn.metrics import (
|
|
14
|
+
accuracy_score,
|
|
15
|
+
f1_score,
|
|
16
|
+
mean_absolute_error,
|
|
17
|
+
mean_squared_error,
|
|
18
|
+
precision_score,
|
|
19
|
+
r2_score,
|
|
20
|
+
recall_score,
|
|
21
|
+
roc_auc_score,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ClassificationMetrics:
|
|
27
|
+
accuracy: float
|
|
28
|
+
precision: float
|
|
29
|
+
recall: float
|
|
30
|
+
f1: float
|
|
31
|
+
roc_auc: float | None
|
|
32
|
+
support: int
|
|
33
|
+
explanation: str
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
return {
|
|
37
|
+
"accuracy": round(self.accuracy, 6),
|
|
38
|
+
"precision": round(self.precision, 6),
|
|
39
|
+
"recall": round(self.recall, 6),
|
|
40
|
+
"f1": round(self.f1, 6),
|
|
41
|
+
"roc_auc": None if self.roc_auc is None else round(self.roc_auc, 6),
|
|
42
|
+
"support": self.support,
|
|
43
|
+
"explanation": self.explanation,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class RegressionMetrics:
|
|
49
|
+
mae: float
|
|
50
|
+
rmse: float
|
|
51
|
+
r2: float
|
|
52
|
+
support: int
|
|
53
|
+
explanation: str
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"mae": round(self.mae, 6),
|
|
58
|
+
"rmse": round(self.rmse, 6),
|
|
59
|
+
"r2": round(self.r2, 6),
|
|
60
|
+
"support": self.support,
|
|
61
|
+
"explanation": self.explanation,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def classification_explanation() -> str:
|
|
66
|
+
return (
|
|
67
|
+
"Accuracy = fraction of correct predictions. Precision = fraction of "
|
|
68
|
+
"positive predictions that are correct. Recall = fraction of actual "
|
|
69
|
+
"positives found. F1 = harmonic mean of precision and recall. "
|
|
70
|
+
"ROC-AUC = ability to rank positives above negatives (binary only)."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def regression_explanation() -> str:
|
|
75
|
+
return (
|
|
76
|
+
"MAE = average absolute error (same units as target). RMSE = root mean "
|
|
77
|
+
"squared error (penalizes large errors). R^2 = fraction of target "
|
|
78
|
+
"variance explained (1 = perfect, 0 = mean baseline, negative = worse "
|
|
79
|
+
"than mean)."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def evaluate_classification(
|
|
84
|
+
y_true: np.ndarray, y_pred: np.ndarray, y_proba: np.ndarray | None = None
|
|
85
|
+
) -> ClassificationMetrics:
|
|
86
|
+
"""Compute classification metrics with zero-division safety."""
|
|
87
|
+
average = "binary" if len(np.unique(y_true)) == 2 else "macro"
|
|
88
|
+
zero_division = 0
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
roc_auc: float | None = None
|
|
92
|
+
if y_proba is not None and len(np.unique(y_true)) == 2:
|
|
93
|
+
# Use probability of the positive class when available.
|
|
94
|
+
proba = np.asarray(y_proba)
|
|
95
|
+
if proba.ndim == 2 and proba.shape[1] >= 2:
|
|
96
|
+
roc_auc = float(roc_auc_score(y_true, proba[:, 1]))
|
|
97
|
+
elif proba.ndim == 1:
|
|
98
|
+
roc_auc = float(roc_auc_score(y_true, proba))
|
|
99
|
+
except Exception: # noqa: BLE001
|
|
100
|
+
roc_auc = None
|
|
101
|
+
|
|
102
|
+
return ClassificationMetrics(
|
|
103
|
+
accuracy=float(accuracy_score(y_true, y_pred)),
|
|
104
|
+
precision=float(
|
|
105
|
+
precision_score(y_true, y_pred, average=average, zero_division=zero_division)
|
|
106
|
+
),
|
|
107
|
+
recall=float(recall_score(y_true, y_pred, average=average, zero_division=zero_division)),
|
|
108
|
+
f1=float(f1_score(y_true, y_pred, average=average, zero_division=zero_division)),
|
|
109
|
+
roc_auc=roc_auc,
|
|
110
|
+
support=int(len(y_true)),
|
|
111
|
+
explanation=classification_explanation(),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def evaluate_regression(y_true: np.ndarray, y_pred: np.ndarray) -> RegressionMetrics:
|
|
116
|
+
"""Compute regression metrics."""
|
|
117
|
+
mae = float(mean_absolute_error(y_true, y_pred))
|
|
118
|
+
rmse = float(np.sqrt(mean_squared_error(y_true, y_pred)))
|
|
119
|
+
r2 = float(r2_score(y_true, y_pred))
|
|
120
|
+
return RegressionMetrics(
|
|
121
|
+
mae=mae,
|
|
122
|
+
rmse=rmse,
|
|
123
|
+
r2=r2,
|
|
124
|
+
support=int(len(y_true)),
|
|
125
|
+
explanation=regression_explanation(),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cross_validate_classification(
|
|
130
|
+
estimator: Any,
|
|
131
|
+
X: np.ndarray,
|
|
132
|
+
y: np.ndarray,
|
|
133
|
+
cv: int = 5,
|
|
134
|
+
scoring: str = "accuracy",
|
|
135
|
+
) -> dict[str, float]:
|
|
136
|
+
"""Run stratified cross-validation and return mean/std per metric."""
|
|
137
|
+
from sklearn.model_selection import StratifiedKFold, cross_validate
|
|
138
|
+
|
|
139
|
+
cv_splitter = StratifiedKFold(n_splits=min(cv, len(np.unique(y))), shuffle=True)
|
|
140
|
+
scores = cross_validate(estimator, X, y, cv=cv_splitter, scoring=scoring, n_jobs=1)
|
|
141
|
+
key = f"test_{scoring}"
|
|
142
|
+
return {
|
|
143
|
+
"mean": float(np.mean(scores[key])),
|
|
144
|
+
"std": float(np.std(scores[key])),
|
|
145
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Hardware and resource detection.
|
|
2
|
+
|
|
3
|
+
Reports CPU, memory, storage and GPU availability using only psutil and the
|
|
4
|
+
standard library. No PyTorch, TensorFlow, or CUDA dependencies are required.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import platform
|
|
11
|
+
import shutil
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
import psutil
|
|
15
|
+
|
|
16
|
+
from devray_datalab.utils.errors import HardwareError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class GpuInfo:
|
|
21
|
+
"""Detected GPU information."""
|
|
22
|
+
|
|
23
|
+
detected: bool = False
|
|
24
|
+
detail: str = "No supported GPU detected"
|
|
25
|
+
backend_available: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class SystemInfo:
|
|
30
|
+
operating_system: str
|
|
31
|
+
os_version: str
|
|
32
|
+
processor: str
|
|
33
|
+
cpu_cores_logical: int
|
|
34
|
+
cpu_cores_physical: int
|
|
35
|
+
cpu_percent: float
|
|
36
|
+
ram_total_gb: float
|
|
37
|
+
ram_available_gb: float
|
|
38
|
+
ram_percent: float
|
|
39
|
+
storage_total_gb: float
|
|
40
|
+
storage_free_gb: float
|
|
41
|
+
storage_used_percent: float
|
|
42
|
+
gpu: GpuInfo = field(default_factory=GpuInfo)
|
|
43
|
+
sklearn_cpu_backend: bool = True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _detect_gpu() -> GpuInfo:
|
|
47
|
+
"""Best-effort GPU detection without GPU dependencies.
|
|
48
|
+
|
|
49
|
+
Checks common GPU indicators exposed by psutil/the OS and reports whether
|
|
50
|
+
a GPU-accelerated sklearn backend appears to be installed.
|
|
51
|
+
"""
|
|
52
|
+
detail_parts: list[str] = []
|
|
53
|
+
detected = False
|
|
54
|
+
|
|
55
|
+
# NVIDIA GPU hint via nvidia-smi availability.
|
|
56
|
+
nvidia_smi = shutil.which("nvidia-smi")
|
|
57
|
+
if nvidia_smi:
|
|
58
|
+
detected = True
|
|
59
|
+
detail_parts.append("NVIDIA GPU (nvidia-smi available)")
|
|
60
|
+
|
|
61
|
+
# Try to detect GPU via py3nvml if present (optional, no hard dep).
|
|
62
|
+
try:
|
|
63
|
+
import py3nvml # type: ignore
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
py3nvml.nvmlInit()
|
|
67
|
+
count = py3nvml.nvmlDeviceGetCount()
|
|
68
|
+
if count > 0:
|
|
69
|
+
detected = True
|
|
70
|
+
detail_parts.append(f"NVIDIA GPU(s) via NVML: {count}")
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
except ImportError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
detail = "; ".join(detail_parts) if detail_parts else "No supported GPU detected"
|
|
77
|
+
return GpuInfo(detected=detected, detail=detail)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_system_info() -> SystemInfo:
|
|
81
|
+
"""Gather system hardware information."""
|
|
82
|
+
try:
|
|
83
|
+
vm = psutil.virtual_memory()
|
|
84
|
+
cpu_percent = psutil.cpu_percent(interval=0.1)
|
|
85
|
+
disk = shutil.disk_usage(os.getcwd())
|
|
86
|
+
gpu = _detect_gpu()
|
|
87
|
+
|
|
88
|
+
return SystemInfo(
|
|
89
|
+
operating_system=platform.system(),
|
|
90
|
+
os_version=platform.version(),
|
|
91
|
+
processor=platform.processor() or platform.machine(),
|
|
92
|
+
cpu_cores_logical=psutil.cpu_count(logical=True) or 1,
|
|
93
|
+
cpu_cores_physical=psutil.cpu_count(logical=False) or 1,
|
|
94
|
+
cpu_percent=cpu_percent,
|
|
95
|
+
ram_total_gb=round(vm.total / (1024**3), 2),
|
|
96
|
+
ram_available_gb=round(vm.available / (1024**3), 2),
|
|
97
|
+
ram_percent=round(vm.percent, 1),
|
|
98
|
+
storage_total_gb=round(disk.total / (1024**3), 2),
|
|
99
|
+
storage_free_gb=round(disk.free / (1024**3), 2),
|
|
100
|
+
storage_used_percent=round(disk.used / disk.total * 100, 1),
|
|
101
|
+
gpu=gpu,
|
|
102
|
+
)
|
|
103
|
+
except Exception as exc: # noqa: BLE001
|
|
104
|
+
raise HardwareError(f"Failed to gather system info: {exc}") from exc
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Dataset loading utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from devray_datalab.config import DevRayConfig
|
|
11
|
+
from devray_datalab.utils.errors import DatasetError
|
|
12
|
+
from devray_datalab.utils.validation import (
|
|
13
|
+
MAX_PREVIEW_ROWS,
|
|
14
|
+
validate_dataset_path,
|
|
15
|
+
validate_file_size,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def list_datasets(workspace: Path) -> list[dict[str, Any]]:
|
|
20
|
+
"""List datasets in the workspace with filenames, formats, and sizes."""
|
|
21
|
+
if not workspace.is_dir():
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
datasets: list[dict[str, Any]] = []
|
|
25
|
+
for entry in sorted(workspace.iterdir()):
|
|
26
|
+
if not entry.is_file():
|
|
27
|
+
continue
|
|
28
|
+
if entry.suffix.lower() not in (".csv",):
|
|
29
|
+
continue
|
|
30
|
+
try:
|
|
31
|
+
stat = entry.stat()
|
|
32
|
+
except OSError:
|
|
33
|
+
continue
|
|
34
|
+
datasets.append(
|
|
35
|
+
{
|
|
36
|
+
"filename": entry.name,
|
|
37
|
+
"format": entry.suffix.lower().lstrip("."),
|
|
38
|
+
"size_bytes": stat.st_size,
|
|
39
|
+
"size_mb": round(stat.st_size / (1024 * 1024), 3),
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
return datasets
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_dataset(config: DevRayConfig, name: str) -> pd.DataFrame:
|
|
46
|
+
"""Load a CSV dataset from the workspace, validating path and size."""
|
|
47
|
+
path = validate_dataset_path(config.workspace_dir, name, must_exist=True)
|
|
48
|
+
validate_file_size(path, config.max_dataset_size_bytes)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
df = pd.read_csv(path)
|
|
52
|
+
except Exception as exc: # noqa: BLE001 - surface a clear dataset error
|
|
53
|
+
raise DatasetError(f"Failed to load dataset '{name}': {exc}") from exc
|
|
54
|
+
|
|
55
|
+
if df.empty:
|
|
56
|
+
raise DatasetError(f"Dataset '{name}' is empty.")
|
|
57
|
+
return df
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def preview_dataframe(df: pd.DataFrame, max_rows: int = MAX_PREVIEW_ROWS) -> list[dict[str, Any]]:
|
|
61
|
+
"""Return the first ``max_rows`` rows as a list of dicts."""
|
|
62
|
+
return df.head(max_rows).to_dict(orient="records")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def dataset_statistics(df: pd.DataFrame) -> dict[str, Any]:
|
|
66
|
+
"""Compute numerical statistics for a dataframe."""
|
|
67
|
+
numeric = df.select_dtypes(include="number")
|
|
68
|
+
if numeric.empty:
|
|
69
|
+
return {}
|
|
70
|
+
stats = numeric.describe().to_dict()
|
|
71
|
+
return stats
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Preprocessing pipelines built from scikit-learn transformers.
|
|
2
|
+
|
|
3
|
+
Transformations are fitted on training data only and preserved for later
|
|
4
|
+
prediction, preventing data leakage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
from sklearn.compose import ColumnTransformer
|
|
15
|
+
from sklearn.impute import SimpleImputer
|
|
16
|
+
from sklearn.pipeline import Pipeline
|
|
17
|
+
from sklearn.preprocessing import OneHotEncoder, StandardScaler
|
|
18
|
+
|
|
19
|
+
from devray_datalab.utils.errors import ValidationError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class PreprocessingArtifact:
|
|
24
|
+
"""A fitted preprocessing pipeline plus the schema it was fitted on."""
|
|
25
|
+
|
|
26
|
+
preprocessor: ColumnTransformer
|
|
27
|
+
feature_columns: list[str]
|
|
28
|
+
target_column: str
|
|
29
|
+
task_type: str
|
|
30
|
+
numeric_features: list[str] = field(default_factory=list)
|
|
31
|
+
categorical_features: list[str] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _infer_feature_types(df: pd.DataFrame, target_column: str) -> tuple[list[str], list[str]]:
|
|
35
|
+
"""Split feature columns into numeric and categorical lists."""
|
|
36
|
+
if target_column not in df.columns:
|
|
37
|
+
raise ValidationError(f"Target column '{target_column}' not found in dataset.")
|
|
38
|
+
features = [c for c in df.columns if c != target_column]
|
|
39
|
+
|
|
40
|
+
numeric_features: list[str] = []
|
|
41
|
+
categorical_features: list[str] = []
|
|
42
|
+
for col in features:
|
|
43
|
+
if pd.api.types.is_numeric_dtype(df[col]):
|
|
44
|
+
numeric_features.append(col)
|
|
45
|
+
else:
|
|
46
|
+
categorical_features.append(col)
|
|
47
|
+
return numeric_features, categorical_features
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_preprocessor(
|
|
51
|
+
df: pd.DataFrame,
|
|
52
|
+
target_column: str,
|
|
53
|
+
task_type: str,
|
|
54
|
+
impute_strategy: str = "mean",
|
|
55
|
+
scale_numeric: bool = True,
|
|
56
|
+
encode_categorical: bool = True,
|
|
57
|
+
) -> PreprocessingArtifact:
|
|
58
|
+
"""Build and fit a preprocessing pipeline on ``df`` (training data only).
|
|
59
|
+
|
|
60
|
+
- Numeric columns: impute (median for robustness) then optionally standard-scale.
|
|
61
|
+
- Categorical columns: one-hot encode with unknown categories ignored.
|
|
62
|
+
"""
|
|
63
|
+
numeric_features, categorical_features = _infer_feature_types(df, target_column)
|
|
64
|
+
|
|
65
|
+
transformers: list[tuple[str, Any, list[str]]] = []
|
|
66
|
+
|
|
67
|
+
if numeric_features:
|
|
68
|
+
numeric_steps: list[tuple[str, Any]] = [("imputer", SimpleImputer(strategy="median"))]
|
|
69
|
+
if scale_numeric:
|
|
70
|
+
numeric_steps.append(("scaler", StandardScaler()))
|
|
71
|
+
transformers.append(("num", Pipeline(steps=numeric_steps), numeric_features))
|
|
72
|
+
|
|
73
|
+
if categorical_features:
|
|
74
|
+
encode_steps: list[tuple[str, Any]] = [("imputer", SimpleImputer(strategy="most_frequent"))]
|
|
75
|
+
if encode_categorical:
|
|
76
|
+
encode_steps.append(
|
|
77
|
+
(
|
|
78
|
+
"onehot",
|
|
79
|
+
OneHotEncoder(handle_unknown="ignore", sparse_output=False),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
transformers.append(("cat", Pipeline(steps=encode_steps), categorical_features))
|
|
83
|
+
|
|
84
|
+
if not transformers:
|
|
85
|
+
raise ValidationError("No feature columns available for preprocessing.")
|
|
86
|
+
|
|
87
|
+
preprocessor = ColumnTransformer(transformers=transformers, remainder="drop")
|
|
88
|
+
feature_columns = [c for c in df.columns if c != target_column]
|
|
89
|
+
preprocessor.fit(df[feature_columns])
|
|
90
|
+
|
|
91
|
+
return PreprocessingArtifact(
|
|
92
|
+
preprocessor=preprocessor,
|
|
93
|
+
feature_columns=feature_columns,
|
|
94
|
+
target_column=target_column,
|
|
95
|
+
task_type=task_type,
|
|
96
|
+
numeric_features=numeric_features,
|
|
97
|
+
categorical_features=categorical_features,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def transform_features(artifact: PreprocessingArtifact, df: pd.DataFrame) -> np.ndarray:
|
|
102
|
+
"""Transform a dataframe using a fitted preprocessing artifact."""
|
|
103
|
+
missing = [c for c in artifact.feature_columns if c not in df.columns]
|
|
104
|
+
if missing:
|
|
105
|
+
raise ValidationError(f"Missing feature columns: {', '.join(missing)}")
|
|
106
|
+
return artifact.preprocessor.transform(df[artifact.feature_columns])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_feature_names_out(artifact: PreprocessingArtifact) -> list[str]:
|
|
110
|
+
"""Return the output feature names after preprocessing."""
|
|
111
|
+
try:
|
|
112
|
+
names = artifact.preprocessor.get_feature_names_out()
|
|
113
|
+
return [str(n) for n in names]
|
|
114
|
+
except Exception: # pragma: no cover - fallback for older sklearn
|
|
115
|
+
return list(artifact.feature_columns)
|