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
ml_mcp/server/http.py ADDED
@@ -0,0 +1,183 @@
1
+ """HTTP transport application for ModelLab FastMCP Server.
2
+
3
+ Exposes Streamable HTTP endpoint (/mcp), health probes (/health/live, /health/ready),
4
+ and enforces OAuth 2.1 / OIDC Bearer token authentication middleware.
5
+ """
6
+
7
+ import logging
8
+
9
+ from starlette.applications import Starlette
10
+ from starlette.requests import Request
11
+ from starlette.responses import JSONResponse
12
+ from starlette.types import ASGIApp, Receive, Scope, Send
13
+
14
+ from ml_mcp.config import get_settings
15
+ from ml_mcp.infrastructure.object_storage.s3 import get_storage_service
16
+ from ml_mcp.infrastructure.postgres.session import get_db_manager
17
+ from ml_mcp.infrastructure.redis.client import get_redis_manager
18
+ from ml_mcp.server.app import create_server
19
+ from ml_mcp.server.auth import get_token_validator
20
+ from ml_mcp.server.context import set_current_principal
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class BearerAuthMiddleware:
26
+ """Pure ASGI middleware enforcing Bearer token authentication on protected MCP endpoints."""
27
+
28
+ def __init__(self, app: ASGIApp) -> None:
29
+ self.app = app
30
+
31
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
32
+ if scope["type"] != "http":
33
+ await self.app(scope, receive, send)
34
+ return
35
+
36
+ path: str = scope.get("path", "")
37
+ # Health probes bypass token authentication
38
+ if path.startswith("/health"):
39
+ await self.app(scope, receive, send)
40
+ return
41
+
42
+ settings = get_settings()
43
+ if not settings.auth.enabled:
44
+ await self.app(scope, receive, send)
45
+ return
46
+
47
+ # Extract Authorization header
48
+ headers = dict(scope.get("headers", []))
49
+ auth_header_bytes = headers.get(b"authorization", b"")
50
+ auth_header = auth_header_bytes.decode("utf-8") if auth_header_bytes else ""
51
+
52
+ if not auth_header or not auth_header.startswith("Bearer "):
53
+ response = JSONResponse(
54
+ {
55
+ "error": "Unauthorized",
56
+ "code": "AUTHENTICATION_REQUIRED",
57
+ "detail": "Missing Bearer token in Authorization header",
58
+ },
59
+ status_code=401,
60
+ )
61
+ await response(scope, receive, send)
62
+ return
63
+
64
+ token = auth_header.split(" ", 1)[1].strip()
65
+ try:
66
+ validator = get_token_validator()
67
+ principal = validator.validate_token(token)
68
+ except Exception as exc:
69
+ logger.warning("Authentication failure: %s", exc)
70
+ response = JSONResponse(
71
+ {
72
+ "error": "Unauthorized",
73
+ "code": "INVALID_TOKEN",
74
+ "detail": str(exc),
75
+ },
76
+ status_code=401,
77
+ )
78
+ await response(scope, receive, send)
79
+ return
80
+
81
+ # Bind authenticated principal to context variable for downstream handlers
82
+ set_current_principal(principal)
83
+ try:
84
+ await self.app(scope, receive, send)
85
+ finally:
86
+ set_current_principal(None)
87
+
88
+
89
+ async def health_live(request: Request) -> JSONResponse:
90
+ """Liveness probe: verifies process is alive and responsive."""
91
+ return JSONResponse({"status": "alive", "service": "modellab-mcp-api"})
92
+
93
+
94
+ async def health_ready(request: Request) -> JSONResponse:
95
+ """Readiness probe: verifies operational connectivity to Postgres, Redis, and Object Store."""
96
+ checks: dict[str, str] = {}
97
+ all_healthy = True
98
+
99
+ # 1. Database Check
100
+ try:
101
+ from sqlalchemy import text
102
+
103
+ async with get_db_manager().session() as sess:
104
+ await sess.execute(text("SELECT 1"))
105
+ checks["database"] = "healthy"
106
+ except Exception as exc:
107
+ checks["database"] = f"unhealthy: {exc}"
108
+ all_healthy = False
109
+
110
+ # 2. Redis Check
111
+ try:
112
+ redis_mgr = get_redis_manager()
113
+ healthy = await redis_mgr.check_health()
114
+ checks["redis"] = "healthy" if healthy else "degraded"
115
+ except Exception as exc:
116
+ checks["redis"] = f"degraded: {exc}"
117
+ # Redis failure can fall back to in-memory, but flag status
118
+
119
+ # 3. Object Store Check
120
+ try:
121
+ storage = get_storage_service()
122
+ # Verify storage client initialized
123
+ if storage.client is not None:
124
+ checks["object_store"] = "healthy"
125
+ else:
126
+ checks["object_store"] = "uninitialized"
127
+ all_healthy = False
128
+ except Exception as exc:
129
+ checks["object_store"] = f"unhealthy: {exc}"
130
+ all_healthy = False
131
+
132
+ status_code = 200 if all_healthy else 503
133
+ return JSONResponse(
134
+ {
135
+ "status": "ready" if all_healthy else "degraded",
136
+ "checks": checks,
137
+ },
138
+ status_code=status_code,
139
+ )
140
+
141
+
142
+ def create_http_app() -> Starlette:
143
+ """Create configured Starlette application wrapping FastMCP with auth and operational endpoints."""
144
+ settings = get_settings()
145
+ server = create_server()
146
+
147
+ # FastMCP streamable HTTP app mounted at configured path (default: /mcp)
148
+ app = server.http_app(
149
+ path=settings.mcp.streamable_http_path,
150
+ transport="streamable-http",
151
+ )
152
+
153
+ # Register health probes
154
+ app.add_route("/health/live", health_live, methods=["GET"])
155
+ app.add_route("/health/ready", health_ready, methods=["GET"])
156
+
157
+ # Register BearerAuthMiddleware
158
+ app.add_middleware(BearerAuthMiddleware)
159
+
160
+ return app
161
+
162
+
163
+ def run_http() -> None:
164
+ """CLI entrypoint for running the ModelLab HTTP server using uvicorn."""
165
+ import uvicorn
166
+
167
+ from ml_mcp.infrastructure.telemetry.logging import configure_logging
168
+
169
+ configure_logging()
170
+ settings = get_settings()
171
+
172
+ uvicorn.run(
173
+ "ml_mcp.server.http:create_http_app",
174
+ factory=True,
175
+ host=settings.mcp.host,
176
+ port=settings.mcp.port,
177
+ log_config=None, # Use structured stderr logger
178
+ access_log=False,
179
+ )
180
+
181
+
182
+ if __name__ == "__main__":
183
+ run_http()
@@ -0,0 +1,121 @@
1
+ """Security, authorization, and rate-limiting middleware pipeline."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from ml_mcp.config import get_settings
7
+ from ml_mcp.domain.errors import ResourceLimitExceededError
8
+ from ml_mcp.domain.policies import (
9
+ TOOL_POLICIES,
10
+ Principal,
11
+ Role,
12
+ )
13
+ from ml_mcp.infrastructure.postgres.models import AuditEventOrm
14
+ from ml_mcp.infrastructure.postgres.repositories.audit import AuditRepository
15
+ from ml_mcp.infrastructure.postgres.session import get_db_manager
16
+ from ml_mcp.infrastructure.redis.rate_limiter import SlidingWindowRateLimiter
17
+ from ml_mcp.infrastructure.telemetry.logging import (
18
+ clear_request_context,
19
+ set_request_context,
20
+ )
21
+ from ml_mcp.infrastructure.telemetry.otel import get_instruments
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ # Default local principal for STDIO operations (trusted local process boundary)
27
+ LOCAL_STDIO_PRINCIPAL = Principal(
28
+ principal_id="local-user",
29
+ tenant_id="default-tenant",
30
+ role=Role.ADMIN,
31
+ )
32
+
33
+
34
+ class SecurityPipeline:
35
+ """Orchestrates authentication, scope checks, rate limiting, and audit logging for tool executions."""
36
+
37
+ def __init__(self, rate_limiter: SlidingWindowRateLimiter | None = None) -> None:
38
+ self.rate_limiter = rate_limiter or SlidingWindowRateLimiter()
39
+
40
+ async def pre_tool_call(
41
+ self,
42
+ tool_name: str,
43
+ principal: Principal,
44
+ request_id: str | None = None,
45
+ ) -> None:
46
+ """Enforce rate limits, scopes, and set request context."""
47
+ policy = TOOL_POLICIES.get(tool_name)
48
+ if not policy:
49
+ return
50
+
51
+ # 1. Telemetry context
52
+ set_request_context(
53
+ request_id=request_id,
54
+ tenant_id=principal.tenant_id,
55
+ principal_id=principal.principal_id,
56
+ )
57
+
58
+ # 2. Scope enforcement
59
+ principal.enforce_permission(policy.required_scope)
60
+
61
+ # 3. Rate limiting enforcement
62
+ settings = get_settings()
63
+ limits_map = {
64
+ "read": settings.rate_limit.read_per_minute,
65
+ "metadata": settings.rate_limit.metadata_per_minute,
66
+ "experiment": settings.rate_limit.experiment_per_minute,
67
+ "analysis": settings.rate_limit.analysis_per_minute,
68
+ }
69
+ limit = limits_map.get(policy.rate_limit_category, 60)
70
+ res = await self.rate_limiter.check_rate_limit(
71
+ identifier=f"{principal.tenant_id}:{principal.principal_id}",
72
+ category=policy.rate_limit_category,
73
+ limit=limit,
74
+ )
75
+ if not res.allowed:
76
+ get_instruments().rate_limit_denials_total.add(1)
77
+ raise ResourceLimitExceededError(
78
+ f"Rate limit exceeded for tool category '{policy.rate_limit_category}'",
79
+ limit=limit,
80
+ current=limit + 1,
81
+ )
82
+
83
+ async def post_tool_call(
84
+ self,
85
+ tool_name: str,
86
+ principal: Principal,
87
+ result: Any,
88
+ success: bool = True,
89
+ error_code: str | None = None,
90
+ ) -> None:
91
+ """Emit audit log entry if required by policy and clear telemetry context."""
92
+ policy = TOOL_POLICIES.get(tool_name)
93
+ if policy and policy.requires_audit:
94
+ try:
95
+ async with get_db_manager().session() as sess:
96
+ audit_repo = AuditRepository(sess)
97
+ await audit_repo.record_event(
98
+ AuditEventOrm(
99
+ principal_id=principal.principal_id,
100
+ tenant_id=principal.tenant_id,
101
+ action=f"TOOL_{tool_name.upper()}",
102
+ resource_type="tool",
103
+ resource_id=tool_name,
104
+ result="SUCCESS" if success else "ERROR",
105
+ reason_code=error_code,
106
+ )
107
+ )
108
+ except Exception as exc:
109
+ logger.warning("Failed to record audit event for %s: %s", tool_name, exc)
110
+
111
+ clear_request_context()
112
+
113
+
114
+ _pipeline: SecurityPipeline | None = None
115
+
116
+
117
+ def get_security_pipeline() -> SecurityPipeline:
118
+ global _pipeline
119
+ if _pipeline is None:
120
+ _pipeline = SecurityPipeline()
121
+ return _pipeline
ml_mcp/server/stdio.py ADDED
@@ -0,0 +1,42 @@
1
+ """STDIO transport runner for ModelLab FastMCP Server.
2
+
3
+ Adheres strictly to standard stream discipline:
4
+ - stdout: Exclusively reserved for valid MCP JSON-RPC protocol frames.
5
+ - stderr: All structured JSON logs, diagnostic output, and trace messages.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import sys
11
+
12
+ from ml_mcp.infrastructure.telemetry.logging import configure_logging
13
+ from ml_mcp.server.app import create_server
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ async def run_stdio_server() -> None:
19
+ """Initialize server components and launch FastMCP STDIO loop."""
20
+ # Strict stream discipline: logs go to stderr ONLY
21
+ configure_logging()
22
+ logger.info("Initializing ModelLab FastMCP server on STDIO transport...")
23
+
24
+ server = create_server("ModelLab-STDIO")
25
+
26
+ # Run FastMCP stdio server
27
+ await server.run_stdio_async()
28
+
29
+
30
+ def run_stdio() -> None:
31
+ """Synchronous entrypoint for STDIO launcher."""
32
+ try:
33
+ asyncio.run(run_stdio_server())
34
+ except (KeyboardInterrupt, SystemExit):
35
+ sys.stderr.write("ModelLab STDIO server terminated gracefully.\n")
36
+ except Exception as exc:
37
+ sys.stderr.write(f"Fatal error in ModelLab STDIO server: {exc}\n")
38
+ sys.exit(1)
39
+
40
+
41
+ if __name__ == "__main__":
42
+ run_stdio()
@@ -0,0 +1 @@
1
+ """Isolated ML worker package."""
@@ -0,0 +1,130 @@
1
+ """Worker daemon dequeuing and executing ML experiment tasks from Redis."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import signal
6
+ import sys
7
+ from typing import Any
8
+
9
+ from ml_mcp.infrastructure.object_storage.s3 import S3StorageService, get_storage_service
10
+ from ml_mcp.infrastructure.postgres.session import DatabaseManager, get_db_manager
11
+ from ml_mcp.infrastructure.queue.task_queue import RedisTaskQueue
12
+ from ml_mcp.infrastructure.telemetry.logging import configure_logging
13
+ from ml_mcp.workers.runner import WorkerRunner
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class WorkerDaemon:
19
+ """Asynchronous background worker daemon executing experiment tasks from Redis."""
20
+
21
+ def __init__(
22
+ self,
23
+ task_queue: RedisTaskQueue | None = None,
24
+ storage_service: S3StorageService | None = None,
25
+ db_manager: DatabaseManager | None = None,
26
+ drain_outbox: bool = False,
27
+ ) -> None:
28
+ self.queue = task_queue or RedisTaskQueue()
29
+ self.storage = storage_service or get_storage_service()
30
+ self.db_mgr = db_manager or get_db_manager()
31
+ self.drain_outbox = drain_outbox
32
+ self._stop_event = asyncio.Event()
33
+ self._running = False
34
+
35
+ def stop(self, *args: Any) -> None:
36
+ """Signal the daemon loop to terminate gracefully."""
37
+ logger.info("Shutdown signal received; initiating graceful termination of worker daemon...")
38
+ self._stop_event.set()
39
+
40
+ async def run(self) -> None:
41
+ """Run continuous dequeue-execute loop until stopped."""
42
+ self._running = True
43
+ logger.info("Worker daemon started, polling for experiment tasks...")
44
+
45
+ while not self._stop_event.is_set():
46
+ try:
47
+ # 1. Drain pending outbox events into Redis queue if enabled
48
+ if self.drain_outbox:
49
+ try:
50
+ async with self.db_mgr.session() as session:
51
+ from ml_mcp.infrastructure.queue.outbox_processor import OutboxProcessor
52
+
53
+ processor = OutboxProcessor(session, self.queue)
54
+ await processor.process_batch(limit=20)
55
+ except Exception as outbox_exc:
56
+ logger.debug("Outbox drain cycle check: %s", outbox_exc)
57
+
58
+ # 2. Dequeue experiment task
59
+ task_message = await self.queue.dequeue(timeout_seconds=2)
60
+ if task_message is None:
61
+ continue
62
+
63
+ logger.info(
64
+ "Dequeued task %s for experiment %s (tenant %s)",
65
+ task_message.task_id,
66
+ task_message.experiment_id,
67
+ task_message.tenant_id,
68
+ )
69
+
70
+ async with self.db_mgr.session() as session:
71
+ runner = WorkerRunner(session=session, storage_service=self.storage)
72
+ try:
73
+ result = await runner.execute_experiment(
74
+ experiment_id=task_message.experiment_id,
75
+ tenant_id=task_message.tenant_id,
76
+ )
77
+ logger.info(
78
+ "Task %s succeeded for experiment %s: %s",
79
+ task_message.task_id,
80
+ task_message.experiment_id,
81
+ result.get("status"),
82
+ )
83
+ except Exception as exc:
84
+ logger.error(
85
+ "Task %s failed for experiment %s: %s",
86
+ task_message.task_id,
87
+ task_message.experiment_id,
88
+ exc,
89
+ )
90
+
91
+ except asyncio.CancelledError:
92
+ logger.info("Worker daemon cancelled")
93
+ break
94
+ except Exception as exc:
95
+ logger.error("Unexpected error in worker daemon polling loop: %s", exc)
96
+ # Brief sleep on unexpected error to prevent rapid spinning
97
+ await asyncio.sleep(1)
98
+
99
+ self._running = False
100
+ logger.info("Worker daemon stopped cleanly.")
101
+
102
+
103
+ def _register_signals(daemon: WorkerDaemon) -> None:
104
+ """Attach graceful shutdown handlers for SIGINT and SIGTERM."""
105
+ loop = asyncio.get_running_loop()
106
+ signals = [signal.SIGINT]
107
+ if hasattr(signal, "SIGTERM"):
108
+ signals.append(signal.SIGTERM)
109
+
110
+ for sig in signals:
111
+ try:
112
+ loop.add_signal_handler(sig, daemon.stop)
113
+ except (NotImplementedError, AttributeError):
114
+ # Fallback for platforms without loop.add_signal_handler (e.g. Windows)
115
+ signal.signal(sig, lambda s, f: daemon.stop())
116
+
117
+
118
+ async def main() -> None:
119
+ """Daemon CLI entrypoint."""
120
+ configure_logging()
121
+ daemon = WorkerDaemon()
122
+ _register_signals(daemon)
123
+ await daemon.run()
124
+
125
+
126
+ if __name__ == "__main__":
127
+ try:
128
+ asyncio.run(main())
129
+ except (KeyboardInterrupt, SystemExit):
130
+ sys.exit(0)
@@ -0,0 +1,110 @@
1
+ """Comprehensive evaluation metrics computation engine for classification and regression."""
2
+
3
+ import contextlib
4
+
5
+ import numpy as np
6
+ from sklearn.metrics import (
7
+ accuracy_score,
8
+ average_precision_score,
9
+ brier_score_loss,
10
+ confusion_matrix,
11
+ explained_variance_score,
12
+ f1_score,
13
+ log_loss,
14
+ mean_absolute_error,
15
+ mean_absolute_percentage_error,
16
+ mean_squared_error,
17
+ precision_score,
18
+ r2_score,
19
+ recall_score,
20
+ roc_auc_score,
21
+ )
22
+
23
+ from ml_mcp.domain.value_objects import TaskType
24
+
25
+
26
+ class MetricsCalculator:
27
+ """Calculates all standard ML evaluation metrics without placeholders or approximations."""
28
+
29
+ @staticmethod
30
+ def compute_classification_metrics(
31
+ y_true: np.ndarray,
32
+ y_pred: np.ndarray,
33
+ y_proba: np.ndarray | None = None,
34
+ task_type: TaskType = TaskType.BINARY_CLASSIFICATION,
35
+ ) -> dict[str, float | list[list[int]]]:
36
+ """Compute full classification metric suite."""
37
+ metrics: dict[str, float | list[list[int]]] = {}
38
+
39
+ metrics["accuracy"] = float(accuracy_score(y_true, y_pred))
40
+ metrics["f1"] = float(
41
+ f1_score(
42
+ y_true,
43
+ y_pred,
44
+ average="weighted" if task_type == TaskType.MULTICLASS_CLASSIFICATION else "binary",
45
+ zero_division=0,
46
+ )
47
+ )
48
+ metrics["f1_macro"] = float(f1_score(y_true, y_pred, average="macro", zero_division=0))
49
+ metrics["precision"] = float(
50
+ precision_score(
51
+ y_true,
52
+ y_pred,
53
+ average="weighted" if task_type == TaskType.MULTICLASS_CLASSIFICATION else "binary",
54
+ zero_division=0,
55
+ )
56
+ )
57
+ metrics["recall"] = float(
58
+ recall_score(
59
+ y_true,
60
+ y_pred,
61
+ average="weighted" if task_type == TaskType.MULTICLASS_CLASSIFICATION else "binary",
62
+ zero_division=0,
63
+ )
64
+ )
65
+
66
+ # Confusion matrix
67
+ cm = confusion_matrix(y_true, y_pred)
68
+ metrics["confusion_matrix"] = cm.tolist()
69
+
70
+ # Probability-based metrics
71
+ if y_proba is not None:
72
+ with contextlib.suppress(Exception):
73
+ if task_type == TaskType.BINARY_CLASSIFICATION:
74
+ # y_proba can be 1D or 2D
75
+ proba_pos = (
76
+ y_proba[:, 1] if y_proba.ndim == 2 and y_proba.shape[1] > 1 else y_proba
77
+ )
78
+ metrics["roc_auc"] = float(roc_auc_score(y_true, proba_pos))
79
+ metrics["pr_auc"] = float(average_precision_score(y_true, proba_pos))
80
+ metrics["brier_score"] = float(brier_score_loss(y_true, proba_pos))
81
+ metrics["log_loss"] = float(log_loss(y_true, y_proba))
82
+ elif task_type == TaskType.MULTICLASS_CLASSIFICATION:
83
+ metrics["roc_auc"] = float(roc_auc_score(y_true, y_proba, multi_class="ovr"))
84
+ metrics["log_loss"] = float(log_loss(y_true, y_proba))
85
+
86
+ return metrics
87
+
88
+ @staticmethod
89
+ def compute_regression_metrics(
90
+ y_true: np.ndarray,
91
+ y_pred: np.ndarray,
92
+ ) -> dict[str, float]:
93
+ """Compute full regression metric suite."""
94
+ mse = float(mean_squared_error(y_true, y_pred))
95
+ rmse = float(np.sqrt(mse))
96
+ mae = float(mean_absolute_error(y_true, y_pred))
97
+ r2 = float(r2_score(y_true, y_pred))
98
+
99
+ metrics: dict[str, float] = {
100
+ "mse": mse,
101
+ "rmse": rmse,
102
+ "mae": mae,
103
+ "r2": r2,
104
+ "explained_variance": float(explained_variance_score(y_true, y_pred)),
105
+ }
106
+
107
+ with contextlib.suppress(Exception):
108
+ metrics["mape"] = float(mean_absolute_percentage_error(y_true, y_pred))
109
+
110
+ return metrics