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.
Files changed (92) hide show
  1. ml_mcp/__init__.py +3 -0
  2. ml_mcp/application/__init__.py +1 -0
  3. ml_mcp/application/analysis/__init__.py +15 -0
  4. ml_mcp/application/analysis/diagnostics.py +122 -0
  5. ml_mcp/application/analysis/service.py +155 -0
  6. ml_mcp/application/artifacts/__init__.py +5 -0
  7. ml_mcp/application/artifacts/service.py +149 -0
  8. ml_mcp/application/datasets/__init__.py +6 -0
  9. ml_mcp/application/datasets/service.py +178 -0
  10. ml_mcp/application/datasets/validator.py +156 -0
  11. ml_mcp/application/experiments/__init__.py +5 -0
  12. ml_mcp/application/experiments/service.py +254 -0
  13. ml_mcp/application/models/__init__.py +6 -0
  14. ml_mcp/application/models/catalog_seed.py +131 -0
  15. ml_mcp/application/models/service.py +177 -0
  16. ml_mcp/application/projects/__init__.py +5 -0
  17. ml_mcp/application/projects/service.py +93 -0
  18. ml_mcp/config/__init__.py +5 -0
  19. ml_mcp/config/settings.py +176 -0
  20. ml_mcp/domain/__init__.py +1 -0
  21. ml_mcp/domain/errors/__init__.py +39 -0
  22. ml_mcp/domain/errors/base.py +155 -0
  23. ml_mcp/domain/errors/codes.py +23 -0
  24. ml_mcp/domain/policies/__init__.py +16 -0
  25. ml_mcp/domain/policies/rbac.py +126 -0
  26. ml_mcp/domain/policies/tenant_isolation.py +34 -0
  27. ml_mcp/domain/policies/tool_policy.py +185 -0
  28. ml_mcp/domain/value_objects/__init__.py +41 -0
  29. ml_mcp/domain/value_objects/dataset.py +60 -0
  30. ml_mcp/domain/value_objects/experiment.py +131 -0
  31. ml_mcp/domain/value_objects/model.py +48 -0
  32. ml_mcp/infrastructure/__init__.py +1 -0
  33. ml_mcp/infrastructure/object_storage/__init__.py +5 -0
  34. ml_mcp/infrastructure/object_storage/s3.py +272 -0
  35. ml_mcp/infrastructure/postgres/__init__.py +14 -0
  36. ml_mcp/infrastructure/postgres/base.py +40 -0
  37. ml_mcp/infrastructure/postgres/models.py +356 -0
  38. ml_mcp/infrastructure/postgres/repositories/__init__.py +21 -0
  39. ml_mcp/infrastructure/postgres/repositories/artifacts.py +34 -0
  40. ml_mcp/infrastructure/postgres/repositories/audit.py +34 -0
  41. ml_mcp/infrastructure/postgres/repositories/datasets.py +85 -0
  42. ml_mcp/infrastructure/postgres/repositories/experiments.py +134 -0
  43. ml_mcp/infrastructure/postgres/repositories/metrics.py +61 -0
  44. ml_mcp/infrastructure/postgres/repositories/models.py +61 -0
  45. ml_mcp/infrastructure/postgres/repositories/outbox.py +47 -0
  46. ml_mcp/infrastructure/postgres/repositories/projects.py +83 -0
  47. ml_mcp/infrastructure/postgres/session.py +117 -0
  48. ml_mcp/infrastructure/queue/__init__.py +6 -0
  49. ml_mcp/infrastructure/queue/outbox_processor.py +67 -0
  50. ml_mcp/infrastructure/queue/task_queue.py +98 -0
  51. ml_mcp/infrastructure/redis/__init__.py +11 -0
  52. ml_mcp/infrastructure/redis/client.py +64 -0
  53. ml_mcp/infrastructure/redis/rate_limiter.py +129 -0
  54. ml_mcp/infrastructure/telemetry/__init__.py +23 -0
  55. ml_mcp/infrastructure/telemetry/logging.py +155 -0
  56. ml_mcp/infrastructure/telemetry/otel.py +141 -0
  57. ml_mcp/mcp/__init__.py +1 -0
  58. ml_mcp/mcp/prompts.py +38 -0
  59. ml_mcp/mcp/resources.py +35 -0
  60. ml_mcp/mcp/schemas/__init__.py +1 -0
  61. ml_mcp/mcp/schemas/tools.py +124 -0
  62. ml_mcp/mcp/tools/__init__.py +63 -0
  63. ml_mcp/mcp/tools/analysis.py +37 -0
  64. ml_mcp/mcp/tools/datasets.py +82 -0
  65. ml_mcp/mcp/tools/discovery.py +82 -0
  66. ml_mcp/mcp/tools/experiments.py +96 -0
  67. ml_mcp/mcp/tools/projects.py +39 -0
  68. ml_mcp/mcp/tools/results.py +59 -0
  69. ml_mcp/server/__init__.py +1 -0
  70. ml_mcp/server/app.py +488 -0
  71. ml_mcp/server/auth.py +146 -0
  72. ml_mcp/server/context.py +59 -0
  73. ml_mcp/server/http.py +183 -0
  74. ml_mcp/server/middleware.py +121 -0
  75. ml_mcp/server/stdio.py +42 -0
  76. ml_mcp/workers/__init__.py +1 -0
  77. ml_mcp/workers/daemon.py +130 -0
  78. ml_mcp/workers/metrics.py +110 -0
  79. ml_mcp/workers/preprocessing.py +170 -0
  80. ml_mcp/workers/runner.py +250 -0
  81. ml_mcp/workers/sandbox.py +35 -0
  82. ml_mcp/workers/trainers/__init__.py +52 -0
  83. ml_mcp/workers/trainers/base.py +52 -0
  84. ml_mcp/workers/trainers/catboost_model.py +85 -0
  85. ml_mcp/workers/trainers/lightgbm_model.py +81 -0
  86. ml_mcp/workers/trainers/sklearn_models.py +266 -0
  87. ml_mcp/workers/trainers/xgboost_model.py +82 -0
  88. quicklab_mcp-0.1.0.dist-info/METADATA +540 -0
  89. quicklab_mcp-0.1.0.dist-info/RECORD +92 -0
  90. quicklab_mcp-0.1.0.dist-info/WHEEL +5 -0
  91. quicklab_mcp-0.1.0.dist-info/entry_points.txt +4 -0
  92. 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
@@ -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
+ )