Adisri99 commited on
Commit
3bf1d32
·
verified ·
1 Parent(s): 10ceaa1

Upload 117 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +37 -10
  2. backend/.DS_Store +0 -0
  3. backend/app/__init__.py +0 -0
  4. backend/app/api/__init__.py +0 -0
  5. backend/app/api/clusters.py +40 -0
  6. backend/app/api/compare.py +19 -0
  7. backend/app/api/datasets.py +55 -0
  8. backend/app/api/experiments.py +105 -0
  9. backend/app/api/exports.py +24 -0
  10. backend/app/api/health.py +7 -0
  11. backend/app/api/presets.py +17 -0
  12. backend/app/config.py +23 -0
  13. backend/app/db.py +36 -0
  14. backend/app/logging_config.py +7 -0
  15. backend/app/main.py +31 -0
  16. backend/app/ml/__init__.py +0 -0
  17. backend/app/ml/algorithms/__init__.py +0 -0
  18. backend/app/ml/algorithms/agglomerative_runner.py +14 -0
  19. backend/app/ml/algorithms/base.py +17 -0
  20. backend/app/ml/algorithms/birch_runner.py +15 -0
  21. backend/app/ml/algorithms/dbscan_runner.py +17 -0
  22. backend/app/ml/algorithms/gmm_runner.py +15 -0
  23. backend/app/ml/algorithms/hdbscan_runner.py +20 -0
  24. backend/app/ml/algorithms/kmeans_runner.py +17 -0
  25. backend/app/ml/algorithms/minibatch_kmeans_runner.py +17 -0
  26. backend/app/ml/algorithms/spectral_runner.py +17 -0
  27. backend/app/ml/explain/__init__.py +0 -0
  28. backend/app/ml/explain/cluster_profiles.py +22 -0
  29. backend/app/ml/explain/feature_lift.py +24 -0
  30. backend/app/ml/explain/summary_writer.py +10 -0
  31. backend/app/ml/metrics/__init__.py +0 -0
  32. backend/app/ml/metrics/clustering_metrics.py +33 -0
  33. backend/app/ml/metrics/interpretability.py +19 -0
  34. backend/app/ml/metrics/stability.py +10 -0
  35. backend/app/ml/outliers/__init__.py +0 -0
  36. backend/app/ml/outliers/centroid_distance.py +18 -0
  37. backend/app/ml/outliers/density_noise.py +2 -0
  38. backend/app/ml/outliers/isolation_forest.py +6 -0
  39. backend/app/ml/preprocess/__init__.py +0 -0
  40. backend/app/ml/preprocess/column_inference.py +33 -0
  41. backend/app/ml/preprocess/encoders.py +7 -0
  42. backend/app/ml/preprocess/imputers.py +7 -0
  43. backend/app/ml/preprocess/matrix_builder.py +32 -0
  44. backend/app/ml/preprocess/reducers.py +16 -0
  45. backend/app/ml/preprocess/scalers.py +9 -0
  46. backend/app/models/__init__.py +0 -0
  47. backend/app/models/artifact.py +12 -0
  48. backend/app/models/cluster_result.py +36 -0
  49. backend/app/models/dataset.py +16 -0
  50. backend/app/models/experiment.py +17 -0
README.md CHANGED
@@ -1,10 +1,37 @@
1
- ---
2
- title: ClusterBuster API
3
- emoji: 🔥
4
- colorFrom: green
5
- colorTo: red
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ClusterForge
2
+
3
+ Full stack clustering experimentation platform.
4
+
5
+ Stack:
6
+ - FastAPI backend
7
+ - Next.js frontend
8
+ - SQLite database
9
+ - scikit-learn clustering pipeline
10
+ - Hugging Face Docker Space deployment files
11
+
12
+ Core features:
13
+ - CSV and Excel upload
14
+ - Dataset profiling
15
+ - Algorithm selection
16
+ - Dynamic parameters
17
+ - Run experiments
18
+ - Result visualizations
19
+ - Cluster summaries
20
+ - Outlier detection
21
+ - Run comparison
22
+ - CSV export
23
+
24
+ Algorithms:
25
+ - KMeans
26
+ - MiniBatchKMeans
27
+ - Agglomerative
28
+ - DBSCAN
29
+ - HDBSCAN
30
+ - Gaussian Mixture
31
+ - Spectral
32
+ - Birch
33
+
34
+ Runtime paths for Hugging Face:
35
+ - DB: /data/clusterforge.db
36
+ - Uploads: /data/uploads
37
+ - Artifacts: /data/artifacts
backend/.DS_Store ADDED
Binary file (6.15 kB). View file
 
