QuickLab-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.
- ml_mcp/__init__.py +3 -0
- ml_mcp/application/__init__.py +1 -0
- ml_mcp/application/analysis/__init__.py +15 -0
- ml_mcp/application/analysis/diagnostics.py +122 -0
- ml_mcp/application/analysis/service.py +155 -0
- ml_mcp/application/artifacts/__init__.py +5 -0
- ml_mcp/application/artifacts/service.py +149 -0
- ml_mcp/application/datasets/__init__.py +6 -0
- ml_mcp/application/datasets/service.py +178 -0
- ml_mcp/application/datasets/validator.py +156 -0
- ml_mcp/application/experiments/__init__.py +5 -0
- ml_mcp/application/experiments/service.py +254 -0
- ml_mcp/application/models/__init__.py +6 -0
- ml_mcp/application/models/catalog_seed.py +131 -0
- ml_mcp/application/models/service.py +177 -0
- ml_mcp/application/projects/__init__.py +5 -0
- ml_mcp/application/projects/service.py +93 -0
- ml_mcp/config/__init__.py +5 -0
- ml_mcp/config/settings.py +176 -0
- ml_mcp/domain/__init__.py +1 -0
- ml_mcp/domain/errors/__init__.py +39 -0
- ml_mcp/domain/errors/base.py +155 -0
- ml_mcp/domain/errors/codes.py +23 -0
- ml_mcp/domain/policies/__init__.py +16 -0
- ml_mcp/domain/policies/rbac.py +126 -0
- ml_mcp/domain/policies/tenant_isolation.py +34 -0
- ml_mcp/domain/policies/tool_policy.py +185 -0
- ml_mcp/domain/value_objects/__init__.py +41 -0
- ml_mcp/domain/value_objects/dataset.py +60 -0
- ml_mcp/domain/value_objects/experiment.py +131 -0
- ml_mcp/domain/value_objects/model.py +48 -0
- ml_mcp/infrastructure/__init__.py +1 -0
- ml_mcp/infrastructure/object_storage/__init__.py +5 -0
- ml_mcp/infrastructure/object_storage/s3.py +272 -0
- ml_mcp/infrastructure/postgres/__init__.py +14 -0
- ml_mcp/infrastructure/postgres/base.py +40 -0
- ml_mcp/infrastructure/postgres/models.py +356 -0
- ml_mcp/infrastructure/postgres/repositories/__init__.py +21 -0
- ml_mcp/infrastructure/postgres/repositories/artifacts.py +34 -0
- ml_mcp/infrastructure/postgres/repositories/audit.py +34 -0
- ml_mcp/infrastructure/postgres/repositories/datasets.py +85 -0
- ml_mcp/infrastructure/postgres/repositories/experiments.py +134 -0
- ml_mcp/infrastructure/postgres/repositories/metrics.py +61 -0
- ml_mcp/infrastructure/postgres/repositories/models.py +61 -0
- ml_mcp/infrastructure/postgres/repositories/outbox.py +47 -0
- ml_mcp/infrastructure/postgres/repositories/projects.py +83 -0
- ml_mcp/infrastructure/postgres/session.py +117 -0
- ml_mcp/infrastructure/queue/__init__.py +6 -0
- ml_mcp/infrastructure/queue/outbox_processor.py +67 -0
- ml_mcp/infrastructure/queue/task_queue.py +98 -0
- ml_mcp/infrastructure/redis/__init__.py +11 -0
- ml_mcp/infrastructure/redis/client.py +64 -0
- ml_mcp/infrastructure/redis/rate_limiter.py +129 -0
- ml_mcp/infrastructure/telemetry/__init__.py +23 -0
- ml_mcp/infrastructure/telemetry/logging.py +155 -0
- ml_mcp/infrastructure/telemetry/otel.py +141 -0
- ml_mcp/mcp/__init__.py +1 -0
- ml_mcp/mcp/prompts.py +38 -0
- ml_mcp/mcp/resources.py +35 -0
- ml_mcp/mcp/schemas/__init__.py +1 -0
- ml_mcp/mcp/schemas/tools.py +124 -0
- ml_mcp/mcp/tools/__init__.py +63 -0
- ml_mcp/mcp/tools/analysis.py +37 -0
- ml_mcp/mcp/tools/datasets.py +82 -0
- ml_mcp/mcp/tools/discovery.py +82 -0
- ml_mcp/mcp/tools/experiments.py +96 -0
- ml_mcp/mcp/tools/projects.py +39 -0
- ml_mcp/mcp/tools/results.py +59 -0
- ml_mcp/server/__init__.py +1 -0
- ml_mcp/server/app.py +488 -0
- ml_mcp/server/auth.py +146 -0
- ml_mcp/server/context.py +59 -0
- ml_mcp/server/http.py +183 -0
- ml_mcp/server/middleware.py +121 -0
- ml_mcp/server/stdio.py +42 -0
- ml_mcp/workers/__init__.py +1 -0
- ml_mcp/workers/daemon.py +130 -0
- ml_mcp/workers/metrics.py +110 -0
- ml_mcp/workers/preprocessing.py +170 -0
- ml_mcp/workers/runner.py +250 -0
- ml_mcp/workers/sandbox.py +35 -0
- ml_mcp/workers/trainers/__init__.py +52 -0
- ml_mcp/workers/trainers/base.py +52 -0
- ml_mcp/workers/trainers/catboost_model.py +85 -0
- ml_mcp/workers/trainers/lightgbm_model.py +81 -0
- ml_mcp/workers/trainers/sklearn_models.py +266 -0
- ml_mcp/workers/trainers/xgboost_model.py +82 -0
- quicklab_mcp-0.1.0.dist-info/METADATA +540 -0
- quicklab_mcp-0.1.0.dist-info/RECORD +92 -0
- quicklab_mcp-0.1.0.dist-info/WHEEL +5 -0
- quicklab_mcp-0.1.0.dist-info/entry_points.txt +4 -0
- quicklab_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Leak-free feature preprocessing and dataset splitting pipeline."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from sklearn.compose import ColumnTransformer
|
|
6
|
+
from sklearn.impute import SimpleImputer
|
|
7
|
+
from sklearn.model_selection import (
|
|
8
|
+
KFold,
|
|
9
|
+
StratifiedKFold,
|
|
10
|
+
TimeSeriesSplit,
|
|
11
|
+
train_test_split,
|
|
12
|
+
)
|
|
13
|
+
from sklearn.preprocessing import OneHotEncoder, StandardScaler
|
|
14
|
+
|
|
15
|
+
from ml_mcp.domain.value_objects import (
|
|
16
|
+
SplitStrategy,
|
|
17
|
+
SplitStrategyType,
|
|
18
|
+
TaskType,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PreprocessingPipeline:
|
|
23
|
+
"""Encapsulates dataset partitioning and strict leak-free feature transformations."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
target_column: str,
|
|
28
|
+
task_type: TaskType,
|
|
29
|
+
feature_columns: list[str] | None = None,
|
|
30
|
+
split_strategy: SplitStrategy | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.target_column = target_column
|
|
33
|
+
self.task_type = task_type
|
|
34
|
+
self.feature_columns = feature_columns
|
|
35
|
+
self.split_strategy = split_strategy or SplitStrategy()
|
|
36
|
+
self.transformer: ColumnTransformer | None = None
|
|
37
|
+
self.numeric_features: list[str] = []
|
|
38
|
+
self.categorical_features: list[str] = []
|
|
39
|
+
|
|
40
|
+
def prepare_data(self, df: pd.DataFrame) -> tuple[pd.DataFrame, pd.Series]:
|
|
41
|
+
"""Validate presence of target and features and separate X and y."""
|
|
42
|
+
if self.target_column not in df.columns:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"Target column '{self.target_column}' not found in dataset columns: {list(df.columns)}"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
y = df[self.target_column]
|
|
48
|
+
if self.feature_columns:
|
|
49
|
+
missing = [c for c in self.feature_columns if c not in df.columns]
|
|
50
|
+
if missing:
|
|
51
|
+
raise ValueError(f"Feature columns {missing} missing from dataset")
|
|
52
|
+
X = df[self.feature_columns].copy()
|
|
53
|
+
else:
|
|
54
|
+
X = df.drop(columns=[self.target_column]).copy()
|
|
55
|
+
|
|
56
|
+
# Identify numeric vs categorical columns
|
|
57
|
+
self.numeric_features = list(X.select_dtypes(include=[np.number]).columns)
|
|
58
|
+
self.categorical_features = [c for c in X.columns if c not in self.numeric_features]
|
|
59
|
+
|
|
60
|
+
return X, y
|
|
61
|
+
|
|
62
|
+
def build_transformer(self) -> ColumnTransformer:
|
|
63
|
+
"""Construct scikit-learn ColumnTransformer for leak-free transformations."""
|
|
64
|
+
transformers = []
|
|
65
|
+
if self.numeric_features:
|
|
66
|
+
from sklearn.pipeline import Pipeline
|
|
67
|
+
|
|
68
|
+
num_pipe = Pipeline(
|
|
69
|
+
[
|
|
70
|
+
("imputer", SimpleImputer(strategy="median")),
|
|
71
|
+
("scaler", StandardScaler()),
|
|
72
|
+
]
|
|
73
|
+
)
|
|
74
|
+
transformers.append(("numeric", num_pipe, self.numeric_features))
|
|
75
|
+
|
|
76
|
+
if self.categorical_features:
|
|
77
|
+
from sklearn.pipeline import Pipeline
|
|
78
|
+
|
|
79
|
+
cat_pipe = Pipeline(
|
|
80
|
+
[
|
|
81
|
+
("imputer", SimpleImputer(strategy="most_frequent")),
|
|
82
|
+
("encoder", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
transformers.append(("categorical", cat_pipe, self.categorical_features))
|
|
86
|
+
|
|
87
|
+
return ColumnTransformer(transformers=transformers, remainder="drop")
|
|
88
|
+
|
|
89
|
+
def split_data(
|
|
90
|
+
self,
|
|
91
|
+
X: pd.DataFrame,
|
|
92
|
+
y: pd.Series,
|
|
93
|
+
) -> list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]]:
|
|
94
|
+
"""Split data into (X_train, X_val, y_train, y_val) folds based on SplitStrategy."""
|
|
95
|
+
strat = self.split_strategy
|
|
96
|
+
seed = strat.random_seed
|
|
97
|
+
|
|
98
|
+
if strat.strategy == SplitStrategyType.TRAIN_TEST_SPLIT:
|
|
99
|
+
stratify = (
|
|
100
|
+
y
|
|
101
|
+
if self.task_type
|
|
102
|
+
in (TaskType.BINARY_CLASSIFICATION, TaskType.MULTICLASS_CLASSIFICATION)
|
|
103
|
+
else None
|
|
104
|
+
)
|
|
105
|
+
X_tr, X_val, y_tr, y_val = train_test_split(
|
|
106
|
+
X,
|
|
107
|
+
y,
|
|
108
|
+
test_size=strat.test_size,
|
|
109
|
+
random_state=seed,
|
|
110
|
+
shuffle=strat.shuffle,
|
|
111
|
+
stratify=stratify,
|
|
112
|
+
)
|
|
113
|
+
return [(X_tr, X_val, y_tr, y_val)]
|
|
114
|
+
|
|
115
|
+
elif strat.strategy == SplitStrategyType.STRATIFIED_K_FOLD:
|
|
116
|
+
skf = StratifiedKFold(n_splits=strat.n_splits, shuffle=strat.shuffle, random_state=seed)
|
|
117
|
+
folds = []
|
|
118
|
+
for train_idx, val_idx in skf.split(X, y):
|
|
119
|
+
folds.append(
|
|
120
|
+
(X.iloc[train_idx], X.iloc[val_idx], y.iloc[train_idx], y.iloc[val_idx])
|
|
121
|
+
)
|
|
122
|
+
return folds
|
|
123
|
+
|
|
124
|
+
elif strat.strategy == SplitStrategyType.K_FOLD:
|
|
125
|
+
kf = KFold(n_splits=strat.n_splits, shuffle=strat.shuffle, random_state=seed)
|
|
126
|
+
folds = []
|
|
127
|
+
for train_idx, val_idx in kf.split(X):
|
|
128
|
+
folds.append(
|
|
129
|
+
(X.iloc[train_idx], X.iloc[val_idx], y.iloc[train_idx], y.iloc[val_idx])
|
|
130
|
+
)
|
|
131
|
+
return folds
|
|
132
|
+
|
|
133
|
+
elif strat.strategy == SplitStrategyType.TIME_SERIES_SPLIT:
|
|
134
|
+
tscv = TimeSeriesSplit(n_splits=strat.n_splits)
|
|
135
|
+
folds = []
|
|
136
|
+
for train_idx, val_idx in tscv.split(X):
|
|
137
|
+
folds.append(
|
|
138
|
+
(X.iloc[train_idx], X.iloc[val_idx], y.iloc[train_idx], y.iloc[val_idx])
|
|
139
|
+
)
|
|
140
|
+
return folds
|
|
141
|
+
|
|
142
|
+
else:
|
|
143
|
+
raise ValueError(f"Unknown split strategy: {strat.strategy}")
|
|
144
|
+
|
|
145
|
+
def fit_transform_folds(
|
|
146
|
+
self,
|
|
147
|
+
df: pd.DataFrame,
|
|
148
|
+
) -> list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, ColumnTransformer]]:
|
|
149
|
+
"""Separate, split, and fit transformers strictly on train folds to prevent data leakage."""
|
|
150
|
+
X, y = self.prepare_data(df)
|
|
151
|
+
raw_folds = self.split_data(X, y)
|
|
152
|
+
|
|
153
|
+
transformed_folds = []
|
|
154
|
+
for X_tr_raw, X_val_raw, y_tr_raw, y_val_raw in raw_folds:
|
|
155
|
+
transformer = self.build_transformer()
|
|
156
|
+
# STRICT LEAK-FREE RULE: fit strictly on train fold
|
|
157
|
+
X_tr_proc = transformer.fit_transform(X_tr_raw)
|
|
158
|
+
# transform validation fold using fitted statistics
|
|
159
|
+
X_val_proc = transformer.transform(X_val_raw)
|
|
160
|
+
|
|
161
|
+
y_tr_arr = np.asarray(y_tr_raw)
|
|
162
|
+
y_val_arr = np.asarray(y_val_raw)
|
|
163
|
+
|
|
164
|
+
transformed_folds.append((X_tr_proc, X_val_proc, y_tr_arr, y_val_arr, transformer))
|
|
165
|
+
|
|
166
|
+
# Retain last transformer as primary fitted reference
|
|
167
|
+
if transformed_folds:
|
|
168
|
+
self.transformer = transformed_folds[-1][4]
|
|
169
|
+
|
|
170
|
+
return transformed_folds
|
ml_mcp/workers/runner.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Worker runner executing end-to-end ML model training and artifact persistence."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import io
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
|
|
12
|
+
from ml_mcp.domain.value_objects import ExperimentSpec
|
|
13
|
+
from ml_mcp.infrastructure.object_storage.s3 import S3StorageService, get_storage_service
|
|
14
|
+
from ml_mcp.infrastructure.postgres.base import generate_uuid7
|
|
15
|
+
from ml_mcp.infrastructure.postgres.models import (
|
|
16
|
+
ArtifactOrm,
|
|
17
|
+
ExperimentRunOrm,
|
|
18
|
+
MetricOrm,
|
|
19
|
+
)
|
|
20
|
+
from ml_mcp.infrastructure.postgres.repositories import (
|
|
21
|
+
ArtifactRepository,
|
|
22
|
+
DatasetRepository,
|
|
23
|
+
ExperimentRepository,
|
|
24
|
+
MetricRepository,
|
|
25
|
+
ModelRepository,
|
|
26
|
+
)
|
|
27
|
+
from ml_mcp.workers.preprocessing import PreprocessingPipeline
|
|
28
|
+
from ml_mcp.workers.sandbox import WorkerSandbox
|
|
29
|
+
from ml_mcp.workers.trainers import get_trainer
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WorkerRunner:
|
|
35
|
+
"""Orchestrates ML experiment execution, metric logging, and artifact persistence."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
session: AsyncSession,
|
|
40
|
+
storage_service: S3StorageService | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.session = session
|
|
43
|
+
self.storage = storage_service or get_storage_service()
|
|
44
|
+
self.exp_repo = ExperimentRepository(session)
|
|
45
|
+
self.ds_repo = DatasetRepository(session)
|
|
46
|
+
self.model_repo = ModelRepository(session)
|
|
47
|
+
self.metric_repo = MetricRepository(session)
|
|
48
|
+
self.artifact_repo = ArtifactRepository(session)
|
|
49
|
+
|
|
50
|
+
async def execute_experiment(
|
|
51
|
+
self,
|
|
52
|
+
experiment_id: str,
|
|
53
|
+
tenant_id: str,
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
"""Execute experiment end-to-end within isolated sandbox."""
|
|
56
|
+
start_time = time.time()
|
|
57
|
+
|
|
58
|
+
# 1. Fetch experiment record
|
|
59
|
+
exp = await self.exp_repo.get_experiment(tenant_id, experiment_id)
|
|
60
|
+
if not exp:
|
|
61
|
+
raise ValueError(f"Experiment '{experiment_id}' not found for tenant '{tenant_id}'")
|
|
62
|
+
|
|
63
|
+
spec = ExperimentSpec(**exp.spec_json)
|
|
64
|
+
|
|
65
|
+
# 2. Mark running & create run record
|
|
66
|
+
await self.exp_repo.update_status(tenant_id, experiment_id, "RUNNING")
|
|
67
|
+
run_id = generate_uuid7()
|
|
68
|
+
run_orm = ExperimentRunOrm(
|
|
69
|
+
id=run_id,
|
|
70
|
+
experiment_id=experiment_id,
|
|
71
|
+
run_number=len(exp.runs) + 1,
|
|
72
|
+
status="RUNNING",
|
|
73
|
+
worker_id="worker-cpu-01",
|
|
74
|
+
)
|
|
75
|
+
await self.exp_repo.create_run(run_orm)
|
|
76
|
+
|
|
77
|
+
sandbox = WorkerSandbox(
|
|
78
|
+
timeout_seconds=spec.resource_policy.max_runtime_seconds,
|
|
79
|
+
max_memory_mb=spec.resource_policy.max_memory_mb,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
result = await sandbox.execute(self._execute_training_job, exp, spec, run_id)
|
|
84
|
+
duration = time.time() - start_time
|
|
85
|
+
await self.exp_repo.update_run(
|
|
86
|
+
run_id, "SUCCEEDED", duration_seconds=duration, end_time=True
|
|
87
|
+
)
|
|
88
|
+
await self.exp_repo.update_status(tenant_id, experiment_id, "SUCCEEDED")
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
duration = time.time() - start_time
|
|
93
|
+
logger.error("Experiment %s execution failed: %s", experiment_id, exc)
|
|
94
|
+
await self.exp_repo.update_run(
|
|
95
|
+
run_id, "FAILED", duration_seconds=duration, failure_reason=str(exc), end_time=True
|
|
96
|
+
)
|
|
97
|
+
await self.exp_repo.update_status(tenant_id, experiment_id, "FAILED")
|
|
98
|
+
raise
|
|
99
|
+
|
|
100
|
+
async def _execute_training_job(
|
|
101
|
+
self,
|
|
102
|
+
exp: Any,
|
|
103
|
+
spec: ExperimentSpec,
|
|
104
|
+
run_id: str,
|
|
105
|
+
) -> dict[str, Any]:
|
|
106
|
+
tenant_id = exp.tenant_id
|
|
107
|
+
experiment_id = exp.id
|
|
108
|
+
|
|
109
|
+
# 1. Download dataset Parquet from S3
|
|
110
|
+
dsv = await self.ds_repo.get_version_by_id(tenant_id, spec.dataset_version_id)
|
|
111
|
+
if not dsv:
|
|
112
|
+
raise ValueError(f"DatasetVersion '{spec.dataset_version_id}' not found")
|
|
113
|
+
|
|
114
|
+
dataset_key = self.storage.get_dataset_key(
|
|
115
|
+
tenant_id, dsv.dataset_id, dsv.version, "data.parquet"
|
|
116
|
+
)
|
|
117
|
+
dataset_bytes = await asyncio.to_thread(self.storage.get_object, dataset_key)
|
|
118
|
+
df = await asyncio.to_thread(pd.read_parquet, io.BytesIO(dataset_bytes))
|
|
119
|
+
|
|
120
|
+
# 2. Preprocess features and create leak-free splits
|
|
121
|
+
prep = PreprocessingPipeline(
|
|
122
|
+
target_column=spec.target_column,
|
|
123
|
+
task_type=spec.task_type,
|
|
124
|
+
feature_columns=spec.feature_columns,
|
|
125
|
+
split_strategy=spec.split_strategy,
|
|
126
|
+
)
|
|
127
|
+
folds = await asyncio.to_thread(prep.fit_transform_folds, df)
|
|
128
|
+
X_tr, X_val, y_tr, y_val, fitted_transformer = folds[0]
|
|
129
|
+
|
|
130
|
+
# 3. Fetch model metadata and instantiate trainer
|
|
131
|
+
model_version = exp.model_version
|
|
132
|
+
model_family = model_version.model.family
|
|
133
|
+
trainer = get_trainer(model_family)
|
|
134
|
+
|
|
135
|
+
# 4. Train model
|
|
136
|
+
train_result = await asyncio.to_thread(
|
|
137
|
+
trainer.train,
|
|
138
|
+
X_tr=X_tr,
|
|
139
|
+
y_tr=y_tr,
|
|
140
|
+
X_val=X_val,
|
|
141
|
+
y_val=y_val,
|
|
142
|
+
task_type=spec.task_type,
|
|
143
|
+
hyperparameters=spec.hyperparameters,
|
|
144
|
+
random_seed=spec.random_seed,
|
|
145
|
+
max_cpu_cores=spec.resource_policy.max_cpu_cores,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# 5. Save model artifact to S3
|
|
149
|
+
model_key = self.storage.get_experiment_artifact_key(
|
|
150
|
+
tenant_id, experiment_id, "model", "model.joblib"
|
|
151
|
+
)
|
|
152
|
+
model_bytes = await asyncio.to_thread(train_result.serialize_artifact)
|
|
153
|
+
model_uri, model_hash = await asyncio.to_thread(
|
|
154
|
+
self.storage.put_object,
|
|
155
|
+
key=model_key,
|
|
156
|
+
data=model_bytes,
|
|
157
|
+
content_type="application/octet-stream",
|
|
158
|
+
)
|
|
159
|
+
await self.artifact_repo.record_artifact(
|
|
160
|
+
ArtifactOrm(
|
|
161
|
+
id=generate_uuid7(),
|
|
162
|
+
tenant_id=tenant_id,
|
|
163
|
+
experiment_id=experiment_id,
|
|
164
|
+
artifact_type="model",
|
|
165
|
+
storage_uri=model_uri,
|
|
166
|
+
size_bytes=len(train_result.model_artifact_bytes),
|
|
167
|
+
content_hash=model_hash,
|
|
168
|
+
metadata_json={"model_family": model_family, "task_type": spec.task_type.value},
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# 6. Save predictions to S3
|
|
173
|
+
pred_df = pd.DataFrame(
|
|
174
|
+
{
|
|
175
|
+
"y_true": y_val,
|
|
176
|
+
"y_pred": train_result.val_predictions,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
if train_result.val_probabilities is not None:
|
|
180
|
+
if train_result.val_probabilities.ndim == 1:
|
|
181
|
+
pred_df["y_proba"] = train_result.val_probabilities
|
|
182
|
+
else:
|
|
183
|
+
for idx in range(train_result.val_probabilities.shape[1]):
|
|
184
|
+
pred_df[f"y_proba_class_{idx}"] = train_result.val_probabilities[:, idx]
|
|
185
|
+
|
|
186
|
+
pred_buf = io.BytesIO()
|
|
187
|
+
await asyncio.to_thread(pred_df.to_parquet, pred_buf, index=False)
|
|
188
|
+
pred_bytes = pred_buf.getvalue()
|
|
189
|
+
|
|
190
|
+
pred_key = self.storage.get_experiment_artifact_key(
|
|
191
|
+
tenant_id, experiment_id, "predictions", "predictions.parquet"
|
|
192
|
+
)
|
|
193
|
+
pred_uri, pred_hash = await asyncio.to_thread(
|
|
194
|
+
self.storage.put_object,
|
|
195
|
+
key=pred_key,
|
|
196
|
+
data=pred_bytes,
|
|
197
|
+
content_type="application/vnd.apache.parquet",
|
|
198
|
+
)
|
|
199
|
+
await self.artifact_repo.record_artifact(
|
|
200
|
+
ArtifactOrm(
|
|
201
|
+
id=generate_uuid7(),
|
|
202
|
+
tenant_id=tenant_id,
|
|
203
|
+
experiment_id=experiment_id,
|
|
204
|
+
artifact_type="predictions",
|
|
205
|
+
storage_uri=pred_uri,
|
|
206
|
+
size_bytes=len(pred_bytes),
|
|
207
|
+
content_hash=pred_hash,
|
|
208
|
+
metadata_json={"row_count": len(pred_df)},
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# 7. Record metrics in DB
|
|
213
|
+
metric_records: list[MetricOrm] = []
|
|
214
|
+
for metric_name, val in train_result.validation_metrics.items():
|
|
215
|
+
if isinstance(val, (int, float)):
|
|
216
|
+
metric_records.append(
|
|
217
|
+
MetricOrm(
|
|
218
|
+
id=generate_uuid7(),
|
|
219
|
+
experiment_id=experiment_id,
|
|
220
|
+
run_id=run_id,
|
|
221
|
+
metric_name=metric_name,
|
|
222
|
+
metric_value=float(val),
|
|
223
|
+
split="validation",
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
for metric_name, val in train_result.train_metrics.items():
|
|
228
|
+
if isinstance(val, (int, float)):
|
|
229
|
+
metric_records.append(
|
|
230
|
+
MetricOrm(
|
|
231
|
+
id=generate_uuid7(),
|
|
232
|
+
experiment_id=experiment_id,
|
|
233
|
+
run_id=run_id,
|
|
234
|
+
metric_name=metric_name,
|
|
235
|
+
metric_value=float(val),
|
|
236
|
+
split="train",
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
await self.metric_repo.record_metrics(metric_records)
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
"experiment_id": experiment_id,
|
|
244
|
+
"status": "SUCCEEDED",
|
|
245
|
+
"metrics": train_result.validation_metrics,
|
|
246
|
+
"artifacts": {
|
|
247
|
+
"model_uri": model_uri,
|
|
248
|
+
"predictions_uri": pred_uri,
|
|
249
|
+
},
|
|
250
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Worker sandboxing and resource boundary enforcement."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Callable, Coroutine
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
from ml_mcp.domain.errors import ResourceLimitExceededError
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WorkerSandbox:
|
|
16
|
+
"""Safeguards worker execution with wall-clock timeouts and error containment."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, timeout_seconds: int = 1800, max_memory_mb: int = 4096) -> None:
|
|
19
|
+
self.timeout_seconds = timeout_seconds
|
|
20
|
+
self.max_memory_mb = max_memory_mb
|
|
21
|
+
|
|
22
|
+
async def execute(
|
|
23
|
+
self,
|
|
24
|
+
coro_fn: Callable[..., Coroutine[Any, Any, T]],
|
|
25
|
+
*args: Any,
|
|
26
|
+
**kwargs: Any,
|
|
27
|
+
) -> T:
|
|
28
|
+
"""Execute async function wrapped with wall-clock timeout."""
|
|
29
|
+
try:
|
|
30
|
+
return await asyncio.wait_for(coro_fn(*args, **kwargs), timeout=self.timeout_seconds)
|
|
31
|
+
except TimeoutError as exc:
|
|
32
|
+
logger.error("Worker execution timed out after %d seconds", self.timeout_seconds)
|
|
33
|
+
raise ResourceLimitExceededError(
|
|
34
|
+
"execution_time", f"{self.timeout_seconds}s", "timeout"
|
|
35
|
+
) from exc
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Model trainers registry and factory."""
|
|
2
|
+
|
|
3
|
+
from ml_mcp.domain.value_objects import ModelFamily
|
|
4
|
+
from ml_mcp.workers.trainers.base import BaseModelTrainer, TrainingResult
|
|
5
|
+
from ml_mcp.workers.trainers.catboost_model import CatBoostTrainer
|
|
6
|
+
from ml_mcp.workers.trainers.lightgbm_model import LightGBMTrainer
|
|
7
|
+
from ml_mcp.workers.trainers.sklearn_models import (
|
|
8
|
+
LinearSVMTrainer,
|
|
9
|
+
LogisticRegressionTrainer,
|
|
10
|
+
MLPTrainer,
|
|
11
|
+
RandomForestTrainer,
|
|
12
|
+
)
|
|
13
|
+
from ml_mcp.workers.trainers.xgboost_model import XGBoostTrainer
|
|
14
|
+
|
|
15
|
+
TRAINER_MAP: dict[ModelFamily | str, type[BaseModelTrainer]] = {
|
|
16
|
+
ModelFamily.LOGISTIC_REGRESSION: LogisticRegressionTrainer,
|
|
17
|
+
ModelFamily.LOGISTIC_REGRESSION.value: LogisticRegressionTrainer,
|
|
18
|
+
ModelFamily.RANDOM_FOREST: RandomForestTrainer,
|
|
19
|
+
ModelFamily.RANDOM_FOREST.value: RandomForestTrainer,
|
|
20
|
+
ModelFamily.XGBOOST: XGBoostTrainer,
|
|
21
|
+
ModelFamily.XGBOOST.value: XGBoostTrainer,
|
|
22
|
+
ModelFamily.LIGHTGBM: LightGBMTrainer,
|
|
23
|
+
ModelFamily.LIGHTGBM.value: LightGBMTrainer,
|
|
24
|
+
ModelFamily.CATBOOST: CatBoostTrainer,
|
|
25
|
+
ModelFamily.CATBOOST.value: CatBoostTrainer,
|
|
26
|
+
ModelFamily.LINEAR_SVM: LinearSVMTrainer,
|
|
27
|
+
ModelFamily.LINEAR_SVM.value: LinearSVMTrainer,
|
|
28
|
+
ModelFamily.MLP: MLPTrainer,
|
|
29
|
+
ModelFamily.MLP.value: MLPTrainer,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_trainer(family: ModelFamily | str) -> BaseModelTrainer:
|
|
34
|
+
"""Return concrete trainer instance for a given model family."""
|
|
35
|
+
trainer_cls = TRAINER_MAP.get(family)
|
|
36
|
+
if not trainer_cls:
|
|
37
|
+
raise ValueError(f"No trainer registered for model family '{family}'")
|
|
38
|
+
return trainer_cls()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"BaseModelTrainer",
|
|
43
|
+
"TrainingResult",
|
|
44
|
+
"LogisticRegressionTrainer",
|
|
45
|
+
"RandomForestTrainer",
|
|
46
|
+
"LinearSVMTrainer",
|
|
47
|
+
"MLPTrainer",
|
|
48
|
+
"XGBoostTrainer",
|
|
49
|
+
"LightGBMTrainer",
|
|
50
|
+
"CatBoostTrainer",
|
|
51
|
+
"get_trainer",
|
|
52
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Base trainer contract and training result container."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import joblib
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ml_mcp.domain.value_objects import TaskType
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class TrainingResult:
|
|
16
|
+
"""Artifacts, predictions, and metrics emitted from model training."""
|
|
17
|
+
|
|
18
|
+
model: Any
|
|
19
|
+
task_type: TaskType
|
|
20
|
+
train_metrics: dict[str, Any]
|
|
21
|
+
validation_metrics: dict[str, Any]
|
|
22
|
+
feature_importances: dict[str, float]
|
|
23
|
+
val_predictions: np.ndarray
|
|
24
|
+
val_probabilities: np.ndarray | None
|
|
25
|
+
model_artifact_bytes: bytes
|
|
26
|
+
|
|
27
|
+
def serialize_artifact(self) -> bytes:
|
|
28
|
+
"""Serialize model object using joblib."""
|
|
29
|
+
if self.model_artifact_bytes:
|
|
30
|
+
return self.model_artifact_bytes
|
|
31
|
+
bio = io.BytesIO()
|
|
32
|
+
joblib.dump(self.model, bio)
|
|
33
|
+
return bio.getvalue()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BaseModelTrainer(ABC):
|
|
37
|
+
"""Abstract base class for all approved tabular model trainers."""
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def train(
|
|
41
|
+
self,
|
|
42
|
+
X_tr: np.ndarray,
|
|
43
|
+
y_tr: np.ndarray,
|
|
44
|
+
X_val: np.ndarray,
|
|
45
|
+
y_val: np.ndarray,
|
|
46
|
+
task_type: TaskType,
|
|
47
|
+
hyperparameters: dict[str, Any],
|
|
48
|
+
random_seed: int = 42,
|
|
49
|
+
max_cpu_cores: int | None = None,
|
|
50
|
+
) -> TrainingResult:
|
|
51
|
+
"""Execute model training, evaluate metrics, and package artifacts."""
|
|
52
|
+
pass
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""CatBoost model trainer."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import catboost as cb
|
|
7
|
+
import joblib
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ml_mcp.domain.value_objects import TaskType
|
|
11
|
+
from ml_mcp.workers.metrics import MetricsCalculator
|
|
12
|
+
from ml_mcp.workers.trainers.base import BaseModelTrainer, TrainingResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CatBoostTrainer(BaseModelTrainer):
|
|
16
|
+
"""Trainer for CatBoost gradient boosted trees."""
|
|
17
|
+
|
|
18
|
+
def train(
|
|
19
|
+
self,
|
|
20
|
+
X_tr: np.ndarray,
|
|
21
|
+
y_tr: np.ndarray,
|
|
22
|
+
X_val: np.ndarray,
|
|
23
|
+
y_val: np.ndarray,
|
|
24
|
+
task_type: TaskType,
|
|
25
|
+
hyperparameters: dict[str, Any],
|
|
26
|
+
random_seed: int = 42,
|
|
27
|
+
max_cpu_cores: int | None = None,
|
|
28
|
+
) -> TrainingResult:
|
|
29
|
+
thread_count = (
|
|
30
|
+
max_cpu_cores if max_cpu_cores is not None else hyperparameters.get("thread_count", -1)
|
|
31
|
+
)
|
|
32
|
+
params = {
|
|
33
|
+
"random_seed": random_seed,
|
|
34
|
+
"iterations": hyperparameters.get("iterations", 100),
|
|
35
|
+
"depth": hyperparameters.get("depth", 6),
|
|
36
|
+
"learning_rate": hyperparameters.get("learning_rate", 0.1),
|
|
37
|
+
"thread_count": thread_count,
|
|
38
|
+
"verbose": False,
|
|
39
|
+
"allow_writing_files": False,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if task_type in (TaskType.BINARY_CLASSIFICATION, TaskType.MULTICLASS_CLASSIFICATION):
|
|
43
|
+
model = cb.CatBoostClassifier(**params)
|
|
44
|
+
model.fit(X_tr, y_tr, eval_set=(X_val, y_val), verbose=False)
|
|
45
|
+
|
|
46
|
+
train_pred = model.predict(X_tr)
|
|
47
|
+
train_proba = model.predict_proba(X_tr)
|
|
48
|
+
val_pred = model.predict(X_val)
|
|
49
|
+
val_proba = model.predict_proba(X_val)
|
|
50
|
+
|
|
51
|
+
train_metrics = MetricsCalculator.compute_classification_metrics(
|
|
52
|
+
y_tr, train_pred, train_proba, task_type
|
|
53
|
+
)
|
|
54
|
+
val_metrics = MetricsCalculator.compute_classification_metrics(
|
|
55
|
+
y_val, val_pred, val_proba, task_type
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
model = cb.CatBoostRegressor(**params)
|
|
59
|
+
model.fit(X_tr, y_tr, eval_set=(X_val, y_val), verbose=False)
|
|
60
|
+
|
|
61
|
+
train_pred = model.predict(X_tr)
|
|
62
|
+
val_pred = model.predict(X_val)
|
|
63
|
+
val_proba = None
|
|
64
|
+
|
|
65
|
+
train_metrics = MetricsCalculator.compute_regression_metrics(y_tr, train_pred)
|
|
66
|
+
val_metrics = MetricsCalculator.compute_regression_metrics(y_val, val_pred)
|
|
67
|
+
|
|
68
|
+
feat_imp: dict[str, float] = {}
|
|
69
|
+
if hasattr(model, "get_feature_importance"):
|
|
70
|
+
importances = model.get_feature_importance()
|
|
71
|
+
feat_imp = {f"feature_{i}": float(v) for i, v in enumerate(importances)}
|
|
72
|
+
|
|
73
|
+
bio = io.BytesIO()
|
|
74
|
+
joblib.dump(model, bio)
|
|
75
|
+
|
|
76
|
+
return TrainingResult(
|
|
77
|
+
model=model,
|
|
78
|
+
task_type=task_type,
|
|
79
|
+
train_metrics=train_metrics,
|
|
80
|
+
validation_metrics=val_metrics,
|
|
81
|
+
feature_importances=feat_imp,
|
|
82
|
+
val_predictions=val_pred,
|
|
83
|
+
val_probabilities=val_proba,
|
|
84
|
+
model_artifact_bytes=bio.getvalue(),
|
|
85
|
+
)
|