Spaces:
Sleeping
Sleeping
Upload 117 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +37 -10
- backend/.DS_Store +0 -0
- backend/app/__init__.py +0 -0
- backend/app/api/__init__.py +0 -0
- backend/app/api/clusters.py +40 -0
- backend/app/api/compare.py +19 -0
- backend/app/api/datasets.py +55 -0
- backend/app/api/experiments.py +105 -0
- backend/app/api/exports.py +24 -0
- backend/app/api/health.py +7 -0
- backend/app/api/presets.py +17 -0
- backend/app/config.py +23 -0
- backend/app/db.py +36 -0
- backend/app/logging_config.py +7 -0
- backend/app/main.py +31 -0
- backend/app/ml/__init__.py +0 -0
- backend/app/ml/algorithms/__init__.py +0 -0
- backend/app/ml/algorithms/agglomerative_runner.py +14 -0
- backend/app/ml/algorithms/base.py +17 -0
- backend/app/ml/algorithms/birch_runner.py +15 -0
- backend/app/ml/algorithms/dbscan_runner.py +17 -0
- backend/app/ml/algorithms/gmm_runner.py +15 -0
- backend/app/ml/algorithms/hdbscan_runner.py +20 -0
- backend/app/ml/algorithms/kmeans_runner.py +17 -0
- backend/app/ml/algorithms/minibatch_kmeans_runner.py +17 -0
- backend/app/ml/algorithms/spectral_runner.py +17 -0
- backend/app/ml/explain/__init__.py +0 -0
- backend/app/ml/explain/cluster_profiles.py +22 -0
- backend/app/ml/explain/feature_lift.py +24 -0
- backend/app/ml/explain/summary_writer.py +10 -0
- backend/app/ml/metrics/__init__.py +0 -0
- backend/app/ml/metrics/clustering_metrics.py +33 -0
- backend/app/ml/metrics/interpretability.py +19 -0
- backend/app/ml/metrics/stability.py +10 -0
- backend/app/ml/outliers/__init__.py +0 -0
- backend/app/ml/outliers/centroid_distance.py +18 -0
- backend/app/ml/outliers/density_noise.py +2 -0
- backend/app/ml/outliers/isolation_forest.py +6 -0
- backend/app/ml/preprocess/__init__.py +0 -0
- backend/app/ml/preprocess/column_inference.py +33 -0
- backend/app/ml/preprocess/encoders.py +7 -0
- backend/app/ml/preprocess/imputers.py +7 -0
- backend/app/ml/preprocess/matrix_builder.py +32 -0
- backend/app/ml/preprocess/reducers.py +16 -0
- backend/app/ml/preprocess/scalers.py +9 -0
- backend/app/models/__init__.py +0 -0
- backend/app/models/artifact.py +12 -0
- backend/app/models/cluster_result.py +36 -0
- backend/app/models/dataset.py +16 -0
- backend/app/models/experiment.py +17 -0
README.md
CHANGED
|
@@ -1,10 +1,37 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|