backend/app/__init__.py ADDED
File without changes
backend/app/api/__init__.py ADDED
File without changes
backend/app/api/clusters.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import pandas as pd
3
+ from fastapi import APIRouter, HTTPException
4
+ from app.db import get_session
5
+ from app.repositories.cluster_repo import ClusterRepository
6
+ from app.repositories.dataset_repo import DatasetRepository
7
+ from app.repositories.experiment_repo import ExperimentRepository
8
+ from app.schemas.cluster import ClusterDetailResponse, ClusterMember
9
+
10
+ router = APIRouter(prefix='/clusters', tags=['clusters'])
11
+
12
+ @router.get('/{experiment_id}/{cluster_label}', response_model=ClusterDetailResponse)
13
+ def get_cluster_detail(experiment_id: str, cluster_label: int):
14
+ with get_session() as session:
15
+ exp_repo = ExperimentRepository(session)
16
+ dataset_repo = DatasetRepository(session)
17
+ cluster_repo = ClusterRepository(session)
18
+ exp = exp_repo.get(experiment_id)
19
+ if not exp:
20
+ raise HTTPException(status_code=404, detail='Experiment not found')
21
+ dataset = dataset_repo.get(exp.dataset_id)
22
+ if not dataset:
23
+ raise HTTPException(status_code=404, detail='Dataset not found')
24
+ df = pd.read_csv(dataset.file_path) if dataset.file_path.lower().endswith('.csv') else pd.read_excel(dataset.file_path)
25
+ results = [r for r in cluster_repo.get_results(experiment_id) if r.cluster_label == cluster_label]
26
+ summary = cluster_repo.get_summary(experiment_id, cluster_label)
27
+ if not summary:
28
+ raise HTTPException(status_code=404, detail='Cluster summary not found')
29
+ members = []
30
+ for row in results[:200]:
31
+ values = df.iloc[row.row_index].to_dict()
32
+ values = {str(k): (None if pd.isna(v) else v) for k, v in values.items()}
33
+ members.append(ClusterMember(row_index=row.row_index, values=values))
34
+ return ClusterDetailResponse(
35
+ experiment_id=experiment_id,
36
+ cluster_label=cluster_label,
37
+ members=members,
38
+ summary=json.loads(summary.summary_json),
39
+ description_text=summary.description_text,
40
+ )
backend/app/api/compare.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Query
2
+ from app.db import get_session
3
+ from app.repositories.cluster_repo import ClusterRepository
4
+ from app.repositories.experiment_repo import ExperimentRepository
5
+ from app.schemas.compare import CompareResponse
6
+ from app.services.comparison_service import ComparisonService
7
+
8
+ router = APIRouter(prefix='/compare', tags=['compare'])
9
+
10
+ @router.get('', response_model=CompareResponse)
11
+ def compare_runs(experiment_ids: str = Query(...)):
12
+ ids = [item.strip() for item in experiment_ids.split(',') if item.strip()]
13
+ with get_session() as session:
14
+ payload = ComparisonService().build_compare_payload(
15
+ ExperimentRepository(session),
16
+ ClusterRepository(session),
17
+ ids,
18
+ )
19
+ return CompareResponse(**payload)
backend/app/api/datasets.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, File, HTTPException, UploadFile
2
+ from app.db import get_session
3
+ from app.repositories.dataset_repo import DatasetRepository
4
+ from app.schemas.dataset import DatasetProfileResponse, DatasetUploadResponse
5
+ from app.services.dataset_service import DatasetService
6
+ from app.services.profiling_service import ProfilingService
7
+
8
+ router = APIRouter(prefix='/datasets', tags=['datasets'])
9
+
10
+ @router.post('/upload', response_model=DatasetUploadResponse)
11
+ async def upload_dataset(file: UploadFile = File(...)):
12
+ with get_session() as session:
13
+ repo = DatasetRepository(session)
14
+ service = DatasetService(repo)
15
+ dataset = await service.save_upload(file)
16
+ return DatasetUploadResponse(
17
+ dataset_id=dataset.id,
18
+ name=dataset.name,
19
+ row_count=dataset.row_count,
20
+ column_count=dataset.column_count,
21
+ )
22
+
23
+ @router.get('', response_model=list[DatasetUploadResponse])
24
+ def list_datasets():
25
+ with get_session() as session:
26
+ repo = DatasetRepository(session)
27
+ items = repo.list_all()
28
+ return [
29
+ DatasetUploadResponse(
30
+ dataset_id=item.id,
31
+ name=item.name,
32
+ row_count=item.row_count,
33
+ column_count=item.column_count,
34
+ )
35
+ for item in items
36
+ ]
37
+
38
+ @router.get('/{dataset_id}/profile', response_model=DatasetProfileResponse)
39
+ def dataset_profile(dataset_id: str):
40
+ with get_session() as session:
41
+ repo = DatasetRepository(session)
42
+ dataset = repo.get(dataset_id)
43
+ if not dataset:
44
+ raise HTTPException(status_code=404, detail='Dataset not found')
45
+ df = DatasetService(repo).load_dataframe(dataset)
46
+ profile = ProfilingService().profile(df)
47
+ repo.update_profile(dataset_id, schema_json={'columns': [c['name'] for c in profile['columns']]}, profile_json=profile)
48
+ return DatasetProfileResponse(
49
+ dataset_id=dataset_id,
50
+ columns=profile['columns'],
51
+ suggested_feature_columns=profile['suggested_feature_columns'],
52
+ recommended_algorithms=profile['recommended_algorithms'],
53
+ row_count=profile['row_count'],
54
+ column_count=profile['column_count'],
55
+ )
backend/app/api/experiments.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from fastapi import APIRouter, HTTPException
3
+ from app.db import get_session
4
+ from app.repositories.cluster_repo import ClusterRepository
5
+ from app.repositories.dataset_repo import DatasetRepository
6
+ from app.repositories.experiment_repo import ExperimentRepository
7
+ from app.schemas.experiment import ClusterSummaryResponse, ExperimentListItem, ExperimentResultResponse, ExperimentRunRequest, ExperimentRunResponse, MetricResponse, ScatterPoint
8
+ from app.services.run_service import RunService
9
+
10
+ router = APIRouter(prefix='/experiments', tags=['experiments'])
11
+
12
+ @router.post('/run', response_model=ExperimentRunResponse)
13
+ def run_experiment(payload: ExperimentRunRequest):
14
+ with get_session() as session:
15
+ dataset_repo = DatasetRepository(session)
16
+ exp_repo = ExperimentRepository(session)
17
+ cluster_repo = ClusterRepository(session)
18
+ service = RunService(dataset_repo, exp_repo, cluster_repo)
19
+ experiment = service.run(payload.model_dump())
20
+ return ExperimentRunResponse(experiment_id=experiment.id, status='completed')
21
+
22
+ @router.get('', response_model=list[ExperimentListItem])
23
+ def list_experiments():
24
+ with get_session() as session:
25
+ repo = ExperimentRepository(session)
26
+ items = repo.list_all()
27
+ return [
28
+ ExperimentListItem(
29
+ experiment_id=item.id,
30
+ dataset_id=item.dataset_id,
31
+ name=item.name,
32
+ algorithm=item.algorithm,
33
+ status=item.status,
34
+ started_at=item.started_at.isoformat(),
35
+ completed_at=item.completed_at.isoformat() if item.completed_at else None,
36
+ )
37
+ for item in items
38
+ ]
39
+
40
+ @router.get('/{experiment_id}')
41
+ def get_experiment(experiment_id: str):
42
+ with get_session() as session:
43
+ repo = ExperimentRepository(session)
44
+ exp = repo.get(experiment_id)
45
+ if not exp:
46
+ raise HTTPException(status_code=404, detail='Experiment not found')
47
+ return {
48
+ 'experiment_id': exp.id,
49
+ 'dataset_id': exp.dataset_id,
50
+ 'name': exp.name,
51
+ 'algorithm': exp.algorithm,
52
+ 'status': exp.status,
53
+ 'config': json.loads(exp.config_json),
54
+ 'runtime_ms': exp.runtime_ms,
55
+ 'error_message': exp.error_message,
56
+ }
57
+
58
+ @router.get('/{experiment_id}/results', response_model=ExperimentResultResponse)
59
+ def get_results(experiment_id: str):
60
+ with get_session() as session:
61
+ exp_repo = ExperimentRepository(session)
62
+ cluster_repo = ClusterRepository(session)
63
+ exp = exp_repo.get(experiment_id)
64
+ if not exp:
65
+ raise HTTPException(status_code=404, detail='Experiment not found')
66
+ metric = cluster_repo.get_metric(experiment_id)
67
+ summaries = cluster_repo.get_summaries(experiment_id)
68
+ results = cluster_repo.get_results(experiment_id)
69
+ if not metric:
70
+ raise HTTPException(status_code=404, detail='Results not found')
71
+ scatter = [
72
+ ScatterPoint(
73
+ row_index=item.row_index,
74
+ cluster_label=item.cluster_label,
75
+ is_outlier=item.is_outlier,
76
+ x=item.umap_x if item.umap_x is not None else item.pca_x,
77
+ y=item.umap_y if item.umap_y is not None else item.pca_y,
78
+ )
79
+ for item in results
80
+ ]
81
+ summary_items = [
82
+ ClusterSummaryResponse(
83
+ cluster_label=item.cluster_label,
84
+ size=item.size,
85
+ description_text=item.description_text,
86
+ summary_json=json.loads(item.summary_json),
87
+ )
88
+ for item in summaries
89
+ ]
90
+ return ExperimentResultResponse(
91
+ experiment_id=experiment_id,
92
+ algorithm=exp.algorithm,
93
+ metrics=MetricResponse(
94
+ cluster_count=metric.cluster_count,
95
+ noise_ratio=metric.noise_ratio,
96
+ silhouette_score=metric.silhouette_score,
97
+ davies_bouldin_score=metric.davies_bouldin_score,
98
+ calinski_harabasz_score=metric.calinski_harabasz_score,
99
+ stability_score=metric.stability_score,
100
+ interpretability_score=metric.interpretability_score,
101
+ ),
102
+ scatter_points=scatter,
103
+ cluster_summaries=summary_items,
104
+ status=exp.status,
105
+ )
backend/app/api/exports.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from app.db import get_session
3
+ from app.repositories.artifact_repo import ArtifactRepository
4
+ from app.repositories.cluster_repo import ClusterRepository
5
+ from app.repositories.dataset_repo import DatasetRepository
6
+ from app.repositories.experiment_repo import ExperimentRepository
7
+ from app.schemas.export import ExportResponse
8
+ from app.services.export_service import ExportService
9
+
10
+ router = APIRouter(prefix='/exports', tags=['exports'])
11
+
12
+ @router.get('/{experiment_id}', response_model=ExportResponse)
13
+ def export_experiment(experiment_id: str):
14
+ with get_session() as session:
15
+ exp = ExperimentRepository(session).get(experiment_id)
16
+ if not exp:
17
+ raise HTTPException(status_code=404, detail='Experiment not found')
18
+ service = ExportService(
19
+ dataset_repo=DatasetRepository(session),
20
+ cluster_repo=ClusterRepository(session),
21
+ artifact_repo=ArtifactRepository(session),
22
+ )
23
+ path = service.export_csv(experiment_id, exp.dataset_id)
24
+ return ExportResponse(experiment_id=experiment_id, download_path=path)
backend/app/api/health.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from app.schemas.health import HealthResponse
3
+ router = APIRouter(tags=['health'])
4
+
5
+ @router.get('/health', response_model=HealthResponse)
6
+ def health() -> HealthResponse:
7
+ return HealthResponse(ok=True, service='clusterforge-api')
backend/app/api/presets.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ router = APIRouter(prefix='/presets', tags=['presets'])
3
+
4
+ @router.get('/algorithms')
5
+ def list_algorithms():
6
+ return {
7
+ 'algorithms': [
8
+ {'key': 'kmeans', 'label': 'KMeans', 'params': {'n_clusters': 4, 'random_state': 42, 'max_iter': 300, 'init': 'k-means++'}},
9
+ {'key': 'minibatch_kmeans', 'label': 'MiniBatch KMeans', 'params': {'n_clusters': 4, 'batch_size': 256, 'max_iter': 200}},
10
+ {'key': 'agglomerative', 'label': 'Agglomerative', 'params': {'n_clusters': 4, 'linkage': 'ward', 'metric': 'euclidean'}},
11
+ {'key': 'dbscan', 'label': 'DBSCAN', 'params': {'eps': 0.7, 'min_samples': 5, 'metric': 'euclidean'}},
12
+ {'key': 'hdbscan', 'label': 'HDBSCAN', 'params': {'min_cluster_size': 15, 'min_samples': 5, 'cluster_selection_method': 'eom'}},
13
+ {'key': 'gmm', 'label': 'Gaussian Mixture', 'params': {'n_components': 4, 'covariance_type': 'full'}},
14
+ {'key': 'spectral', 'label': 'Spectral', 'params': {'n_clusters': 4, 'affinity': 'nearest_neighbors', 'n_neighbors': 10}},
15
+ {'key': 'birch', 'label': 'Birch', 'params': {'n_clusters': 4, 'threshold': 0.5, 'branching_factor': 50}},
16
+ ]
17
+ }
backend/app/config.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+ from typing import List
3
+ from pydantic import Field
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+ class Settings(BaseSettings):
7
+ model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', extra='ignore')
8
+ app_name: str = Field(default='ClusterForge', alias='APP_NAME')
9
+ app_env: str = Field(default='dev', alias='APP_ENV')
10
+ api_prefix: str = Field(default='/api', alias='API_PREFIX')
11
+ data_dir: str = Field(default='/data', alias='DATA_DIR')
12
+ db_path: str = Field(default='/data/clusterforge.db', alias='DB_PATH')
13
+ upload_dir: str = Field(default='/data/uploads', alias='UPLOAD_DIR')
14
+ artifact_dir: str = Field(default='/data/artifacts', alias='ARTIFACT_DIR')
15
+ allowed_origins_raw: str = Field(default='http://localhost:3000,http://127.0.0.1:3000', alias='ALLOWED_ORIGINS')
16
+
17
+ @property
18
+ def allowed_origins(self) -> List[str]:
19
+ return [x.strip() for x in self.allowed_origins_raw.split(',') if x.strip()]
20
+
21
+ @lru_cache
22
+ def get_settings() -> Settings:
23
+ return Settings()
backend/app/db.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import contextmanager
2
+ from sqlalchemy import create_engine
3
+ from sqlalchemy.orm import declarative_base, sessionmaker
4
+ from app.config import get_settings
5
+
6
+ settings = get_settings()
7
+ engine = create_engine(
8
+ f"sqlite:///{settings.db_path}",
9
+ connect_args={'check_same_thread': False},
10
+ future=True,
11
+ )
12
+ SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
13
+ Base = declarative_base()
14
+
15
+ def init_storage_dirs() -> None:
16
+ import os
17
+ os.makedirs(settings.data_dir, exist_ok=True)
18
+ os.makedirs(settings.upload_dir, exist_ok=True)
19
+ os.makedirs(settings.artifact_dir, exist_ok=True)
20
+
21
+ def init_db() -> None:
22
+ init_storage_dirs()
23
+ from app.models import dataset, experiment, cluster_result, artifact # noqa
24
+ Base.metadata.create_all(bind=engine)
25
+
26
+ @contextmanager
27
+ def get_session():
28
+ session = SessionLocal()
29
+ try:
30
+ yield session
31
+ session.commit()
32
+ except Exception:
33
+ session.rollback()
34
+ raise
35
+ finally:
36
+ session.close()
backend/app/logging_config.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+ def setup_logging() -> None:
4
+ logging.basicConfig(
5
+ level=logging.INFO,
6
+ format='%(asctime)s | %(levelname)s | %(name)s | %(message)s'
7
+ )
backend/app/main.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from app.api.clusters import router as clusters_router
4
+ from app.api.compare import router as compare_router
5
+ from app.api.datasets import router as datasets_router
6
+ from app.api.experiments import router as experiments_router
7
+ from app.api.exports import router as exports_router
8
+ from app.api.health import router as health_router
9
+ from app.api.presets import router as presets_router
10
+ from app.config import get_settings
11
+ from app.db import init_db
12
+ from app.logging_config import setup_logging
13
+
14
+ setup_logging()
15
+ settings = get_settings()
16
+ init_db()
17
+ app = FastAPI(title=settings.app_name)
18
+ app.add_middleware(
19
+ CORSMiddleware,
20
+ allow_origins=settings.allowed_origins,
21
+ allow_credentials=True,
22
+ allow_methods=['*'],
23
+ allow_headers=['*'],
24
+ )
25
+ app.include_router(health_router, prefix=settings.api_prefix)
26
+ app.include_router(datasets_router, prefix=settings.api_prefix)
27
+ app.include_router(experiments_router, prefix=settings.api_prefix)
28
+ app.include_router(clusters_router, prefix=settings.api_prefix)
29
+ app.include_router(compare_router, prefix=settings.api_prefix)
30
+ app.include_router(exports_router, prefix=settings.api_prefix)
31
+ app.include_router(presets_router, prefix=settings.api_prefix)
backend/app/ml/__init__.py ADDED
File without changes
backend/app/ml/algorithms/__init__.py ADDED
File without changes
backend/app/ml/algorithms/agglomerative_runner.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sklearn.cluster import AgglomerativeClustering
2
+ from app.ml.algorithms.base import BaseClusterRunner, ClusterRunOutput
3
+ class Runner(BaseClusterRunner):
4
+ name = 'agglomerative'
5
+ def default_params(self) -> dict:
6
+ return {'n_clusters': 4, 'linkage': 'ward', 'metric': 'euclidean'}
7
+ def fit_predict(self, X, params: dict) -> ClusterRunOutput:
8
+ cfg = self.default_params() | params
9
+ kwargs = {'n_clusters': int(cfg['n_clusters']), 'linkage': str(cfg.get('linkage', 'ward'))}
10
+ if kwargs['linkage'] != 'ward':
11
+ kwargs['metric'] = str(cfg.get('metric', 'euclidean'))
12
+ model = AgglomerativeClustering(**kwargs)
13
+ labels = model.fit_predict(X)
14
+ return ClusterRunOutput(labels=labels.tolist())
backend/app/ml/algorithms/base.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ @dataclass
5
+ class ClusterRunOutput:
6
+ labels: list[int]
7
+ cluster_centers: list[list[float]] | None = None
8
+ extra: dict[str, Any] | None = None
9
+
10
+ class BaseClusterRunner:
11
+ name: str = 'base'
12
+ def fit_predict(self, X, params: dict) -> ClusterRunOutput:
13
+ raise NotImplementedError
14
+ def supports_noise(self) -> bool:
15
+ return False
16
+ def default_params(self) -> dict:
17
+ return {}
backend/app/ml/algorithms/birch_runner.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sklearn.cluster import Birch
2
+ from app.ml.algorithms.base import BaseClusterRunner, ClusterRunOutput
3
+ class Runner(BaseClusterRunner):
4
+ name = 'birch'
5
+ def default_params(self) -> dict:
6
+ return {'n_clusters': 4, 'threshold': 0.5, 'branching_factor': 50}
7
+ def fit_predict(self, X, params: dict) -> ClusterRunOutput:
8
+ cfg = self.default_params() | params
9
+ model = Birch(
10
+ n_clusters=int(cfg.get('n_clusters', 4)),
11
+ threshold=float(cfg.get('threshold', 0.5)),
12
+ branching_factor=int(cfg.get('branching_factor', 50)),
13
+ )
14
+ labels = model.fit_predict(X)
15
+ return ClusterRunOutput(labels=labels.tolist())
backend/app/ml/algorithms/dbscan_runner.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sklearn.cluster import DBSCAN
2
+ from app.ml.algorithms.base import BaseClusterRunner, ClusterRunOutput
3
+ class Runner(BaseClusterRunner):
4
+ name = 'dbscan'
5
+ def default_params(self) -> dict:
6
+ return {'eps': 0.7, 'min_samples': 5, 'metric': 'euclidean'}
7
+ def supports_noise(self) -> bool:
8
+ return True
9
+ def fit_predict(self, X, params: dict) -> ClusterRunOutput:
10
+ cfg = self.default_params() | params
11
+ model = DBSCAN(
12
+ eps=float(cfg.get('eps', 0.7)),
13
+ min_samples=int(cfg.get('min_samples', 5)),
14
+ metric=str(cfg.get('metric', 'euclidean')),
15
+ )
16
+ labels = model.fit_predict(X)
17
+ return ClusterRunOutput(labels=labels.tolist())
backend/app/ml/algorithms/gmm_runner.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sklearn.mixture import GaussianMixture
2
+ from app.ml.algorithms.base import BaseClusterRunner, ClusterRunOutput
3
+ class Runner(BaseClusterRunner):
4
+ name = 'gmm'
5
+ def default_params(self) -> dict:
6
+ return {'n_components': 4, 'covariance_type': 'full', 'random_state': 42}
7
+ def fit_predict(self, X, params: dict) -> ClusterRunOutput:
8
+ cfg = self.default_params() | params
9
+ model = GaussianMixture(
10
+ n_components=int(cfg.get('n_components', 4)),
11
+ covariance_type=str(cfg.get('covariance_type', 'full')),
12
+ random_state=int(cfg.get('random_state', 42)),
13
+ )
14
+ labels = model.fit_predict(X)
15
+ return ClusterRunOutput(labels=labels.tolist(), cluster_centers=model.means_.tolist())
backend/app/ml/algorithms/hdbscan_runner.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.ml.algorithms.base import BaseClusterRunner, ClusterRunOutput
2
+ class Runner(BaseClusterRunner):
3
+ name = 'hdbscan'
4
+ def default_params(self) -> dict:
5
+ return {'min_cluster_size': 15, 'min_samples': 5, 'cluster_selection_method': 'eom'}
6
+ def supports_noise(self) -> bool:
7
+ return True
8
+ def fit_predict(self, X, params: dict) -> ClusterRunOutput:
9
+ try:
10
+ import hdbscan
11
+ except Exception as exc:
12
+ raise ValueError('HDBSCAN is not installed in this environment') from exc
13
+ cfg = self.default_params() | params
14
+ model = hdbscan.HDBSCAN(
15
+ min_cluster_size=int(cfg.get('min_cluster_size', 15)),
16
+ min_samples=int(cfg.get('min_samples', 5)),
17
+ cluster_selection_method=str(cfg.get('cluster_selection_method', 'eom')),
18
+ )
19
+ labels = model.fit_predict(X)
20
+ return ClusterRunOutput(labels=labels.tolist())
backend/app/ml/algorithms/kmeans_runner.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sklearn.cluster import KMeans
2
+ from app.ml.algorithms.base import BaseClusterRunner, ClusterRunOutput
3
+ class Runner(BaseClusterRunner):
4
+ name = 'kmeans'
5
+ def default_params(self) -> dict:
6
+ return {'n_clusters': 4, 'random_state': 42, 'max_iter': 300, 'init': 'k-means++'}
7
+ def fit_predict(self, X, params: dict) -> ClusterRunOutput:
8
+ cfg = self.default_params() | params
9
+ model = KMeans(
10
+ n_clusters=int(cfg['n_clusters']),
11
+ random_state=int(cfg.get('random_state', 42)),
12
+ max_iter=int(cfg.get('max_iter', 300)),
13
+ init=str(cfg.get('init', 'k-means++')),
14
+ n_init=10,
15
+ )
16
+ labels = model.fit_predict(X)
17
+ return ClusterRunOutput(labels=labels.tolist(), cluster_centers=model.cluster_centers_.tolist())
backend/app/ml/algorithms/minibatch_kmeans_runner.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sklearn.cluster import MiniBatchKMeans
2
+ from app.ml.algorithms.base import BaseClusterRunner, ClusterRunOutput
3
+ class Runner(BaseClusterRunner):
4
+ name = 'minibatch_kmeans'
5
+ def default_params(self) -> dict:
6
+ return {'n_clusters': 4, 'random_state': 42, 'max_iter': 200, 'batch_size': 256}
7
+ def fit_predict(self, X, params: dict) -> ClusterRunOutput:
8
+ cfg = self.default_params() | params
9
+ model = MiniBatchKMeans(
10
+ n_clusters=int(cfg['n_clusters']),
11
+ random_state=int(cfg.get('random_state', 42)),
12
+ max_iter=int(cfg.get('max_iter', 200)),
13
+ batch_size=int(cfg.get('batch_size', 256)),
14
+ n_init=5,
15
+ )
16
+ labels = model.fit_predict(X)
17
+ return ClusterRunOutput(labels=labels.tolist(), cluster_centers=model.cluster_centers_.tolist())
backend/app/ml/algorithms/spectral_runner.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sklearn.cluster import SpectralClustering
2
+ from app.ml.algorithms.base import BaseClusterRunner, ClusterRunOutput
3
+ class Runner(BaseClusterRunner):
4
+ name = 'spectral'
5
+ def default_params(self) -> dict:
6
+ return {'n_clusters': 4, 'affinity': 'nearest_neighbors', 'n_neighbors': 10, 'random_state': 42}
7
+ def fit_predict(self, X, params: dict) -> ClusterRunOutput:
8
+ cfg = self.default_params() | params
9
+ model = SpectralClustering(
10
+ n_clusters=int(cfg.get('n_clusters', 4)),
11
+ affinity=str(cfg.get('affinity', 'nearest_neighbors')),
12
+ n_neighbors=int(cfg.get('n_neighbors', 10)),
13
+ random_state=int(cfg.get('random_state', 42)),
14
+ assign_labels='kmeans',
15
+ )
16
+ labels = model.fit_predict(X)
17
+ return ClusterRunOutput(labels=labels.tolist())
backend/app/ml/explain/__init__.py ADDED
File without changes
backend/app/ml/explain/cluster_profiles.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+
3
+ def build_cluster_profiles(df: pd.DataFrame, labels: list[int], feature_columns: list[str]) -> dict[int, dict]:
4
+ temp = df[feature_columns].copy()
5
+ temp['_cluster'] = labels
6
+ profiles = {}
7
+ for cluster_label, cluster_df in temp.groupby('_cluster'):
8
+ numeric_profile = {}
9
+ categorical_profile = {}
10
+ for col in feature_columns:
11
+ if pd.api.types.is_numeric_dtype(temp[col]):
12
+ numeric_profile[col] = round(float(cluster_df[col].mean()), 4)
13
+ else:
14
+ vc = cluster_df[col].astype(str).value_counts(normalize=True)
15
+ if len(vc):
16
+ categorical_profile[col] = {'top_value': vc.index[0], 'share': round(float(vc.iloc[0]), 4)}
17
+ profiles[int(cluster_label)] = {
18
+ 'size': int(len(cluster_df)),
19
+ 'numeric_profile': numeric_profile,
20
+ 'categorical_profile': categorical_profile,
21
+ }
22
+ return profiles
backend/app/ml/explain/feature_lift.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+
3
+ def compute_feature_lift(df: pd.DataFrame, labels: list[int], feature_columns: list[str]) -> dict[int, dict]:
4
+ temp = df[feature_columns].copy()
5
+ temp['_cluster'] = labels
6
+ out = {}
7
+ global_numeric = {c: float(temp[c].mean()) for c in feature_columns if pd.api.types.is_numeric_dtype(temp[c])}
8
+ for cluster_label, cluster_df in temp.groupby('_cluster'):
9
+ info = {'top_numeric_lifts': [], 'top_categorical_modes': []}
10
+ numeric_lifts = []
11
+ for col in feature_columns:
12
+ if pd.api.types.is_numeric_dtype(temp[col]):
13
+ base = global_numeric.get(col, 0.0)
14
+ mean = float(cluster_df[col].mean())
15
+ lift = round(mean - base, 4)
16
+ numeric_lifts.append({'feature': col, 'cluster_mean': round(mean, 4), 'global_mean': round(base, 4), 'lift': lift})
17
+ else:
18
+ mode_series = cluster_df[col].astype(str).value_counts(normalize=True)
19
+ if len(mode_series):
20
+ info['top_categorical_modes'].append({'feature': col, 'value': mode_series.index[0], 'share': round(float(mode_series.iloc[0]), 4)})
21
+ info['top_numeric_lifts'] = sorted(numeric_lifts, key=lambda x: abs(x['lift']), reverse=True)[:5]
22
+ info['top_categorical_modes'] = info['top_categorical_modes'][:5]
23
+ out[int(cluster_label)] = info
24
+ return out
backend/app/ml/explain/summary_writer.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ def build_cluster_description(cluster_label: int, profile: dict, lift_info: dict) -> str:
2
+ numeric = lift_info.get('top_numeric_lifts', [])[:3]
3
+ categorical = lift_info.get('top_categorical_modes', [])[:2]
4
+ parts = []
5
+ if numeric:
6
+ parts.append(', '.join([('high ' if item['lift'] >= 0 else 'low ') + item['feature'] for item in numeric]))
7
+ if categorical:
8
+ parts.append(', '.join([f"{item['feature']} is mostly {item['value']}" for item in categorical]))
9
+ core = '. '.join([p for p in parts if p]).strip() or 'This cluster is defined by a mixed feature pattern.'
10
+ return f"Cluster {cluster_label} has {profile.get('size', 0)} rows. {core}."
backend/app/ml/metrics/__init__.py ADDED
File without changes
backend/app/ml/metrics/clustering_metrics.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from sklearn.metrics import calinski_harabasz_score, davies_bouldin_score, silhouette_score
3
+
4
+ def compute_metrics(X, labels: list[int]) -> dict:
5
+ arr = np.array(labels)
6
+ unique = set(arr.tolist())
7
+ non_noise = [x for x in unique if x != -1]
8
+ cluster_count = len(non_noise)
9
+ noise_ratio = float((arr == -1).mean()) if len(arr) else 0.0
10
+ metrics = {
11
+ 'cluster_count': cluster_count,
12
+ 'noise_ratio': round(noise_ratio, 4),
13
+ 'silhouette_score': None,
14
+ 'davies_bouldin_score': None,
15
+ 'calinski_harabasz_score': None,
16
+ }
17
+ mask = arr != -1 if -1 in unique else np.ones(len(arr), dtype=bool)
18
+ X_eval = X[mask]
19
+ y_eval = arr[mask]
20
+ if len(set(y_eval.tolist())) >= 2 and len(X_eval) > len(set(y_eval.tolist())):
21
+ try:
22
+ metrics['silhouette_score'] = round(float(silhouette_score(X_eval, y_eval)), 4)
23
+ except Exception:
24
+ pass
25
+ try:
26
+ metrics['davies_bouldin_score'] = round(float(davies_bouldin_score(X_eval, y_eval)), 4)
27
+ except Exception:
28
+ pass
29
+ try:
30
+ metrics['calinski_harabasz_score'] = round(float(calinski_harabasz_score(X_eval, y_eval)), 4)
31
+ except Exception:
32
+ pass
33
+ return metrics
backend/app/ml/metrics/interpretability.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ def compute_interpretability_score(df: pd.DataFrame, labels: list[int], feature_columns: list[str]) -> float:
5
+ if len(set(labels)) <= 1:
6
+ return 0.0
7
+ scored = []
8
+ temp = df[feature_columns].copy()
9
+ temp['_cluster'] = labels
10
+ for col in feature_columns:
11
+ if pd.api.types.is_numeric_dtype(temp[col]):
12
+ global_std = temp[col].std() or 1.0
13
+ diffs = temp.groupby('_cluster')[col].mean().diff().abs().fillna(0)
14
+ lift = float(diffs.mean() / global_std)
15
+ else:
16
+ top_share = temp.groupby('_cluster')[col].agg(lambda s: s.astype(str).value_counts(normalize=True).iloc[0] if len(s) else 0).mean()
17
+ lift = float(top_share)
18
+ scored.append(min(lift, 1.0))
19
+ return round(float(np.mean(scored)), 4) if scored else 0.0
backend/app/ml/metrics/stability.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from sklearn.metrics import normalized_mutual_info_score
3
+
4
+ def compute_stability_score(labels: list[int]) -> float:
5
+ arr = np.array(labels)
6
+ if len(set(arr.tolist())) <= 1:
7
+ return 0.0
8
+ rotated = np.roll(arr, 1)
9
+ score = normalized_mutual_info_score(arr, rotated)
10
+ return round(float(score), 4)
backend/app/ml/outliers/__init__.py ADDED
File without changes
backend/app/ml/outliers/centroid_distance.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+ def detect_centroid_outliers(X, labels: list[int], threshold_quantile: float = 0.95) -> list[bool]:
4
+ arr = np.array(labels)
5
+ outliers = np.zeros(len(arr), dtype=bool)
6
+ for cluster_label in sorted(set(arr.tolist())):
7
+ if cluster_label == -1:
8
+ outliers[arr == cluster_label] = True
9
+ continue
10
+ idx = np.where(arr == cluster_label)[0]
11
+ if len(idx) <= 2:
12
+ continue
13
+ cluster_points = X[idx]
14
+ centroid = cluster_points.mean(axis=0)
15
+ dists = np.linalg.norm(cluster_points - centroid, axis=1)
16
+ cut = np.quantile(dists, threshold_quantile)
17
+ outliers[idx] = dists >= cut
18
+ return outliers.tolist()
backend/app/ml/outliers/density_noise.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ def detect_noise_outliers(labels: list[int]) -> list[bool]:
2
+ return [label == -1 for label in labels]
backend/app/ml/outliers/isolation_forest.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from sklearn.ensemble import IsolationForest
2
+
3
+ def detect_isolation_outliers(X) -> list[bool]:
4
+ model = IsolationForest(random_state=42, contamination='auto')
5
+ flags = model.fit_predict(X)
6
+ return [item == -1 for item in flags.tolist()]
backend/app/ml/preprocess/__init__.py ADDED
File without changes
backend/app/ml/preprocess/column_inference.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ import pandas as pd
3
+
4
+ @dataclass
5
+ class InferredColumn:
6
+ name: str
7
+ inferred_type: str
8
+ missing_pct: float
9
+ cardinality: int
10
+ sample_values: list
11
+
12
+ def infer_columns(df: pd.DataFrame) -> list[InferredColumn]:
13
+ cols = []
14
+ for col in df.columns:
15
+ series = df[col]
16
+ missing_pct = float(series.isna().mean() * 100)
17
+ cardinality = int(series.nunique(dropna=True))
18
+ sample_values = series.dropna().astype(str).head(5).tolist()
19
+ if pd.api.types.is_bool_dtype(series):
20
+ inferred = 'boolean'
21
+ elif pd.api.types.is_numeric_dtype(series):
22
+ inferred = 'numeric'
23
+ else:
24
+ unique_ratio = cardinality / max(len(series), 1)
25
+ inferred = 'high_cardinality' if cardinality > 50 and unique_ratio > 0.3 else 'categorical'
26
+ cols.append(InferredColumn(
27
+ name=col,
28
+ inferred_type=inferred,
29
+ missing_pct=round(missing_pct, 2),
30
+ cardinality=cardinality,
31
+ sample_values=sample_values,
32
+ ))
33
+ return cols
backend/app/ml/preprocess/encoders.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from sklearn.preprocessing import OneHotEncoder
2
+
3
+ def get_onehot_encoder():
4
+ try:
5
+ return OneHotEncoder(handle_unknown='ignore', sparse_output=False)
6
+ except TypeError:
7
+ return OneHotEncoder(handle_unknown='ignore', sparse=False)
backend/app/ml/preprocess/imputers.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from sklearn.impute import SimpleImputer
2
+
3
+ def numeric_imputer():
4
+ return SimpleImputer(strategy='median')
5
+
6
+ def categorical_imputer():
7
+ return SimpleImputer(strategy='most_frequent')
backend/app/ml/preprocess/matrix_builder.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ from sklearn.compose import ColumnTransformer
3
+ from sklearn.pipeline import Pipeline
4
+ from app.ml.preprocess.encoders import get_onehot_encoder
5
+ from app.ml.preprocess.imputers import categorical_imputer, numeric_imputer
6
+ from app.ml.preprocess.scalers import get_scaler
7
+
8
+ def build_feature_matrix(df: pd.DataFrame, feature_columns: list[str], numeric_scaler: str = 'standard', categorical_encoder: str = 'onehot'):
9
+ selected = df[feature_columns].copy()
10
+ numeric_cols = [c for c in selected.columns if pd.api.types.is_numeric_dtype(selected[c]) or pd.api.types.is_bool_dtype(selected[c])]
11
+ categorical_cols = [c for c in selected.columns if c not in numeric_cols]
12
+
13
+ numeric_pipeline = Pipeline(steps=[('imputer', numeric_imputer()), ('scaler', get_scaler(numeric_scaler))])
14
+ categorical_pipeline = Pipeline(steps=[('imputer', categorical_imputer()), ('encoder', get_onehot_encoder())])
15
+
16
+ transformer = ColumnTransformer(
17
+ transformers=[('num', numeric_pipeline, numeric_cols), ('cat', categorical_pipeline, categorical_cols)],
18
+ remainder='drop',
19
+ )
20
+ X = transformer.fit_transform(selected)
21
+
22
+ feature_names = []
23
+ feature_names.extend(numeric_cols)
24
+ if categorical_cols:
25
+ enc = transformer.named_transformers_['cat'].named_steps['encoder']
26
+ try:
27
+ cat_names = enc.get_feature_names_out(categorical_cols).tolist()
28
+ except Exception:
29
+ cat_names = categorical_cols
30
+ feature_names.extend(cat_names)
31
+
32
+ return X, feature_names, {'numeric_cols': numeric_cols, 'categorical_cols': categorical_cols}
backend/app/ml/preprocess/reducers.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sklearn.decomposition import PCA
2
+
3
+ def pca_2d(X):
4
+ reducer = PCA(n_components=2, random_state=42)
5
+ coords = reducer.fit_transform(X)
6
+ return coords[:, 0], coords[:, 1]
7
+
8
+ def umap_2d(X):
9
+ try:
10
+ import umap
11
+ reducer = umap.UMAP(n_components=2, random_state=42)
12
+ coords = reducer.fit_transform(X)
13
+ except Exception:
14
+ reducer = PCA(n_components=2, random_state=42)
15
+ coords = reducer.fit_transform(X)
16
+ return coords[:, 0], coords[:, 1]
backend/app/ml/preprocess/scalers.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from sklearn.preprocessing import MinMaxScaler, RobustScaler, StandardScaler
2
+
3
+ def get_scaler(name: str):
4
+ name = (name or 'standard').lower()
5
+ if name == 'minmax':
6
+ return MinMaxScaler()
7
+ if name == 'robust':
8
+ return RobustScaler()
9
+ return StandardScaler()
backend/app/models/__init__.py ADDED
File without changes
backend/app/models/artifact.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
3
+ from sqlalchemy.orm import Mapped, mapped_column
4
+ from app.db import Base
5
+
6
+ class Artifact(Base):
7
+ __tablename__ = 'artifacts'
8
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
9
+ experiment_id: Mapped[str] = mapped_column(ForeignKey('experiments.id'), index=True, nullable=False)
10
+ artifact_type: Mapped[str] = mapped_column(String(64), nullable=False)
11
+ path: Mapped[str] = mapped_column(Text, nullable=False)
12
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
backend/app/models/cluster_result.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, Text
2
+ from sqlalchemy.orm import Mapped, mapped_column
3
+ from app.db import Base
4
+
5
+ class ExperimentMetric(Base):
6
+ __tablename__ = 'experiment_metrics'
7
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
8
+ experiment_id: Mapped[str] = mapped_column(ForeignKey('experiments.id'), index=True, nullable=False)
9
+ cluster_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
10
+ noise_ratio: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
11
+ silhouette_score: Mapped[float | None] = mapped_column(Float, nullable=True)
12
+ davies_bouldin_score: Mapped[float | None] = mapped_column(Float, nullable=True)
13
+ calinski_harabasz_score: Mapped[float | None] = mapped_column(Float, nullable=True)
14
+ stability_score: Mapped[float | None] = mapped_column(Float, nullable=True)
15
+ interpretability_score: Mapped[float | None] = mapped_column(Float, nullable=True)
16
+
17
+ class ClusterResult(Base):
18
+ __tablename__ = 'cluster_results'
19
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
20
+ experiment_id: Mapped[str] = mapped_column(ForeignKey('experiments.id'), index=True, nullable=False)
21
+ row_index: Mapped[int] = mapped_column(Integer, nullable=False)
22
+ cluster_label: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
23
+ is_outlier: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
24
+ pca_x: Mapped[float | None] = mapped_column(Float, nullable=True)
25
+ pca_y: Mapped[float | None] = mapped_column(Float, nullable=True)
26
+ umap_x: Mapped[float | None] = mapped_column(Float, nullable=True)
27
+ umap_y: Mapped[float | None] = mapped_column(Float, nullable=True)
28
+
29
+ class ClusterSummary(Base):
30
+ __tablename__ = 'cluster_summaries'
31
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
32
+ experiment_id: Mapped[str] = mapped_column(ForeignKey('experiments.id'), index=True, nullable=False)
33
+ cluster_label: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
34
+ size: Mapped[int] = mapped_column(Integer, nullable=False)
35
+ summary_json: Mapped[str] = mapped_column(Text, default='{}', nullable=False)
36
+ description_text: Mapped[str] = mapped_column(Text, nullable=False)
backend/app/models/dataset.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from sqlalchemy import DateTime, Integer, String, Text
3
+ from sqlalchemy.orm import Mapped, mapped_column
4
+ from app.db import Base
5
+
6
+ class Dataset(Base):
7
+ __tablename__ = 'datasets'
8
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
9
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
10
+ file_path: Mapped[str] = mapped_column(Text, nullable=False)
11
+ sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
12
+ uploaded_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
13
+ row_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
14
+ column_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
15
+ schema_json: Mapped[str] = mapped_column(Text, default='{}', nullable=False)
16
+ profile_json: Mapped[str] = mapped_column(Text, default='{}', nullable=False)
backend/app/models/experiment.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
3
+ from sqlalchemy.orm import Mapped, mapped_column
4
+ from app.db import Base
5
+
6
+ class Experiment(Base):
7
+ __tablename__ = 'experiments'
8
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
9
+ dataset_id: Mapped[str] = mapped_column(ForeignKey('datasets.id'), index=True, nullable=False)
10
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
11
+ algorithm: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
12
+ status: Mapped[str] = mapped_column(String(32), nullable=False, default='pending')
13
+ config_json: Mapped[str] = mapped_column(Text, default='{}', nullable=False)
14
+ started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
15
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
16
+ runtime_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
17
+ error_message: Mapped[str | None] = mapped_column(Text, nullable=True)