Spaces:
Sleeping
Sleeping
Commit ·
6973475
1
Parent(s): 9c191b0
Update 2026-03-25 13:52:27
Browse files- .claude/worktrees/jolly-pasteur +1 -0
- .gitignore +9 -1
- Dockerfile +19 -4
- app.py +465 -11
- docs-template.html +963 -0
- mlops/__init__.py +1 -0
- mlops/algorithms.py +392 -0
- mlops/datasets.py +96 -0
- mlops/trainer.py +290 -0
- pipelines/__init__.py +1 -0
- pipelines/dag_engine.py +170 -0
- pipelines/pipeline_defs.py +195 -0
- readme-template.md +264 -0
- requirements.txt +10 -1
- static/css/style.css +486 -0
- static/js/app.js +49 -0
- templates/automl.html +284 -0
- templates/base.html +93 -0
- templates/dashboard.html +327 -0
- templates/experiments.html +47 -0
- templates/models.html +155 -0
- templates/pipeline.html +295 -0
- templates/runs.html +179 -0
.claude/worktrees/jolly-pasteur
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Subproject commit 06e9048746065dfb5fb39450debc6a45ea37c2e6
|
.gitignore
CHANGED
|
@@ -1,7 +1,15 @@
|
|
| 1 |
-
|
| 2 |
*.pyc
|
|
|
|
| 3 |
.env
|
| 4 |
.DS_Store
|
| 5 |
*.log
|
| 6 |
.vscode/
|
| 7 |
*.tmp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
.env
|
| 5 |
.DS_Store
|
| 6 |
*.log
|
| 7 |
.vscode/
|
| 8 |
*.tmp
|
| 9 |
+
mlruns/
|
| 10 |
+
mlflow.db
|
| 11 |
+
logs/
|
| 12 |
+
*.egg-info/
|
| 13 |
+
dist/
|
| 14 |
+
build/
|
| 15 |
+
.pytest_cache/
|
Dockerfile
CHANGED
|
@@ -1,7 +1,22 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
WORKDIR /app
|
| 3 |
-
|
|
|
|
| 4 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
EXPOSE 7860
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# HuggingFace Spaces requires non-root user with UID 1000
|
| 4 |
+
RUN useradd -m -u 1000 user
|
| 5 |
+
USER user
|
| 6 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 7 |
+
|
| 8 |
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
COPY --chown=user:user requirements.txt .
|
| 11 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
COPY --chown=user:user . .
|
| 14 |
+
|
| 15 |
+
RUN mkdir -p mlruns logs
|
| 16 |
+
|
| 17 |
EXPOSE 7860
|
| 18 |
+
|
| 19 |
+
ENV PYTHONUNBUFFERED=1
|
| 20 |
+
ENV FLASK_ENV=production
|
| 21 |
+
|
| 22 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "--threads", "4", "--timeout", "300", "--log-level", "info", "app:app"]
|
app.py
CHANGED
|
@@ -1,12 +1,466 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
app = Flask(__name__)
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AutoMLOps — ML Experiment Tracking & Pipeline Orchestration Platform."""
|
| 2 |
+
import os
|
| 3 |
+
import json
|
| 4 |
+
import threading
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
import mlflow
|
| 8 |
+
import mlflow.sklearn
|
| 9 |
+
from flask import Flask, render_template, request, jsonify
|
| 10 |
+
|
| 11 |
+
from mlops.datasets import DATASETS, load_dataset
|
| 12 |
+
from mlops.algorithms import ALGORITHMS, list_algorithms, all_algorithm_names
|
| 13 |
+
from mlops.trainer import (
|
| 14 |
+
training_jobs, automl_jobs,
|
| 15 |
+
start_training, start_automl,
|
| 16 |
+
)
|
| 17 |
+
from pipelines.dag_engine import pipeline_executions, execute_dag
|
| 18 |
+
from pipelines.pipeline_defs import get_pipeline, PIPELINE_BUILDERS
|
| 19 |
+
|
| 20 |
app = Flask(__name__)
|
| 21 |
+
|
| 22 |
+
# ── MLflow setup ───────────────────────────────────────────────────────────────
|
| 23 |
+
TRACKING_URI = "sqlite:///mlflow.db"
|
| 24 |
+
mlflow.set_tracking_uri(TRACKING_URI)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _mlflow_client():
|
| 28 |
+
return mlflow.tracking.MlflowClient(tracking_uri=TRACKING_URI)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ── Seed demo data on first launch ────────────────────────────────────────────
|
| 32 |
+
|
| 33 |
+
def _seed_demo():
|
| 34 |
+
"""Pre-populate a few MLflow runs so the dashboard looks great immediately."""
|
| 35 |
+
client = _mlflow_client()
|
| 36 |
+
try:
|
| 37 |
+
existing = client.search_runs(experiment_ids=[], max_results=1)
|
| 38 |
+
if existing:
|
| 39 |
+
return # already seeded
|
| 40 |
+
except Exception:
|
| 41 |
+
pass
|
| 42 |
+
|
| 43 |
+
demo_runs = [
|
| 44 |
+
("Iris Flowers", "Ensemble / Boosting", "Random Forest", "classification",
|
| 45 |
+
{"accuracy": 0.9667, "f1_score": 0.9664, "precision": 0.9672, "recall": 0.9667}),
|
| 46 |
+
("Iris Flowers", "Ensemble / Boosting", "XGBoost", "classification",
|
| 47 |
+
{"accuracy": 0.9600, "f1_score": 0.9598, "precision": 0.9601, "recall": 0.9600}),
|
| 48 |
+
("Iris Flowers", "Linear Models", "Logistic Regression", "classification",
|
| 49 |
+
{"accuracy": 0.9467, "f1_score": 0.9463, "precision": 0.9472, "recall": 0.9467}),
|
| 50 |
+
("Wine Quality", "Ensemble / Boosting", "LightGBM", "classification",
|
| 51 |
+
{"accuracy": 0.9722, "f1_score": 0.9720, "precision": 0.9725, "recall": 0.9722}),
|
| 52 |
+
("Wine Quality", "Neural Networks", "MLP (Medium)", "classification",
|
| 53 |
+
{"accuracy": 0.9444, "f1_score": 0.9441, "precision": 0.9449, "recall": 0.9444}),
|
| 54 |
+
("Breast Cancer", "Support Vector Machines", "SVC (RBF Kernel)","classification",
|
| 55 |
+
{"accuracy": 0.9737, "f1_score": 0.9736, "precision": 0.9741, "recall": 0.9737}),
|
| 56 |
+
("Breast Cancer", "Ensemble / Boosting", "Gradient Boosting", "classification",
|
| 57 |
+
{"accuracy": 0.9561, "f1_score": 0.9558, "precision": 0.9565, "recall": 0.9561}),
|
| 58 |
+
("Diabetes Progression", "Ensemble / Boosting", "XGBoost Regressor","regression",
|
| 59 |
+
{"r2_score": 0.4823, "mae": 44.12, "mse": 3124.5, "rmse": 55.90}),
|
| 60 |
+
("Diabetes Progression", "Linear Models", "Ridge Regression", "regression",
|
| 61 |
+
{"r2_score": 0.4612, "mae": 45.87, "mse": 3258.3, "rmse": 57.08}),
|
| 62 |
+
("California Housing","Ensemble / Boosting","LightGBM Regressor", "regression",
|
| 63 |
+
{"r2_score": 0.8341, "mae": 0.3124, "mse": 0.2871, "rmse": 0.5358}),
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
for ds, cat, alg, task, metrics in demo_runs:
|
| 67 |
+
try:
|
| 68 |
+
exp = client.get_experiment_by_name(ds)
|
| 69 |
+
exp_id = exp.experiment_id if exp else mlflow.create_experiment(ds)
|
| 70 |
+
with mlflow.start_run(experiment_id=exp_id,
|
| 71 |
+
run_name=f"{alg} — {ds}") as run:
|
| 72 |
+
mlflow.set_tags({"algorithm": alg, "category": cat,
|
| 73 |
+
"dataset": ds, "task_type": task, "demo": "true"})
|
| 74 |
+
mlflow.log_params({"algorithm": alg, "category": cat, "dataset": ds})
|
| 75 |
+
mlflow.log_metrics(metrics)
|
| 76 |
+
except Exception:
|
| 77 |
+
pass
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# Seed in background so startup isn't delayed
|
| 81 |
+
threading.Thread(target=_seed_demo, daemon=True).start()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 85 |
+
# PAGE ROUTES
|
| 86 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 87 |
+
|
| 88 |
+
@app.route("/")
|
| 89 |
+
def dashboard():
|
| 90 |
+
client = _mlflow_client()
|
| 91 |
+
try:
|
| 92 |
+
runs = client.search_runs(
|
| 93 |
+
experiment_ids=[],
|
| 94 |
+
max_results=200,
|
| 95 |
+
order_by=["start_time DESC"],
|
| 96 |
+
)
|
| 97 |
+
except Exception:
|
| 98 |
+
runs = []
|
| 99 |
+
|
| 100 |
+
# Stats
|
| 101 |
+
total_runs = len(runs)
|
| 102 |
+
completed = [r for r in runs if r.info.status == "FINISHED"]
|
| 103 |
+
best_acc = 0.0
|
| 104 |
+
for r in completed:
|
| 105 |
+
acc = r.data.metrics.get("accuracy") or r.data.metrics.get("r2_score") or 0
|
| 106 |
+
if acc > best_acc:
|
| 107 |
+
best_acc = acc
|
| 108 |
+
|
| 109 |
+
# Recent runs (last 8)
|
| 110 |
+
recent = []
|
| 111 |
+
for r in runs[:8]:
|
| 112 |
+
metrics = r.data.metrics
|
| 113 |
+
primary = (metrics.get("accuracy") or metrics.get("r2_score") or 0)
|
| 114 |
+
recent.append({
|
| 115 |
+
"run_id": r.info.run_id[:8],
|
| 116 |
+
"algorithm": r.data.tags.get("algorithm", "—"),
|
| 117 |
+
"dataset": r.data.tags.get("dataset", "—"),
|
| 118 |
+
"category": r.data.tags.get("category", "—"),
|
| 119 |
+
"task_type": r.data.tags.get("task_type", "classification"),
|
| 120 |
+
"primary_metric": round(primary, 4),
|
| 121 |
+
"status": r.info.status,
|
| 122 |
+
"duration": round((r.info.end_time - r.info.start_time) / 1000, 1)
|
| 123 |
+
if r.info.end_time else "—",
|
| 124 |
+
})
|
| 125 |
+
|
| 126 |
+
# Algorithm distribution
|
| 127 |
+
algo_counts: dict = {}
|
| 128 |
+
for r in completed:
|
| 129 |
+
cat = r.data.tags.get("category", "Other")
|
| 130 |
+
algo_counts[cat] = algo_counts.get(cat, 0) + 1
|
| 131 |
+
|
| 132 |
+
# Dataset distribution
|
| 133 |
+
ds_counts: dict = {}
|
| 134 |
+
for r in completed:
|
| 135 |
+
ds = r.data.tags.get("dataset", "Other")
|
| 136 |
+
ds_counts[ds] = ds_counts.get(ds, 0) + 1
|
| 137 |
+
|
| 138 |
+
return render_template("dashboard.html",
|
| 139 |
+
total_runs=total_runs,
|
| 140 |
+
completed_runs=len(completed),
|
| 141 |
+
best_metric=round(best_acc, 4),
|
| 142 |
+
n_experiments=len(set(r.info.experiment_id for r in runs)),
|
| 143 |
+
recent_runs=recent,
|
| 144 |
+
algo_counts=json.dumps(algo_counts),
|
| 145 |
+
ds_counts=json.dumps(ds_counts),
|
| 146 |
+
datasets=DATASETS,
|
| 147 |
+
algorithms=ALGORITHMS)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@app.route("/experiments")
|
| 151 |
+
def experiments():
|
| 152 |
+
client = _mlflow_client()
|
| 153 |
+
try:
|
| 154 |
+
exps = client.search_experiments()
|
| 155 |
+
except Exception:
|
| 156 |
+
exps = []
|
| 157 |
+
exp_list = []
|
| 158 |
+
for e in exps:
|
| 159 |
+
runs = client.search_runs([e.experiment_id], max_results=100,
|
| 160 |
+
order_by=["start_time DESC"])
|
| 161 |
+
finished = [r for r in runs if r.info.status == "FINISHED"]
|
| 162 |
+
best = 0.0
|
| 163 |
+
for r in finished:
|
| 164 |
+
v = r.data.metrics.get("accuracy") or r.data.metrics.get("r2_score") or 0
|
| 165 |
+
if v > best:
|
| 166 |
+
best = v
|
| 167 |
+
exp_list.append({
|
| 168 |
+
"experiment_id": e.experiment_id,
|
| 169 |
+
"name": e.name,
|
| 170 |
+
"run_count": len(runs),
|
| 171 |
+
"best_metric": round(best, 4),
|
| 172 |
+
"created_at": datetime.fromtimestamp(e.creation_time / 1000).strftime("%Y-%m-%d %H:%M")
|
| 173 |
+
if e.creation_time else "—",
|
| 174 |
+
})
|
| 175 |
+
return render_template("experiments.html", experiments=exp_list)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@app.route("/experiments/<experiment_id>")
|
| 179 |
+
def experiment_runs(experiment_id):
|
| 180 |
+
client = _mlflow_client()
|
| 181 |
+
exp = client.get_experiment(experiment_id)
|
| 182 |
+
runs = client.search_runs([experiment_id], max_results=200,
|
| 183 |
+
order_by=["start_time DESC"])
|
| 184 |
+
run_list = []
|
| 185 |
+
for r in runs:
|
| 186 |
+
m = r.data.metrics
|
| 187 |
+
run_list.append({
|
| 188 |
+
"run_id": r.info.run_id,
|
| 189 |
+
"run_id_short": r.info.run_id[:8],
|
| 190 |
+
"algorithm": r.data.tags.get("algorithm", "—"),
|
| 191 |
+
"category": r.data.tags.get("category", "—"),
|
| 192 |
+
"dataset": r.data.tags.get("dataset", "—"),
|
| 193 |
+
"task_type": r.data.tags.get("task_type", "classification"),
|
| 194 |
+
"metrics": {k: round(v, 4) for k, v in m.items()},
|
| 195 |
+
"params": r.data.params,
|
| 196 |
+
"status": r.info.status,
|
| 197 |
+
"duration": round((r.info.end_time - r.info.start_time) / 1000, 1)
|
| 198 |
+
if r.info.end_time else None,
|
| 199 |
+
"start_time": datetime.fromtimestamp(r.info.start_time / 1000)
|
| 200 |
+
.strftime("%Y-%m-%d %H:%M:%S")
|
| 201 |
+
if r.info.start_time else "—",
|
| 202 |
+
})
|
| 203 |
+
return render_template("runs.html",
|
| 204 |
+
experiment={"name": exp.name, "id": experiment_id},
|
| 205 |
+
runs=run_list)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
@app.route("/pipeline")
|
| 209 |
+
def pipeline():
|
| 210 |
+
dags = {}
|
| 211 |
+
for pid, builder in PIPELINE_BUILDERS.items():
|
| 212 |
+
dags[pid] = builder().to_dict()
|
| 213 |
+
return render_template("pipeline.html", dags=json.dumps(dags),
|
| 214 |
+
datasets=DATASETS)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@app.route("/models")
|
| 218 |
+
def models():
|
| 219 |
+
client = _mlflow_client()
|
| 220 |
+
try:
|
| 221 |
+
registered = client.search_registered_models()
|
| 222 |
+
except Exception:
|
| 223 |
+
registered = []
|
| 224 |
+
model_list = []
|
| 225 |
+
for m in registered:
|
| 226 |
+
versions = client.get_latest_versions(m.name)
|
| 227 |
+
ver_list = []
|
| 228 |
+
for v in versions:
|
| 229 |
+
run = None
|
| 230 |
+
metrics = {}
|
| 231 |
+
try:
|
| 232 |
+
run = client.get_run(v.run_id)
|
| 233 |
+
metrics = {k: round(val, 4) for k, val in run.data.metrics.items()}
|
| 234 |
+
except Exception:
|
| 235 |
+
pass
|
| 236 |
+
ver_list.append({
|
| 237 |
+
"version": v.version,
|
| 238 |
+
"stage": v.current_stage,
|
| 239 |
+
"run_id": v.run_id[:8] if v.run_id else "—",
|
| 240 |
+
"metrics": metrics,
|
| 241 |
+
"created_at": datetime.fromtimestamp(v.creation_timestamp / 1000)
|
| 242 |
+
.strftime("%Y-%m-%d %H:%M")
|
| 243 |
+
if v.creation_timestamp else "—",
|
| 244 |
+
})
|
| 245 |
+
model_list.append({
|
| 246 |
+
"name": m.name,
|
| 247 |
+
"description": m.description or "—",
|
| 248 |
+
"versions": ver_list,
|
| 249 |
+
"latest_stage": ver_list[0]["stage"] if ver_list else "None",
|
| 250 |
+
})
|
| 251 |
+
return render_template("models.html", models=model_list)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@app.route("/automl")
|
| 255 |
+
def automl():
|
| 256 |
+
return render_template("automl.html",
|
| 257 |
+
datasets=DATASETS,
|
| 258 |
+
algorithms=ALGORITHMS)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 262 |
+
# API — TRAINING
|
| 263 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 264 |
+
|
| 265 |
+
@app.route("/api/train", methods=["POST"])
|
| 266 |
+
def api_train():
|
| 267 |
+
data = request.get_json(force=True)
|
| 268 |
+
required = ["dataset", "algorithm", "category", "task_type"]
|
| 269 |
+
if not all(k in data for k in required):
|
| 270 |
+
return jsonify({"error": f"Missing fields: {required}"}), 400
|
| 271 |
+
job_id = start_training(
|
| 272 |
+
dataset_name=data["dataset"],
|
| 273 |
+
algorithm_name=data["algorithm"],
|
| 274 |
+
algorithm_category=data["category"],
|
| 275 |
+
task_type=data["task_type"],
|
| 276 |
+
custom_params=data.get("params"),
|
| 277 |
+
)
|
| 278 |
+
return jsonify({"job_id": job_id, "status": "queued"})
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
@app.route("/api/run/<job_id>/status")
|
| 282 |
+
def api_run_status(job_id):
|
| 283 |
+
job = training_jobs.get(job_id)
|
| 284 |
+
if not job:
|
| 285 |
+
return jsonify({"error": "Job not found"}), 404
|
| 286 |
+
return jsonify(job)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
@app.route("/api/runs")
|
| 290 |
+
def api_runs():
|
| 291 |
+
client = _mlflow_client()
|
| 292 |
+
exp_filter = request.args.get("experiment")
|
| 293 |
+
task_filter = request.args.get("task")
|
| 294 |
+
try:
|
| 295 |
+
exp_ids = []
|
| 296 |
+
if exp_filter:
|
| 297 |
+
exp = client.get_experiment_by_name(exp_filter)
|
| 298 |
+
if exp:
|
| 299 |
+
exp_ids = [exp.experiment_id]
|
| 300 |
+
runs = client.search_runs(
|
| 301 |
+
experiment_ids=exp_ids or [],
|
| 302 |
+
max_results=200,
|
| 303 |
+
order_by=["start_time DESC"],
|
| 304 |
+
)
|
| 305 |
+
except Exception:
|
| 306 |
+
runs = []
|
| 307 |
+
result = []
|
| 308 |
+
for r in runs:
|
| 309 |
+
if task_filter and r.data.tags.get("task_type") != task_filter:
|
| 310 |
+
continue
|
| 311 |
+
m = r.data.metrics
|
| 312 |
+
result.append({
|
| 313 |
+
"run_id": r.info.run_id,
|
| 314 |
+
"algorithm": r.data.tags.get("algorithm", "—"),
|
| 315 |
+
"category": r.data.tags.get("category", "—"),
|
| 316 |
+
"dataset": r.data.tags.get("dataset", "—"),
|
| 317 |
+
"task_type": r.data.tags.get("task_type", "classification"),
|
| 318 |
+
"metrics": {k: round(v, 4) for k, v in m.items()},
|
| 319 |
+
"status": r.info.status,
|
| 320 |
+
"start_time": r.info.start_time,
|
| 321 |
+
})
|
| 322 |
+
return jsonify(result)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 326 |
+
# API — PIPELINE
|
| 327 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 328 |
+
|
| 329 |
+
@app.route("/api/pipeline/<pipeline_id>/execute", methods=["POST"])
|
| 330 |
+
def api_pipeline_execute(pipeline_id):
|
| 331 |
+
try:
|
| 332 |
+
dag = get_pipeline(pipeline_id)
|
| 333 |
+
except ValueError as e:
|
| 334 |
+
return jsonify({"error": str(e)}), 400
|
| 335 |
+
ctx = request.get_json(force=True) or {}
|
| 336 |
+
exec_id = execute_dag(dag, ctx)
|
| 337 |
+
return jsonify({"exec_id": exec_id, "status": "queued"})
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
@app.route("/api/pipeline/status/<exec_id>")
|
| 341 |
+
def api_pipeline_status(exec_id):
|
| 342 |
+
state = pipeline_executions.get(exec_id)
|
| 343 |
+
if not state:
|
| 344 |
+
return jsonify({"error": "Execution not found"}), 404
|
| 345 |
+
return jsonify(state)
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
@app.route("/api/pipeline/<pipeline_id>/dag")
|
| 349 |
+
def api_pipeline_dag(pipeline_id):
|
| 350 |
+
try:
|
| 351 |
+
dag = get_pipeline(pipeline_id)
|
| 352 |
+
except ValueError as e:
|
| 353 |
+
return jsonify({"error": str(e)}), 400
|
| 354 |
+
return jsonify(dag.to_dict())
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 358 |
+
# API — MODEL REGISTRY
|
| 359 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 360 |
+
|
| 361 |
+
@app.route("/api/models/register", methods=["POST"])
|
| 362 |
+
def api_models_register():
|
| 363 |
+
data = request.get_json(force=True)
|
| 364 |
+
run_id = data.get("run_id")
|
| 365 |
+
name = data.get("name")
|
| 366 |
+
if not run_id or not name:
|
| 367 |
+
return jsonify({"error": "run_id and name required"}), 400
|
| 368 |
+
try:
|
| 369 |
+
client = _mlflow_client()
|
| 370 |
+
run = client.get_run(run_id)
|
| 371 |
+
model_uri = f"runs:/{run_id}/model"
|
| 372 |
+
result = mlflow.register_model(model_uri, name)
|
| 373 |
+
return jsonify({"name": result.name, "version": result.version,
|
| 374 |
+
"status": "registered"})
|
| 375 |
+
except Exception as exc:
|
| 376 |
+
return jsonify({"error": str(exc)}), 500
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
@app.route("/api/models/<name>/<version>/stage", methods=["POST"])
|
| 380 |
+
def api_model_stage(name, version):
|
| 381 |
+
data = request.get_json(force=True)
|
| 382 |
+
stage = data.get("stage", "Staging")
|
| 383 |
+
valid = {"Staging", "Production", "Archived", "None"}
|
| 384 |
+
if stage not in valid:
|
| 385 |
+
return jsonify({"error": f"stage must be one of {valid}"}), 400
|
| 386 |
+
try:
|
| 387 |
+
client = _mlflow_client()
|
| 388 |
+
client.transition_model_version_stage(name=name, version=version,
|
| 389 |
+
stage=stage, archive_existing_versions=False)
|
| 390 |
+
return jsonify({"name": name, "version": version, "stage": stage})
|
| 391 |
+
except Exception as exc:
|
| 392 |
+
return jsonify({"error": str(exc)}), 500
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 396 |
+
# API — AUTO-ML
|
| 397 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 398 |
+
|
| 399 |
+
@app.route("/api/automl", methods=["POST"])
|
| 400 |
+
def api_automl():
|
| 401 |
+
data = request.get_json(force=True)
|
| 402 |
+
if "dataset" not in data or "task_type" not in data:
|
| 403 |
+
return jsonify({"error": "dataset and task_type required"}), 400
|
| 404 |
+
job_id = start_automl(
|
| 405 |
+
dataset_name=data["dataset"],
|
| 406 |
+
task_type=data["task_type"],
|
| 407 |
+
optimize_metric=data.get("metric", "accuracy"),
|
| 408 |
+
max_runs=int(data.get("max_runs", 20)),
|
| 409 |
+
)
|
| 410 |
+
return jsonify({"job_id": job_id, "status": "queued"})
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
@app.route("/api/automl/status/<job_id>")
|
| 414 |
+
def api_automl_status(job_id):
|
| 415 |
+
job = automl_jobs.get(job_id)
|
| 416 |
+
if not job:
|
| 417 |
+
return jsonify({"error": "Job not found"}), 404
|
| 418 |
+
return jsonify(job)
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 422 |
+
# API — META
|
| 423 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 424 |
+
|
| 425 |
+
@app.route("/api/algorithms")
|
| 426 |
+
def api_algorithms():
|
| 427 |
+
task = request.args.get("task", "classification")
|
| 428 |
+
try:
|
| 429 |
+
return jsonify(list_algorithms(task))
|
| 430 |
+
except ValueError as e:
|
| 431 |
+
return jsonify({"error": str(e)}), 400
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
@app.route("/api/datasets")
|
| 435 |
+
def api_datasets():
|
| 436 |
+
result = {
|
| 437 |
+
name: {k: v for k, v in cfg.items() if k != "loader"}
|
| 438 |
+
for name, cfg in DATASETS.items()
|
| 439 |
+
}
|
| 440 |
+
return jsonify(result)
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
@app.route("/api/stats")
|
| 444 |
+
def api_stats():
|
| 445 |
+
client = _mlflow_client()
|
| 446 |
+
try:
|
| 447 |
+
runs = client.search_runs(experiment_ids=[], max_results=500)
|
| 448 |
+
except Exception:
|
| 449 |
+
runs = []
|
| 450 |
+
finished = [r for r in runs if r.info.status == "FINISHED"]
|
| 451 |
+
best = 0.0
|
| 452 |
+
for r in finished:
|
| 453 |
+
v = r.data.metrics.get("accuracy") or r.data.metrics.get("r2_score") or 0
|
| 454 |
+
if v > best:
|
| 455 |
+
best = v
|
| 456 |
+
return jsonify({
|
| 457 |
+
"total_runs": len(runs),
|
| 458 |
+
"completed_runs": len(finished),
|
| 459 |
+
"best_metric": round(best, 4),
|
| 460 |
+
"n_experiments": len(set(r.info.experiment_id for r in runs)),
|
| 461 |
+
})
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
# ── Entry point ─────────────────────────────���──────────────────────────────────
|
| 465 |
+
if __name__ == "__main__":
|
| 466 |
+
app.run(host="0.0.0.0", port=7860, debug=False)
|
docs-template.html
ADDED
|
@@ -0,0 +1,963 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" data-theme="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
+
<title>[PROJECT TITLE] · [YOUR NAME]</title>
|
| 6 |
+
|
| 7 |
+
<!-- ▶ THEME FLICKER FIX -->
|
| 8 |
+
<script>document.documentElement.setAttribute('data-theme', localStorage.getItem('mn-theme') || 'dark')</script>
|
| 9 |
+
|
| 10 |
+
<!-- ▶ FAVICON: Replace initials "MN" and colors as needed -->
|
| 11 |
+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%234f8ef7'/%3E%3Cstop offset='100%25' stop-color='%2306b6d4'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='%23070d1f'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='central' text-anchor='middle' font-family='Segoe UI,system-ui,sans-serif' font-weight='900' font-size='26' fill='url(%23g)'%3E[INITIALS]%3C/text%3E%3C/svg%3E">
|
| 12 |
+
|
| 13 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 14 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js"></script>
|
| 15 |
+
|
| 16 |
+
<!-- ▶ Point these to your shared stylesheet and script if you have them -->
|
| 17 |
+
<!-- <link rel="stylesheet" href="/projects/shared.css"> -->
|
| 18 |
+
<!-- <script src="/projects/shared.js" defer></script> -->
|
| 19 |
+
|
| 20 |
+
<style>
|
| 21 |
+
/* ═══════════════════════════════════════════════════
|
| 22 |
+
CSS VARIABLES — Light/Dark theme
|
| 23 |
+
Accent: blue (#4f8ef7) + gold (#f59e0b)
|
| 24 |
+
Change --accent and --gold to match your project.
|
| 25 |
+
════════════════════════════════════════════════════ */
|
| 26 |
+
:root {
|
| 27 |
+
--accent: #4f8ef7;
|
| 28 |
+
--gold: #f59e0b;
|
| 29 |
+
--teal: #06b6d4;
|
| 30 |
+
--green: #22c55e;
|
| 31 |
+
--radius: 14px;
|
| 32 |
+
|
| 33 |
+
/* Dark theme defaults */
|
| 34 |
+
--body-bg: #070d1f;
|
| 35 |
+
--text: #e2e8f0;
|
| 36 |
+
--muted: #8892a4;
|
| 37 |
+
--glass: rgba(255,255,255,.04);
|
| 38 |
+
--glass-border: rgba(255,255,255,.08);
|
| 39 |
+
--card-hover-bg: rgba(255,255,255,.07);
|
| 40 |
+
--card-hover-border:rgba(79,142,247,.3);
|
| 41 |
+
--section-alt: #0b1120;
|
| 42 |
+
}
|
| 43 |
+
[data-theme="light"] {
|
| 44 |
+
--body-bg: #f8fafc;
|
| 45 |
+
--text: #0f172a;
|
| 46 |
+
--muted: #4b5675;
|
| 47 |
+
--glass: rgba(0,0,0,.03);
|
| 48 |
+
--glass-border: rgba(0,0,0,.08);
|
| 49 |
+
--card-hover-bg: rgba(0,0,0,.05);
|
| 50 |
+
--card-hover-border:rgba(37,99,235,.25);
|
| 51 |
+
--section-alt: #f1f5f9;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 55 |
+
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--body-bg); color: var(--text); transition: background .35s, color .35s; }
|
| 56 |
+
a { text-decoration: none; }
|
| 57 |
+
code { font-family: 'Cascadia Code', 'Fira Code', monospace; font-size: .88em; background: rgba(79,142,247,.1); padding: 1px 5px; border-radius: 4px; }
|
| 58 |
+
|
| 59 |
+
/* ── SECTION TAG ── */
|
| 60 |
+
.s-tag { display: inline-block; font-size: .7rem; font-weight: 800; text-transform: uppercase; letter-spacing: .1em; padding: 3px 10px; border-radius: 6px; margin-bottom: 10px; }
|
| 61 |
+
.s-tag-blue { background: rgba(79,142,247,.12); color: var(--accent); border: 1px solid rgba(79,142,247,.2); }
|
| 62 |
+
.s-tag-gold { background: rgba(245,158,11,.12); color: var(--gold); border: 1px solid rgba(245,158,11,.2); }
|
| 63 |
+
.s-tag-teal { background: rgba(6,182,212,.12); color: var(--teal); border: 1px solid rgba(6,182,212,.2); }
|
| 64 |
+
|
| 65 |
+
/* ── GRADIENT TEXT ── */
|
| 66 |
+
.grad-text { background: linear-gradient(135deg, var(--accent), var(--gold)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
| 67 |
+
|
| 68 |
+
/* ── HERO ── */
|
| 69 |
+
.hero {
|
| 70 |
+
padding: 80px 24px 56px;
|
| 71 |
+
background: var(--body-bg);
|
| 72 |
+
position: relative; overflow: hidden; transition: background .35s;
|
| 73 |
+
}
|
| 74 |
+
.hero::before {
|
| 75 |
+
content: ''; position: absolute; inset: 0; pointer-events: none;
|
| 76 |
+
background: radial-gradient(ellipse 80% 55% at 50% -10%, rgba(79,142,247,.15) 0%, transparent 65%);
|
| 77 |
+
}
|
| 78 |
+
[data-theme="light"] .hero::before {
|
| 79 |
+
background: radial-gradient(ellipse 80% 55% at 50% -10%, rgba(37,99,235,.09) 0%, transparent 65%);
|
| 80 |
+
}
|
| 81 |
+
.hero::after {
|
| 82 |
+
content: ''; position: absolute; inset: 0; pointer-events: none;
|
| 83 |
+
background-image: linear-gradient(rgba(79,142,247,.035) 1px, transparent 1px),
|
| 84 |
+
linear-gradient(90deg, rgba(79,142,247,.035) 1px, transparent 1px);
|
| 85 |
+
background-size: 48px 48px;
|
| 86 |
+
}
|
| 87 |
+
.hero-inner { max-width: 1100px; margin: 0 auto; position: relative; z-index: 1; }
|
| 88 |
+
|
| 89 |
+
/* ── BREADCRUMB ── */
|
| 90 |
+
.breadcrumb { font-size: .78rem; color: var(--muted); margin-bottom: 18px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
| 91 |
+
.breadcrumb a { color: var(--muted); transition: .2s; }
|
| 92 |
+
.breadcrumb a:hover { color: var(--accent); }
|
| 93 |
+
.breadcrumb span { opacity: .4; }
|
| 94 |
+
|
| 95 |
+
/* ── PILLS ── */
|
| 96 |
+
.tag-row { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; flex-wrap: wrap; }
|
| 97 |
+
.pill { display: inline-flex; align-items: center; gap: 6px; padding: 5px 14px; border-radius: 20px; font-size: .75rem; font-weight: 700; letter-spacing: .04em; }
|
| 98 |
+
.pill-blue { background: rgba(79,142,247,.12); border: 1px solid rgba(79,142,247,.25); color: var(--accent); }
|
| 99 |
+
.pill-gold { background: rgba(245,158,11,.12); border: 1px solid rgba(245,158,11,.25); color: var(--gold); }
|
| 100 |
+
.pill-teal { background: rgba(6,182,212,.12); border: 1px solid rgba(6,182,212,.25); color: var(--teal); }
|
| 101 |
+
[data-theme="light"] .pill-blue { background: rgba(37,99,235,.1); border-color: rgba(37,99,235,.25); }
|
| 102 |
+
[data-theme="light"] .pill-gold { background: rgba(217,119,6,.1); border-color: rgba(217,119,6,.25); color: #92400e; }
|
| 103 |
+
[data-theme="light"] .pill-teal { background: rgba(8,145,178,.1); border-color: rgba(8,145,178,.25); color: #0e7490; }
|
| 104 |
+
|
| 105 |
+
h1 { font-size: clamp(1.7rem,3.5vw,2.7rem); font-weight: 900; line-height: 1.2; margin-bottom: 20px; max-width: 820px; color: var(--text); }
|
| 106 |
+
.hero-sub { font-size: 1rem; color: var(--muted); max-width: 680px; margin-bottom: 28px; line-height: 1.65; }
|
| 107 |
+
.hero-sub strong { color: var(--text); }
|
| 108 |
+
.hero-meta { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; margin-bottom: 24px; font-size: .83rem; color: var(--muted); }
|
| 109 |
+
.hero-meta span { display: flex; align-items: center; gap: 6px; }
|
| 110 |
+
.hero-meta i { color: var(--accent); }
|
| 111 |
+
.hero-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
| 112 |
+
|
| 113 |
+
/* ── BUTTONS ── */
|
| 114 |
+
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 9px 20px; border-radius: 8px; font-size: .85rem; font-weight: 600; cursor: pointer; border: 1px solid transparent; transition: all .2s; font-family: inherit; text-decoration: none; }
|
| 115 |
+
.btn-blue { background: rgba(79,142,247,.18); color: var(--accent); border-color: rgba(79,142,247,.35); }
|
| 116 |
+
.btn-blue:hover { background: rgba(79,142,247,.3); transform: translateY(-2px); }
|
| 117 |
+
.btn-gold { background: rgba(245,158,11,.15); color: var(--gold); border-color: rgba(245,158,11,.35); }
|
| 118 |
+
.btn-gold:hover { background: rgba(245,158,11,.28); transform: translateY(-2px); }
|
| 119 |
+
.btn-gray { background: var(--glass); color: var(--text); border-color: var(--glass-border); }
|
| 120 |
+
.btn-gray:hover { background: var(--card-hover-bg); transform: translateY(-2px); }
|
| 121 |
+
.btn-back { background: var(--glass); color: var(--muted); border-color: var(--glass-border); }
|
| 122 |
+
.btn-back:hover { color: var(--accent); border-color: var(--card-hover-border); transform: translateY(-2px); }
|
| 123 |
+
|
| 124 |
+
/* ── STATS BAR ── */
|
| 125 |
+
.stats-bar { background: var(--section-alt); border-top: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border); transition: background .35s; }
|
| 126 |
+
.stats-inner { max-width: 1100px; margin: 0 auto; display: grid; grid-template-columns: repeat(5,1fr); gap: 1px; background: var(--glass-border); }
|
| 127 |
+
.stat-item { background: var(--section-alt); padding: 22px 16px; text-align: center; transition: background .35s; }
|
| 128 |
+
.stat-val { font-size: 1.8rem; font-weight: 900; background: linear-gradient(135deg,var(--accent),var(--gold)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; line-height: 1.1; margin-bottom: 4px; }
|
| 129 |
+
.stat-label { font-size: .75rem; color: var(--muted); line-height: 1.4; }
|
| 130 |
+
|
| 131 |
+
/* ── MAIN LAYOUT ── */
|
| 132 |
+
.main-layout { max-width: 1100px; margin: 0 auto; padding: 48px 24px; display: grid; grid-template-columns: 1fr 310px; gap: 32px; align-items: start; }
|
| 133 |
+
.content-col { display: flex; flex-direction: column; gap: 28px; }
|
| 134 |
+
.sidebar { position: sticky; top: 80px; display: flex; flex-direction: column; gap: 20px; }
|
| 135 |
+
|
| 136 |
+
/* ── CARDS ── */
|
| 137 |
+
.card { background: var(--glass); border: 1px solid var(--glass-border); border-radius: var(--radius); padding: 28px; transition: all .25s; }
|
| 138 |
+
.card:hover { background: var(--card-hover-bg); border-color: var(--card-hover-border); transform: translateY(-3px); }
|
| 139 |
+
.card-title { font-size: 1rem; font-weight: 800; margin-bottom: 18px; color: var(--text); display: flex; align-items: center; gap: 10px; }
|
| 140 |
+
.card-title i { color: var(--accent); font-size: .9rem; }
|
| 141 |
+
.narrative { font-size: .92rem; color: var(--muted); margin-bottom: 10px; line-height: 1.7; }
|
| 142 |
+
.narrative strong { color: var(--text); }
|
| 143 |
+
|
| 144 |
+
/* ── PIPELINE ── */
|
| 145 |
+
.pipeline { display: flex; align-items: stretch; gap: 0; margin: 20px 0; overflow-x: auto; padding-bottom: 4px; }
|
| 146 |
+
.pipe-step { flex: 1; min-width: 120px; background: var(--glass); border: 1px solid var(--glass-border); border-radius: 10px; padding: 16px 10px; text-align: center; transition: .25s; }
|
| 147 |
+
.pipe-step:hover { background: var(--card-hover-bg); border-color: var(--card-hover-border); transform: translateY(-3px); }
|
| 148 |
+
.pipe-arrow { display: flex; align-items: center; justify-content: center; width: 28px; flex-shrink: 0; color: var(--muted); font-size: .8rem; padding-top: 10px; }
|
| 149 |
+
.pipe-icon { font-size: 1.8rem; margin-bottom: 8px; line-height: 1; }
|
| 150 |
+
.pipe-label { font-size: .75rem; font-weight: 700; color: var(--text); margin-bottom: 4px; }
|
| 151 |
+
.pipe-sub { font-size: .7rem; color: var(--muted); line-height: 1.4; }
|
| 152 |
+
|
| 153 |
+
/* ── MODULE GRID ── */
|
| 154 |
+
.module-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 16px 0; }
|
| 155 |
+
.mod-card { border-radius: 12px; padding: 20px; border: 1px solid; transition: .25s; }
|
| 156 |
+
.mod-card:hover { transform: translateY(-3px); }
|
| 157 |
+
/* ▶ Add your own color classes here like .mod-1 { background: rgba(79,142,247,.05); border-color: rgba(79,142,247,.2); } */
|
| 158 |
+
.mod-1 { background: rgba(79,142,247,.05); border-color: rgba(79,142,247,.2); }
|
| 159 |
+
.mod-2 { background: rgba(239,68,68,.05); border-color: rgba(239,68,68,.18); }
|
| 160 |
+
.mod-3 { background: rgba(245,158,11,.05); border-color: rgba(245,158,11,.18); }
|
| 161 |
+
.mod-4 { background: rgba(6,182,212,.05); border-color: rgba(6,182,212,.18); }
|
| 162 |
+
.mod-5 { background: rgba(167,139,250,.05); border-color: rgba(167,139,250,.2); }
|
| 163 |
+
.mod-6 { background: rgba(34,197,94,.05); border-color: rgba(34,197,94,.18); }
|
| 164 |
+
.mod-badge { display: inline-flex; align-items: center; gap: 6px; font-size: .72rem; font-weight: 700; padding: 3px 10px; border-radius: 8px; margin-bottom: 8px; }
|
| 165 |
+
.mod-name { font-size: .93rem; font-weight: 800; margin-bottom: 5px; color: var(--text); }
|
| 166 |
+
.mod-desc { font-size: .77rem; color: var(--muted); line-height: 1.5; margin-bottom: 10px; }
|
| 167 |
+
.mod-detail { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; border-bottom: 1px solid var(--glass-border); font-size: .77rem; }
|
| 168 |
+
.mod-detail:last-child { border-bottom: none; }
|
| 169 |
+
.mod-detail-key { color: var(--muted); }
|
| 170 |
+
|
| 171 |
+
/* ── INSIGHT BANNER ── */
|
| 172 |
+
.insight-banner { background: linear-gradient(135deg,rgba(79,142,247,.07),rgba(245,158,11,.07)); border: 1px solid rgba(79,142,247,.22); border-radius: var(--radius); padding: 22px; margin-top: 8px; display: flex; gap: 16px; align-items: flex-start; }
|
| 173 |
+
.insight-icon { font-size: 2rem; flex-shrink: 0; }
|
| 174 |
+
.insight-body h4 { font-size: .95rem; font-weight: 800; color: var(--text); margin-bottom: 5px; }
|
| 175 |
+
.insight-body p { font-size: .85rem; color: var(--muted); line-height: 1.6; }
|
| 176 |
+
.insight-body strong { color: var(--accent); }
|
| 177 |
+
|
| 178 |
+
/* ── ITEM STACK (reusable list of rows) ── */
|
| 179 |
+
.item-stack { display: flex; flex-direction: column; gap: 8px; margin: 14px 0; }
|
| 180 |
+
.item-row { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--glass); border: 1px solid var(--glass-border); border-radius: 8px; font-size: .82rem; transition: .2s; }
|
| 181 |
+
.item-row:hover { background: var(--card-hover-bg); }
|
| 182 |
+
.item-icon { width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: .9rem; flex-shrink: 0; }
|
| 183 |
+
.item-name { color: var(--text); font-weight: 600; flex: 1; }
|
| 184 |
+
.item-sub { font-size: .72rem; color: var(--muted); }
|
| 185 |
+
.item-tag { font-size: .7rem; padding: 2px 8px; border-radius: 6px; font-weight: 700; white-space: nowrap; }
|
| 186 |
+
.tag-blue { background: rgba(79,142,247,.15); color: var(--accent); border: 1px solid rgba(79,142,247,.3); }
|
| 187 |
+
.tag-red { background: rgba(239,68,68,.15); color: #f87171; border: 1px solid rgba(239,68,68,.3); }
|
| 188 |
+
.tag-green{ background: rgba(34,197,94,.15); color: var(--green); border: 1px solid rgba(34,197,94,.3); }
|
| 189 |
+
.tag-gold { background: rgba(245,158,11,.15); color: var(--gold); border: 1px solid rgba(245,158,11,.3); }
|
| 190 |
+
|
| 191 |
+
/* ── DEMO / INTERACTIVE BLOCK ── */
|
| 192 |
+
.demo-block { background: rgba(79,142,247,.04); border: 1px solid rgba(79,142,247,.15); border-radius: var(--radius); padding: 28px; }
|
| 193 |
+
.demo-intro { font-size: .85rem; color: var(--muted); margin-bottom: 18px; font-style: italic; }
|
| 194 |
+
.scenario-tabs { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
| 195 |
+
.scen-btn { padding: 7px 16px; border-radius: 20px; font-size: .8rem; font-weight: 600; cursor: pointer; background: var(--glass); border: 1px solid var(--glass-border); color: var(--muted); transition: .2s; font-family: inherit; }
|
| 196 |
+
.scen-btn.active, .scen-btn:hover { background: rgba(79,142,247,.15); border-color: rgba(79,142,247,.35); color: var(--accent); }
|
| 197 |
+
.result-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; margin-bottom: 14px; }
|
| 198 |
+
.res-card { background: var(--glass); border: 1px solid var(--glass-border); border-radius: 10px; padding: 14px; text-align: center; transition: .2s; }
|
| 199 |
+
.res-card:hover { background: var(--card-hover-bg); transform: translateY(-2px); }
|
| 200 |
+
.res-label { font-size: .68rem; color: var(--muted); text-transform: uppercase; letter-spacing: .07em; margin-bottom: 4px; }
|
| 201 |
+
.res-val { font-size: 1.4rem; font-weight: 900; line-height: 1.1; }
|
| 202 |
+
.res-sub { font-size: .72rem; color: var(--muted); margin-top: 2px; }
|
| 203 |
+
.risk-bar-wrap { margin: 14px 0; }
|
| 204 |
+
.risk-bar-label { display: flex; justify-content: space-between; font-size: .8rem; margin-bottom: 5px; }
|
| 205 |
+
.risk-bar-track { height: 10px; border-radius: 5px; background: var(--glass); overflow: hidden; }
|
| 206 |
+
.risk-bar-fill { height: 100%; border-radius: 5px; transition: width .7s ease; }
|
| 207 |
+
.demo-note { font-size: .73rem; color: var(--muted); font-style: italic; margin-top: 14px; text-align: center; }
|
| 208 |
+
|
| 209 |
+
/* ─��� CHART TABS ── */
|
| 210 |
+
.chart-tabs { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
| 211 |
+
.chart-tab { padding: 7px 14px; border-radius: 20px; font-size: .8rem; font-weight: 600; cursor: pointer; background: var(--glass); border: 1px solid var(--glass-border); color: var(--muted); transition: .2s; }
|
| 212 |
+
.chart-tab.active { background: rgba(79,142,247,.15); border-color: rgba(79,142,247,.35); color: var(--accent); }
|
| 213 |
+
.chart-panel { display: none; }
|
| 214 |
+
.chart-panel.active { display: block; }
|
| 215 |
+
.chart-wrap { position: relative; height: 280px; }
|
| 216 |
+
.chart-caption { font-size: .8rem; color: var(--muted); margin-top: 10px; font-style: italic; text-align: center; }
|
| 217 |
+
|
| 218 |
+
/* ── TAKEAWAYS / HIGHLIGHTS ── */
|
| 219 |
+
.takeaway-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 16px; margin-top: 8px; }
|
| 220 |
+
.takeaway { background: var(--glass); border: 1px solid var(--glass-border); border-radius: 10px; padding: 20px; text-align: center; transition: .2s; }
|
| 221 |
+
.takeaway:hover { background: var(--card-hover-bg); transform: translateY(-3px); }
|
| 222 |
+
.tk-icon { font-size: 2rem; margin-bottom: 8px; }
|
| 223 |
+
.tk-val { font-size: 1.2rem; font-weight: 900; background: linear-gradient(135deg,var(--accent),var(--gold)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; margin-bottom: 4px; }
|
| 224 |
+
.tk-label { font-size: .78rem; color: var(--muted); line-height: 1.45; }
|
| 225 |
+
|
| 226 |
+
/* ── SIDEBAR ── */
|
| 227 |
+
.sidebar-card { background: var(--glass); border: 1px solid var(--glass-border); border-radius: var(--radius); padding: 20px; }
|
| 228 |
+
.sidebar-card h3 { font-size: .82rem; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 14px; }
|
| 229 |
+
.tldr-text { font-size: .87rem; color: var(--muted); line-height: 1.7; }
|
| 230 |
+
.tldr-text strong { color: var(--text); }
|
| 231 |
+
.info-row { display: flex; justify-content: space-between; align-items: flex-start; padding: 8px 0; border-bottom: 1px solid var(--glass-border); font-size: .82rem; gap: 8px; }
|
| 232 |
+
.info-row:last-child { border-bottom: none; }
|
| 233 |
+
.info-key { color: var(--muted); flex-shrink: 0; }
|
| 234 |
+
.info-val { color: var(--text); font-weight: 600; text-align: right; font-size: .79rem; }
|
| 235 |
+
.tech-pills { display: flex; flex-wrap: wrap; gap: 6px; }
|
| 236 |
+
.tech-pill { background: rgba(79,142,247,.1); border: 1px solid rgba(79,142,247,.2); border-radius: 6px; padding: 3px 10px; font-size: .75rem; color: var(--accent); font-weight: 600; }
|
| 237 |
+
[data-theme="light"] .tech-pill { background: rgba(37,99,235,.08); border-color: rgba(37,99,235,.2); }
|
| 238 |
+
.sidebar-links { display: flex; flex-direction: column; gap: 8px; }
|
| 239 |
+
.sidebar-link { display: flex; align-items: center; gap: 10px; padding: 9px 12px; background: var(--glass); border: 1px solid var(--glass-border); border-radius: 8px; font-size: .82rem; color: var(--muted); transition: .2s; text-decoration: none; }
|
| 240 |
+
.sidebar-link:hover { background: var(--card-hover-bg); border-color: var(--card-hover-border); color: var(--text); }
|
| 241 |
+
.sidebar-link i { color: var(--accent); width: 16px; text-align: center; }
|
| 242 |
+
.hf-btn { display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: linear-gradient(135deg,rgba(255,175,7,.12),rgba(255,175,7,.06)); border: 1px solid rgba(255,175,7,.3); border-radius: 10px; font-size: .85rem; font-weight: 700; color: #f59e0b; transition: .2s; text-decoration: none; }
|
| 243 |
+
.hf-btn:hover { background: linear-gradient(135deg,rgba(255,175,7,.2),rgba(255,175,7,.1)); transform: translateY(-2px); }
|
| 244 |
+
|
| 245 |
+
/* ── RESPONSIVE ── */
|
| 246 |
+
@media (max-width: 1000px) {
|
| 247 |
+
.main-layout { grid-template-columns: 1fr; }
|
| 248 |
+
.sidebar { position: static; }
|
| 249 |
+
.module-grid { grid-template-columns: 1fr 1fr; }
|
| 250 |
+
.takeaway-grid { grid-template-columns: 1fr 1fr; }
|
| 251 |
+
.stats-inner { grid-template-columns: repeat(3,1fr); }
|
| 252 |
+
.result-grid { grid-template-columns: 1fr 1fr; }
|
| 253 |
+
}
|
| 254 |
+
@media (max-width: 600px) {
|
| 255 |
+
.hero { padding: 70px 16px 40px; }
|
| 256 |
+
.pipeline { flex-direction: column; }
|
| 257 |
+
.module-grid { grid-template-columns: 1fr; }
|
| 258 |
+
.takeaway-grid { grid-template-columns: 1fr; }
|
| 259 |
+
.stats-inner { grid-template-columns: repeat(2,1fr); }
|
| 260 |
+
.result-grid { grid-template-columns: 1fr; }
|
| 261 |
+
}
|
| 262 |
+
</style>
|
| 263 |
+
</head>
|
| 264 |
+
|
| 265 |
+
<body>
|
| 266 |
+
|
| 267 |
+
<!-- ═══════════════════════════════════════════
|
| 268 |
+
HERO SECTION
|
| 269 |
+
Replace all [PLACEHOLDER] text below.
|
| 270 |
+
════════════════════════════════════════════ -->
|
| 271 |
+
<section class="hero">
|
| 272 |
+
<div class="hero-inner">
|
| 273 |
+
|
| 274 |
+
<!-- Breadcrumb navigation -->
|
| 275 |
+
<div class="breadcrumb">
|
| 276 |
+
<a href="/index.html"><i class="fas fa-home"></i> Home</a>
|
| 277 |
+
<span>›</span>
|
| 278 |
+
<a href="/projects/index.html">Projects</a>
|
| 279 |
+
<span>›</span>
|
| 280 |
+
<span style="color:var(--text)">[PROJECT NAME]</span>
|
| 281 |
+
</div>
|
| 282 |
+
|
| 283 |
+
<!-- Category pills: edit icon, text for each pill -->
|
| 284 |
+
<div class="tag-row">
|
| 285 |
+
<span class="pill pill-blue"><i class="fas fa-tag"></i> [DOMAIN / CATEGORY]</span>
|
| 286 |
+
<span class="pill pill-teal"><i class="fab fa-python"></i> [TECH STACK SUMMARY]</span>
|
| 287 |
+
<span class="pill pill-gold"><i class="fas fa-rocket"></i> [STATUS / PLATFORM]</span>
|
| 288 |
+
</div>
|
| 289 |
+
|
| 290 |
+
<!-- Main title: wrap gradient words in <span class="grad-text"> -->
|
| 291 |
+
<h1>[PROJECT NAME] — <span class="grad-text">[SUBTITLE OR TAGLINE]</span></h1>
|
| 292 |
+
|
| 293 |
+
<!-- One or two sentence description -->
|
| 294 |
+
<p class="hero-sub">
|
| 295 |
+
[BRIEF DESCRIPTION OF WHAT THE PROJECT IS AND WHAT IT DOES.]
|
| 296 |
+
<strong>[KEY CAPABILITY OR DIFFERENTIATOR.]</strong>
|
| 297 |
+
</p>
|
| 298 |
+
|
| 299 |
+
<!-- Meta info chips -->
|
| 300 |
+
<div class="hero-meta">
|
| 301 |
+
<span><i class="fas fa-calendar-alt"></i> [YEAR / DATE]</span>
|
| 302 |
+
<span><i class="fas fa-user"></i> <strong>[YOUR NAME]</strong></span>
|
| 303 |
+
<span><i class="fas fa-database"></i> [DATASET SIZE / TYPE]</span>
|
| 304 |
+
<span><i class="fas fa-brain"></i> [MODEL COUNT / TYPE]</span>
|
| 305 |
+
</div>
|
| 306 |
+
|
| 307 |
+
<!-- CTA buttons — update hrefs -->
|
| 308 |
+
<div class="hero-actions">
|
| 309 |
+
<a href="#demo" class="btn btn-blue"><i class="fas fa-play-circle"></i> Explore Demo</a>
|
| 310 |
+
<a href="https://huggingface.co/spaces/[YOUR-HF-USERNAME]/[SPACE-NAME]" target="_blank" class="btn btn-gold">
|
| 311 |
+
<i class="fas fa-external-link-alt"></i> Try on HuggingFace
|
| 312 |
+
</a>
|
| 313 |
+
<a href="https://github.com/[YOUR-GITHUB-USERNAME]/[REPO-NAME]" target="_blank" class="btn btn-gray">
|
| 314 |
+
<i class="fab fa-github"></i> View on GitHub
|
| 315 |
+
</a>
|
| 316 |
+
<a href="/projects/index.html" class="btn btn-back"><i class="fas fa-arrow-left"></i> All Projects</a>
|
| 317 |
+
</div>
|
| 318 |
+
|
| 319 |
+
</div>
|
| 320 |
+
</section>
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
<!-- ═══════════════════════════════════════════
|
| 324 |
+
STATS BAR — 5 key numbers/facts
|
| 325 |
+
════════════════════════════════════════════ -->
|
| 326 |
+
<div class="stats-bar">
|
| 327 |
+
<div class="stats-inner">
|
| 328 |
+
<!-- Replace [VALUE] and [DESCRIPTION] for each stat -->
|
| 329 |
+
<div class="stat-item">
|
| 330 |
+
<div class="stat-val">[VALUE 1]</div>
|
| 331 |
+
<div class="stat-label">[Description of stat 1]</div>
|
| 332 |
+
</div>
|
| 333 |
+
<div class="stat-item">
|
| 334 |
+
<div class="stat-val">[VALUE 2]</div>
|
| 335 |
+
<div class="stat-label">[Description of stat 2]</div>
|
| 336 |
+
</div>
|
| 337 |
+
<div class="stat-item">
|
| 338 |
+
<div class="stat-val">[VALUE 3]</div>
|
| 339 |
+
<div class="stat-label">[Description of stat 3]</div>
|
| 340 |
+
</div>
|
| 341 |
+
<div class="stat-item">
|
| 342 |
+
<div class="stat-val">[VALUE 4]</div>
|
| 343 |
+
<div class="stat-label">[Description of stat 4]</div>
|
| 344 |
+
</div>
|
| 345 |
+
<div class="stat-item">
|
| 346 |
+
<div class="stat-val">[VALUE 5]</div>
|
| 347 |
+
<div class="stat-label">[Description of stat 5]</div>
|
| 348 |
+
</div>
|
| 349 |
+
</div>
|
| 350 |
+
</div>
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
<!-- ═══════════════════════════════════════════
|
| 354 |
+
MAIN LAYOUT: content column + sidebar
|
| 355 |
+
════════════════════════════════════════════ -->
|
| 356 |
+
<div class="main-layout">
|
| 357 |
+
<div class="content-col">
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
<!-- ─────────────────────────────────────────
|
| 361 |
+
CARD 1: Architecture / Pipeline
|
| 362 |
+
──────────────────────────────────────────── -->
|
| 363 |
+
<div class="card">
|
| 364 |
+
<div class="s-tag s-tag-blue">Architecture Overview</div>
|
| 365 |
+
<h2 class="card-title"><i class="fas fa-route"></i> [PIPELINE / ARCHITECTURE TITLE]</h2>
|
| 366 |
+
|
| 367 |
+
<!-- Narrative paragraph -->
|
| 368 |
+
<p class="narrative">
|
| 369 |
+
[Describe your system architecture and data flow here.
|
| 370 |
+
<strong>Highlight the key design decisions in bold.</strong>
|
| 371 |
+
Keep it 3–5 sentences.]
|
| 372 |
+
</p>
|
| 373 |
+
|
| 374 |
+
<!-- Pipeline steps — add/remove steps as needed -->
|
| 375 |
+
<div class="pipeline">
|
| 376 |
+
<div class="pipe-step">
|
| 377 |
+
<div class="pipe-icon">🗄️</div>
|
| 378 |
+
<div class="pipe-label">[Step 1 Name]</div>
|
| 379 |
+
<div class="pipe-sub">[Brief description]</div>
|
| 380 |
+
</div>
|
| 381 |
+
<div class="pipe-arrow"><i class="fas fa-chevron-right"></i></div>
|
| 382 |
+
<div class="pipe-step">
|
| 383 |
+
<div class="pipe-icon">🧠</div>
|
| 384 |
+
<div class="pipe-label">[Step 2 Name]</div>
|
| 385 |
+
<div class="pipe-sub">[Brief description]</div>
|
| 386 |
+
</div>
|
| 387 |
+
<div class="pipe-arrow"><i class="fas fa-chevron-right"></i></div>
|
| 388 |
+
<div class="pipe-step">
|
| 389 |
+
<div class="pipe-icon">📊</div>
|
| 390 |
+
<div class="pipe-label">[Step 3 Name]</div>
|
| 391 |
+
<div class="pipe-sub">[Brief description]</div>
|
| 392 |
+
</div>
|
| 393 |
+
<div class="pipe-arrow"><i class="fas fa-chevron-right"></i></div>
|
| 394 |
+
<div class="pipe-step">
|
| 395 |
+
<div class="pipe-icon">🌐</div>
|
| 396 |
+
<div class="pipe-label">[Step 4 Name]</div>
|
| 397 |
+
<div class="pipe-sub">[Brief description]</div>
|
| 398 |
+
</div>
|
| 399 |
+
<div class="pipe-arrow"><i class="fas fa-chevron-right"></i></div>
|
| 400 |
+
<div class="pipe-step">
|
| 401 |
+
<div class="pipe-icon">🚀</div>
|
| 402 |
+
<div class="pipe-label">[Step 5 Name]</div>
|
| 403 |
+
<div class="pipe-sub">[Brief description]</div>
|
| 404 |
+
</div>
|
| 405 |
+
</div>
|
| 406 |
+
|
| 407 |
+
<!-- Insight banner — a highlighted callout box -->
|
| 408 |
+
<div class="insight-banner">
|
| 409 |
+
<div class="insight-icon">💡</div>
|
| 410 |
+
<div class="insight-body">
|
| 411 |
+
<h4>[Callout / Insight Title]</h4>
|
| 412 |
+
<p>[Explain a key insight, design choice, or interesting fact about your architecture.
|
| 413 |
+
<strong>Highlight the most important part.</strong>]</p>
|
| 414 |
+
</div>
|
| 415 |
+
</div>
|
| 416 |
+
</div>
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
<!-- ─────────────────────────────────────────
|
| 420 |
+
CARD 2: Module / Feature Breakdown
|
| 421 |
+
6-card grid — remove cards if not needed
|
| 422 |
+
──────────────────────────────────────────── -->
|
| 423 |
+
<div class="card">
|
| 424 |
+
<div class="s-tag s-tag-teal">Module Breakdown</div>
|
| 425 |
+
<h2 class="card-title"><i class="fas fa-layer-group"></i> [MODULES / FEATURES TITLE]</h2>
|
| 426 |
+
|
| 427 |
+
<div class="module-grid">
|
| 428 |
+
|
| 429 |
+
<!-- Module 1 -->
|
| 430 |
+
<div class="mod-card mod-1">
|
| 431 |
+
<div class="mod-badge" style="background:rgba(79,142,247,.12);color:var(--accent);border:1px solid rgba(79,142,247,.22)">
|
| 432 |
+
[EMOJI] [Badge Label]
|
| 433 |
+
</div>
|
| 434 |
+
<div class="mod-name">[Module 1 Name]</div>
|
| 435 |
+
<div class="mod-desc">[Short description of what this module does — 2 to 3 sentences.]</div>
|
| 436 |
+
<div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="color:var(--accent);font-weight:700">[Value]</span></div>
|
| 437 |
+
<div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="font-weight:700">[Value]</span></div>
|
| 438 |
+
</div>
|
| 439 |
+
|
| 440 |
+
<!-- Module 2 -->
|
| 441 |
+
<div class="mod-card mod-2">
|
| 442 |
+
<div class="mod-badge" style="background:rgba(239,68,68,.12);color:#f87171;border:1px solid rgba(239,68,68,.22)">
|
| 443 |
+
[EMOJI] [Badge Label]
|
| 444 |
+
</div>
|
| 445 |
+
<div class="mod-name">[Module 2 Name]</div>
|
| 446 |
+
<div class="mod-desc">[Short description of what this module does — 2 to 3 sentences.]</div>
|
| 447 |
+
<div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="color:#f87171;font-weight:700">[Value]</span></div>
|
| 448 |
+
<div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="font-weight:700">[Value]</span></div>
|
| 449 |
+
</div>
|
| 450 |
+
|
| 451 |
+
<!-- Module 3 -->
|
| 452 |
+
<div class="mod-card mod-3">
|
| 453 |
+
<div class="mod-badge" style="background:rgba(245,158,11,.12);color:var(--gold);border:1px solid rgba(245,158,11,.22)">
|
| 454 |
+
[EMOJI] [Badge Label]
|
| 455 |
+
</div>
|
| 456 |
+
<div class="mod-name">[Module 3 Name]</div>
|
| 457 |
+
<div class="mod-desc">[Short description of what this module does — 2 to 3 sentences.]</div>
|
| 458 |
+
<div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="color:var(--gold);font-weight:700">[Value]</span></div>
|
| 459 |
+
<div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="font-weight:700">[Value]</span></div>
|
| 460 |
+
</div>
|
| 461 |
+
|
| 462 |
+
<!-- Module 4 -->
|
| 463 |
+
<div class="mod-card mod-4">
|
| 464 |
+
<div class="mod-badge" style="background:rgba(6,182,212,.12);color:var(--teal);border:1px solid rgba(6,182,212,.22)">
|
| 465 |
+
[EMOJI] [Badge Label]
|
| 466 |
+
</div>
|
| 467 |
+
<div class="mod-name">[Module 4 Name]</div>
|
| 468 |
+
<div class="mod-desc">[Short description of what this module does — 2 to 3 sentences.]</div>
|
| 469 |
+
<div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="color:var(--teal);font-weight:700">[Value]</span></div>
|
| 470 |
+
<div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="font-weight:700">[Value]</span></div>
|
| 471 |
+
</div>
|
| 472 |
+
|
| 473 |
+
<!-- Module 5 -->
|
| 474 |
+
<div class="mod-card mod-5">
|
| 475 |
+
<div class="mod-badge" style="background:rgba(167,139,250,.12);color:#a78bfa;border:1px solid rgba(167,139,250,.22)">
|
| 476 |
+
[EMOJI] [Badge Label]
|
| 477 |
+
</div>
|
| 478 |
+
<div class="mod-name">[Module 5 Name]</div>
|
| 479 |
+
<div class="mod-desc">[Short description of what this module does — 2 to 3 sentences.]</div>
|
| 480 |
+
<div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="color:#a78bfa;font-weight:700">[Value]</span></div>
|
| 481 |
+
<div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="font-weight:700">[Value]</span></div>
|
| 482 |
+
</div>
|
| 483 |
+
|
| 484 |
+
<!-- Module 6 -->
|
| 485 |
+
<div class="mod-card mod-6">
|
| 486 |
+
<div class="mod-badge" style="background:rgba(34,197,94,.12);color:var(--green);border:1px solid rgba(34,197,94,.22)">
|
| 487 |
+
[EMOJI] [Badge Label]
|
| 488 |
+
</div>
|
| 489 |
+
<div class="mod-name">[Module 6 Name]</div>
|
| 490 |
+
<div class="mod-desc">[Short description of what this module does — 2 to 3 sentences.]</div>
|
| 491 |
+
<div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="color:var(--green);font-weight:700">[Value]</span></div>
|
| 492 |
+
<div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="font-weight:700">[Value]</span></div>
|
| 493 |
+
</div>
|
| 494 |
+
|
| 495 |
+
</div>
|
| 496 |
+
</div>
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
<!-- ─────────────────────────────────────────
|
| 500 |
+
CARD 3: Tech / Model Stack (row list)
|
| 501 |
+
──────────────────────────────────────────── -->
|
| 502 |
+
<div class="card">
|
| 503 |
+
<div class="s-tag s-tag-blue">[Stack / Method Section Label]</div>
|
| 504 |
+
<h2 class="card-title"><i class="fas fa-brain"></i> [STACK / MODELS TITLE]</h2>
|
| 505 |
+
<p class="narrative">[Describe your models or technical stack in 2–3 sentences. <strong>Explain why these choices were made.</strong>]</p>
|
| 506 |
+
|
| 507 |
+
<div class="item-stack">
|
| 508 |
+
|
| 509 |
+
<!-- Item row 1 -->
|
| 510 |
+
<div class="item-row">
|
| 511 |
+
<div class="item-icon" style="background:rgba(79,142,247,.15);color:var(--accent)">
|
| 512 |
+
<i class="fas fa-cube"></i>
|
| 513 |
+
</div>
|
| 514 |
+
<div>
|
| 515 |
+
<div class="item-name">[Model / Component 1 Name]</div>
|
| 516 |
+
<div class="item-sub">[Features or details]</div>
|
| 517 |
+
</div>
|
| 518 |
+
<div class="item-tag tag-blue">[Tag label]</div>
|
| 519 |
+
</div>
|
| 520 |
+
|
| 521 |
+
<!-- Item row 2 -->
|
| 522 |
+
<div class="item-row">
|
| 523 |
+
<div class="item-icon" style="background:rgba(239,68,68,.15);color:#f87171">
|
| 524 |
+
<i class="fas fa-cube"></i>
|
| 525 |
+
</div>
|
| 526 |
+
<div>
|
| 527 |
+
<div class="item-name">[Model / Component 2 Name]</div>
|
| 528 |
+
<div class="item-sub">[Features or details]</div>
|
| 529 |
+
</div>
|
| 530 |
+
<div class="item-tag tag-red">[Tag label]</div>
|
| 531 |
+
</div>
|
| 532 |
+
|
| 533 |
+
<!-- Item row 3 -->
|
| 534 |
+
<div class="item-row">
|
| 535 |
+
<div class="item-icon" style="background:rgba(34,197,94,.15);color:var(--green)">
|
| 536 |
+
<i class="fas fa-cube"></i>
|
| 537 |
+
</div>
|
| 538 |
+
<div>
|
| 539 |
+
<div class="item-name">[Model / Component 3 Name]</div>
|
| 540 |
+
<div class="item-sub">[Features or details]</div>
|
| 541 |
+
</div>
|
| 542 |
+
<div class="item-tag tag-green">[Tag label]</div>
|
| 543 |
+
</div>
|
| 544 |
+
|
| 545 |
+
<!-- Item row 4 -->
|
| 546 |
+
<div class="item-row">
|
| 547 |
+
<div class="item-icon" style="background:rgba(245,158,11,.15);color:var(--gold)">
|
| 548 |
+
<i class="fas fa-cube"></i>
|
| 549 |
+
</div>
|
| 550 |
+
<div>
|
| 551 |
+
<div class="item-name">[Model / Component 4 Name]</div>
|
| 552 |
+
<div class="item-sub">[Features or details]</div>
|
| 553 |
+
</div>
|
| 554 |
+
<div class="item-tag tag-gold">[Tag label]</div>
|
| 555 |
+
</div>
|
| 556 |
+
|
| 557 |
+
</div>
|
| 558 |
+
|
| 559 |
+
<!-- Second insight banner -->
|
| 560 |
+
<div class="insight-banner" style="margin-top:16px">
|
| 561 |
+
<div class="insight-icon">⚙️</div>
|
| 562 |
+
<div class="insight-body">
|
| 563 |
+
<h4>[Second Callout Title]</h4>
|
| 564 |
+
<p>[Additional insight about your stack or methodology.
|
| 565 |
+
<strong>Highlight the most important part here.</strong>]</p>
|
| 566 |
+
</div>
|
| 567 |
+
</div>
|
| 568 |
+
</div>
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
<!-- ─────────────────────────────────────────
|
| 572 |
+
CARD 4: Interactive Demo
|
| 573 |
+
id="demo" is linked by the hero button
|
| 574 |
+
──────────────────────────────────────────── -->
|
| 575 |
+
<div class="demo-block" id="demo">
|
| 576 |
+
<div class="s-tag s-tag-blue">Interactive Explorer</div>
|
| 577 |
+
<h2 class="card-title" style="margin-bottom:4px"><i class="fas fa-flask"></i> [DEMO SECTION TITLE]</h2>
|
| 578 |
+
<p class="demo-intro">[Brief intro sentence about what these tabs show — e.g. "Select a scenario to see representative outputs from the live model."]</p>
|
| 579 |
+
|
| 580 |
+
<!-- Tab buttons — update labels and onclick indices to match SCENARIOS array in JS -->
|
| 581 |
+
<div class="scenario-tabs" id="scenTabs">
|
| 582 |
+
<button class="scen-btn active" onclick="selectScen(0,this)">[Tab 1 Label]</button>
|
| 583 |
+
<button class="scen-btn" onclick="selectScen(1,this)">[Tab 2 Label]</button>
|
| 584 |
+
<button class="scen-btn" onclick="selectScen(2,this)">[Tab 3 Label]</button>
|
| 585 |
+
<button class="scen-btn" onclick="selectScen(3,this)">[Tab 4 Label]</button>
|
| 586 |
+
</div>
|
| 587 |
+
|
| 588 |
+
<div id="scenOutput"></div>
|
| 589 |
+
<p class="demo-note">[Disclaimer note, e.g. "Illustrative outputs based on synthetic data. Live app scores in real time."]</p>
|
| 590 |
+
</div>
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
<!-- ─────────────────────────────────────────
|
| 594 |
+
CARD 5: Charts (tabbed)
|
| 595 |
+
──────────────────────────────────────────── -->
|
| 596 |
+
<div class="card">
|
| 597 |
+
<div class="s-tag s-tag-blue">Performance Snapshot</div>
|
| 598 |
+
<h2 class="card-title"><i class="fas fa-chart-bar"></i> [CHARTS SECTION TITLE]</h2>
|
| 599 |
+
|
| 600 |
+
<!-- Tab labels — update to match your charts -->
|
| 601 |
+
<div class="chart-tabs">
|
| 602 |
+
<div class="chart-tab active" onclick="switchTab(0,this)">[Chart 1 Tab Label]</div>
|
| 603 |
+
<div class="chart-tab" onclick="switchTab(1,this)">[Chart 2 Tab Label]</div>
|
| 604 |
+
<div class="chart-tab" onclick="switchTab(2,this)">[Chart 3 Tab Label]</div>
|
| 605 |
+
</div>
|
| 606 |
+
|
| 607 |
+
<div class="chart-panel active" id="cp0">
|
| 608 |
+
<div class="chart-wrap"><canvas id="chart0"></canvas></div>
|
| 609 |
+
<p class="chart-caption">[Caption for chart 1 — explain what the data shows and what insight to take away.]</p>
|
| 610 |
+
</div>
|
| 611 |
+
<div class="chart-panel" id="cp1">
|
| 612 |
+
<div class="chart-wrap"><canvas id="chart1"></canvas></div>
|
| 613 |
+
<p class="chart-caption">[Caption for chart 2.]</p>
|
| 614 |
+
</div>
|
| 615 |
+
<div class="chart-panel" id="cp2">
|
| 616 |
+
<div class="chart-wrap"><canvas id="chart2"></canvas></div>
|
| 617 |
+
<p class="chart-caption">[Caption for chart 3.]</p>
|
| 618 |
+
</div>
|
| 619 |
+
</div>
|
| 620 |
+
|
| 621 |
+
|
| 622 |
+
<!-- ─────────────────────────────────────────
|
| 623 |
+
CARD 6: Key Design Decisions / Takeaways
|
| 624 |
+
3-column grid of highlight boxes
|
| 625 |
+
──────────────────────────────────────────── -->
|
| 626 |
+
<div class="card">
|
| 627 |
+
<div class="s-tag s-tag-gold">Design Decisions</div>
|
| 628 |
+
<h2 class="card-title"><i class="fas fa-lightbulb"></i> [DECISIONS / HIGHLIGHTS TITLE]</h2>
|
| 629 |
+
<div class="takeaway-grid">
|
| 630 |
+
|
| 631 |
+
<div class="takeaway">
|
| 632 |
+
<div class="tk-icon">[EMOJI]</div>
|
| 633 |
+
<div class="tk-val">[Short headline]</div>
|
| 634 |
+
<div class="tk-label">[Explanation of this design choice — 2 to 3 sentences.]</div>
|
| 635 |
+
</div>
|
| 636 |
+
|
| 637 |
+
<div class="takeaway">
|
| 638 |
+
<div class="tk-icon">[EMOJI]</div>
|
| 639 |
+
<div class="tk-val">[Short headline]</div>
|
| 640 |
+
<div class="tk-label">[Explanation of this design choice — 2 to 3 sentences.]</div>
|
| 641 |
+
</div>
|
| 642 |
+
|
| 643 |
+
<div class="takeaway">
|
| 644 |
+
<div class="tk-icon">[EMOJI]</div>
|
| 645 |
+
<div class="tk-val">[Short headline]</div>
|
| 646 |
+
<div class="tk-label">[Explanation of this design choice — 2 to 3 sentences.]</div>
|
| 647 |
+
</div>
|
| 648 |
+
|
| 649 |
+
</div>
|
| 650 |
+
</div>
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
</div><!-- /content-col -->
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
<!-- ═══════════════════════════════════════════
|
| 657 |
+
SIDEBAR
|
| 658 |
+
════════════════════════════════════════════ -->
|
| 659 |
+
<div class="sidebar">
|
| 660 |
+
|
| 661 |
+
<!-- At-a-Glance summary -->
|
| 662 |
+
<div class="sidebar-card">
|
| 663 |
+
<h3>At a Glance</h3>
|
| 664 |
+
<p class="tldr-text">
|
| 665 |
+
<strong>What it is:</strong> [One sentence summary.]
|
| 666 |
+
<strong>Tech:</strong> [Key tools/frameworks.]
|
| 667 |
+
<strong>Deploy:</strong> [Where it runs.]
|
| 668 |
+
<strong>Scope:</strong> [What domains/features it covers.]
|
| 669 |
+
</p>
|
| 670 |
+
</div>
|
| 671 |
+
|
| 672 |
+
<!-- Live link button — update href -->
|
| 673 |
+
<div class="sidebar-card">
|
| 674 |
+
<h3>Try It Live</h3>
|
| 675 |
+
<a href="https://huggingface.co/spaces/[YOUR-HF-USERNAME]/[SPACE-NAME]" target="_blank" class="hf-btn">
|
| 676 |
+
<i class="fas fa-rocket"></i> Open on HuggingFace Spaces
|
| 677 |
+
</a>
|
| 678 |
+
</div>
|
| 679 |
+
|
| 680 |
+
<!-- Project metadata -->
|
| 681 |
+
<div class="sidebar-card">
|
| 682 |
+
<h3>Project Info</h3>
|
| 683 |
+
<div class="info-row"><span class="info-key">Status</span> <span class="info-val" style="color:var(--accent)">[🔵 Live / 🟡 In Progress]</span></div>
|
| 684 |
+
<div class="info-row"><span class="info-key">Type</span> <span class="info-val">[Academic / Industry / Personal]</span></div>
|
| 685 |
+
<div class="info-row"><span class="info-key">Domain</span> <span class="info-val">[Field / Sector]</span></div>
|
| 686 |
+
<div class="info-row"><span class="info-key">Backend</span> <span class="info-val">[Language · Framework]</span></div>
|
| 687 |
+
<div class="info-row"><span class="info-key">ML Models</span> <span class="info-val">[Model names]</span></div>
|
| 688 |
+
<div class="info-row"><span class="info-key">Visualization</span><span class="info-val">[Library used]</span></div>
|
| 689 |
+
<div class="info-row"><span class="info-key">Records</span> <span class="info-val">[Dataset size]</span></div>
|
| 690 |
+
<div class="info-row"><span class="info-key">Deploy target</span><span class="info-val">[Platform · Container · Port]</span></div>
|
| 691 |
+
<div class="info-row"><span class="info-key">Year</span> <span class="info-val">[YEAR]</span></div>
|
| 692 |
+
</div>
|
| 693 |
+
|
| 694 |
+
<!-- Tech stack pills -->
|
| 695 |
+
<div class="sidebar-card">
|
| 696 |
+
<h3>Tech Stack</h3>
|
| 697 |
+
<div class="tech-pills">
|
| 698 |
+
<!-- Add or remove pills as needed -->
|
| 699 |
+
<span class="tech-pill">[Tech 1]</span>
|
| 700 |
+
<span class="tech-pill">[Tech 2]</span>
|
| 701 |
+
<span class="tech-pill">[Tech 3]</span>
|
| 702 |
+
<span class="tech-pill">[Tech 4]</span>
|
| 703 |
+
<span class="tech-pill">[Tech 5]</span>
|
| 704 |
+
<span class="tech-pill">[Tech 6]</span>
|
| 705 |
+
</div>
|
| 706 |
+
</div>
|
| 707 |
+
|
| 708 |
+
<!-- Module / Feature links — update hrefs and labels -->
|
| 709 |
+
<div class="sidebar-card">
|
| 710 |
+
<h3>[Modules / Features]</h3>
|
| 711 |
+
<div class="sidebar-links">
|
| 712 |
+
<a href="#" class="sidebar-link"><i class="fas fa-link"></i> [Feature / Page 1]</a>
|
| 713 |
+
<a href="#" class="sidebar-link"><i class="fas fa-link"></i> [Feature / Page 2]</a>
|
| 714 |
+
<a href="#" class="sidebar-link"><i class="fas fa-link"></i> [Feature / Page 3]</a>
|
| 715 |
+
<a href="#" class="sidebar-link"><i class="fas fa-link"></i> [Feature / Page 4]</a>
|
| 716 |
+
<a href="#" class="sidebar-link"><i class="fas fa-link"></i> [Feature / Page 5]</a>
|
| 717 |
+
</div>
|
| 718 |
+
</div>
|
| 719 |
+
|
| 720 |
+
<!-- Related links -->
|
| 721 |
+
<div class="sidebar-card">
|
| 722 |
+
<h3>Related Work</h3>
|
| 723 |
+
<div class="sidebar-links">
|
| 724 |
+
<a href="https://github.com/[YOUR-GITHUB-USERNAME]/[REPO-NAME]" target="_blank" class="sidebar-link">
|
| 725 |
+
<i class="fab fa-github"></i> GitHub Repository
|
| 726 |
+
</a>
|
| 727 |
+
<a href="/projects/[OTHER-PROJECT].html" class="sidebar-link"><i class="fas fa-link"></i> [Related Project 1]</a>
|
| 728 |
+
<a href="/projects/[OTHER-PROJECT].html" class="sidebar-link"><i class="fas fa-link"></i> [Related Project 2]</a>
|
| 729 |
+
<a href="/index.html#publications" class="sidebar-link"><i class="fas fa-book"></i> All Publications</a>
|
| 730 |
+
<a href="/projects/index.html" class="sidebar-link"><i class="fas fa-th-large"></i> Back to Projects</a>
|
| 731 |
+
</div>
|
| 732 |
+
</div>
|
| 733 |
+
|
| 734 |
+
</div><!-- /sidebar -->
|
| 735 |
+
</div><!-- /main-layout -->
|
| 736 |
+
|
| 737 |
+
|
| 738 |
+
<!-- ═══════════════════════════════════════════
|
| 739 |
+
PAGE-SPECIFIC JAVASCRIPT
|
| 740 |
+
Edit SCENARIOS and chart data below.
|
| 741 |
+
════════════════════════════════════════════ -->
|
| 742 |
+
<script>
|
| 743 |
+
|
| 744 |
+
/* ─── THEME HELPERS (don't need to change these) ─── */
|
| 745 |
+
const html = document.documentElement;
|
| 746 |
+
function isDark(){ return html.getAttribute('data-theme') !== 'light'; }
|
| 747 |
+
function gc(){ return isDark() ? 'rgba(255,255,255,.05)' : 'rgba(0,0,0,.06)'; }
|
| 748 |
+
function tc(){ return isDark() ? '#8892a4' : '#4b5675'; }
|
| 749 |
+
function tt(){
|
| 750 |
+
return {
|
| 751 |
+
backgroundColor: isDark() ? 'rgba(7,13,31,.95)' : 'rgba(255,255,255,.97)',
|
| 752 |
+
titleColor: isDark() ? '#e2e8f0' : '#0f172a',
|
| 753 |
+
bodyColor: isDark() ? '#8892a4' : '#4b5675',
|
| 754 |
+
borderColor: isDark() ? 'rgba(79,142,247,.3)' : 'rgba(37,99,235,.2)',
|
| 755 |
+
borderWidth: 1
|
| 756 |
+
};
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
/* ─── SCENARIO DEMO DATA ───────────────────────────
|
| 761 |
+
Each object = one tab. Fill in your own values.
|
| 762 |
+
- title: shown above the metric cards
|
| 763 |
+
- metrics: array of 3 cards (label, value, sub-label, color)
|
| 764 |
+
- bar: a single progress bar (label, pct 0–100, color)
|
| 765 |
+
- insight: the text box below the bar
|
| 766 |
+
─────────────────────────────────────────────────── */
|
| 767 |
+
const SCENARIOS = [
|
| 768 |
+
{
|
| 769 |
+
title: '[EMOJI] [Scenario 1 Title]',
|
| 770 |
+
metrics: [
|
| 771 |
+
{ label: '[Metric Label 1]', val: '[VALUE]', sub: '[Sub-label]', color: '#ef4444' },
|
| 772 |
+
{ label: '[Metric Label 2]', val: '[VALUE]', sub: '[Sub-label]', color: '#f59e0b' },
|
| 773 |
+
{ label: '[Metric Label 3]', val: '[VALUE]', sub: '[Sub-label]', color: '#4f8ef7' }
|
| 774 |
+
],
|
| 775 |
+
bar: { label: '[Bar label]', pct: 50, color: '#ef4444' },
|
| 776 |
+
insight: '[Explanation of what the numbers mean for this scenario — 2 to 3 sentences.]'
|
| 777 |
+
},
|
| 778 |
+
{
|
| 779 |
+
title: '[EMOJI] [Scenario 2 Title]',
|
| 780 |
+
metrics: [
|
| 781 |
+
{ label: '[Metric Label 1]', val: '[VALUE]', sub: '[Sub-label]', color: '#ef4444' },
|
| 782 |
+
{ label: '[Metric Label 2]', val: '[VALUE]', sub: '[Sub-label]', color: '#f59e0b' },
|
| 783 |
+
{ label: '[Metric Label 3]', val: '[VALUE]', sub: '[Sub-label]', color: '#4f8ef7' }
|
| 784 |
+
],
|
| 785 |
+
bar: { label: '[Bar label]', pct: 30, color: '#f59e0b' },
|
| 786 |
+
insight: '[Explanation of what the numbers mean for this scenario — 2 to 3 sentences.]'
|
| 787 |
+
},
|
| 788 |
+
{
|
| 789 |
+
title: '[EMOJI] [Scenario 3 Title]',
|
| 790 |
+
metrics: [
|
| 791 |
+
{ label: '[Metric Label 1]', val: '[VALUE]', sub: '[Sub-label]', color: '#4f8ef7' },
|
| 792 |
+
{ label: '[Metric Label 2]', val: '[VALUE]', sub: '[Sub-label]', color: '#f59e0b' },
|
| 793 |
+
{ label: '[Metric Label 3]', val: '[VALUE]', sub: '[Sub-label]', color: '#22c55e' }
|
| 794 |
+
],
|
| 795 |
+
bar: { label: '[Bar label]', pct: 70, color: '#4f8ef7' },
|
| 796 |
+
insight: '[Explanation of what the numbers mean for this scenario — 2 to 3 sentences.]'
|
| 797 |
+
},
|
| 798 |
+
{
|
| 799 |
+
title: '[EMOJI] [Scenario 4 Title]',
|
| 800 |
+
metrics: [
|
| 801 |
+
{ label: '[Metric Label 1]', val: '[VALUE]', sub: '[Sub-label]', color: '#ef4444' },
|
| 802 |
+
{ label: '[Metric Label 2]', val: '[VALUE]', sub: '[Sub-label]', color: '#ef4444' },
|
| 803 |
+
{ label: '[Metric Label 3]', val: '[VALUE]', sub: '[Sub-label]', color: '#f59e0b' }
|
| 804 |
+
],
|
| 805 |
+
bar: { label: '[Bar label]', pct: 45, color: '#ef4444' },
|
| 806 |
+
insight: '[Explanation of what the numbers mean for this scenario — 2 to 3 sentences.]'
|
| 807 |
+
}
|
| 808 |
+
];
|
| 809 |
+
|
| 810 |
+
/* ─── SCENARIO RENDERER (no need to change) ─── */
|
| 811 |
+
function renderScen(idx){
|
| 812 |
+
const s = SCENARIOS[idx];
|
| 813 |
+
const metrics = s.metrics.map(m => `
|
| 814 |
+
<div class="res-card">
|
| 815 |
+
<div class="res-label">${m.label}</div>
|
| 816 |
+
<div class="res-val" style="color:${m.color}">${m.val}</div>
|
| 817 |
+
<div class="res-sub">${m.sub}</div>
|
| 818 |
+
</div>`).join('');
|
| 819 |
+
document.getElementById('scenOutput').innerHTML = `
|
| 820 |
+
<div style="font-size:.82rem;font-weight:700;color:var(--text);margin-bottom:12px">${s.title}</div>
|
| 821 |
+
<div class="result-grid">${metrics}</div>
|
| 822 |
+
<div class="risk-bar-wrap">
|
| 823 |
+
<div class="risk-bar-label">
|
| 824 |
+
<span style="color:var(--muted);font-size:.78rem">${s.bar.label}</span>
|
| 825 |
+
<span style="color:${s.bar.color};font-weight:700;font-size:.82rem">${s.bar.pct}%</span>
|
| 826 |
+
</div>
|
| 827 |
+
<div class="risk-bar-track">
|
| 828 |
+
<div class="risk-bar-fill" style="width:${s.bar.pct}%;background:${s.bar.color}"></div>
|
| 829 |
+
</div>
|
| 830 |
+
</div>
|
| 831 |
+
<div style="background:rgba(79,142,247,.06);border:1px solid rgba(79,142,247,.15);border-radius:8px;padding:12px 16px;font-size:.82rem;color:var(--muted);line-height:1.65;margin-top:4px">${s.insight}</div>`;
|
| 832 |
+
}
|
| 833 |
+
function selectScen(idx, btn){
|
| 834 |
+
document.querySelectorAll('.scen-btn').forEach(b => b.classList.remove('active'));
|
| 835 |
+
btn.classList.add('active');
|
| 836 |
+
renderScen(idx);
|
| 837 |
+
}
|
| 838 |
+
renderScen(0);
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
/* ─── CHART DATA ───────────────────────────────────
|
| 842 |
+
Edit the three buildChart(i) blocks below.
|
| 843 |
+
i=0 → Chart Tab 1, i=1 → Tab 2, i=2 → Tab 3
|
| 844 |
+
Chart.js docs: https://www.chartjs.org/docs/
|
| 845 |
+
─────────────────────────────────────────────────── */
|
| 846 |
+
const charts = {};
|
| 847 |
+
|
| 848 |
+
function buildChart(i){
|
| 849 |
+
if(charts[i]) charts[i].destroy();
|
| 850 |
+
const ctx = document.getElementById('chart' + i);
|
| 851 |
+
if(!ctx) return;
|
| 852 |
+
const g = gc(), t = tc(), tip = tt();
|
| 853 |
+
|
| 854 |
+
if(i === 0){
|
| 855 |
+
/* ── CHART 1: Replace labels and data ── */
|
| 856 |
+
charts[0] = new Chart(ctx, {
|
| 857 |
+
type: 'bar',
|
| 858 |
+
data: {
|
| 859 |
+
labels: ['[Label A]', '[Label B]', '[Label C]', '[Label D]', '[Label E]'],
|
| 860 |
+
datasets:[{
|
| 861 |
+
label: '[Dataset Name]',
|
| 862 |
+
data: [10, 25, 40, 60, 80], /* ← replace with your values */
|
| 863 |
+
backgroundColor: [
|
| 864 |
+
'rgba(248,81,73,.8)',
|
| 865 |
+
'rgba(210,153,34,.75)',
|
| 866 |
+
'rgba(88,166,255,.7)',
|
| 867 |
+
'rgba(63,185,80,.7)',
|
| 868 |
+
'rgba(0,176,255,.75)'
|
| 869 |
+
],
|
| 870 |
+
borderRadius: 6
|
| 871 |
+
}]
|
| 872 |
+
},
|
| 873 |
+
options:{
|
| 874 |
+
responsive:true, maintainAspectRatio:false,
|
| 875 |
+
plugins:{ legend:{labels:{color:t}}, tooltip:tip },
|
| 876 |
+
scales:{
|
| 877 |
+
x:{ ticks:{color:t}, grid:{color:g} },
|
| 878 |
+
y:{ ticks:{color:t}, grid:{color:g}, title:{display:true, text:'[Y-Axis Label]', color:t, font:{size:11}} }
|
| 879 |
+
}
|
| 880 |
+
}
|
| 881 |
+
});
|
| 882 |
+
|
| 883 |
+
} else if(i === 1){
|
| 884 |
+
/* ── CHART 2: Replace labels and data ── */
|
| 885 |
+
charts[1] = new Chart(ctx, {
|
| 886 |
+
type: 'bar',
|
| 887 |
+
data: {
|
| 888 |
+
labels: ['[Label A]', '[Label B]', '[Label C]', '[Label D]'],
|
| 889 |
+
datasets:[
|
| 890 |
+
{
|
| 891 |
+
label: '[Dataset 1]',
|
| 892 |
+
data: [20, 35, 50, 65], /* ← replace */
|
| 893 |
+
backgroundColor: isDark() ? 'rgba(248,81,73,.75)' : 'rgba(220,38,38,.7)',
|
| 894 |
+
borderRadius: 6
|
| 895 |
+
},
|
| 896 |
+
{
|
| 897 |
+
label: '[Dataset 2]',
|
| 898 |
+
data: [40, 30, 20, 15], /* ← replace */
|
| 899 |
+
backgroundColor: isDark() ? 'rgba(210,153,34,.6)' : 'rgba(217,119,6,.6)',
|
| 900 |
+
borderRadius: 6
|
| 901 |
+
}
|
| 902 |
+
]
|
| 903 |
+
},
|
| 904 |
+
options:{
|
| 905 |
+
responsive:true, maintainAspectRatio:false,
|
| 906 |
+
plugins:{ legend:{labels:{color:t}}, tooltip:tip },
|
| 907 |
+
scales:{
|
| 908 |
+
x:{ ticks:{color:t}, grid:{color:g} },
|
| 909 |
+
y:{ ticks:{color:t}, grid:{color:g}, title:{display:true, text:'[Y-Axis Label]', color:t, font:{size:11}} }
|
| 910 |
+
}
|
| 911 |
+
}
|
| 912 |
+
});
|
| 913 |
+
|
| 914 |
+
} else if(i === 2){
|
| 915 |
+
/* ── CHART 3: Replace labels and data ── */
|
| 916 |
+
charts[2] = new Chart(ctx, {
|
| 917 |
+
type: 'bar',
|
| 918 |
+
data: {
|
| 919 |
+
labels: ['[Label A]', '[Label B]', '[Label C]'],
|
| 920 |
+
datasets:[
|
| 921 |
+
{
|
| 922 |
+
label: '[Dataset 1]',
|
| 923 |
+
data: [0.8, 1.1, 1.3], /* ← replace */
|
| 924 |
+
backgroundColor: [
|
| 925 |
+
isDark()?'rgba(0,176,255,.7)':'rgba(37,99,235,.65)',
|
| 926 |
+
'rgba(210,153,34,.7)',
|
| 927 |
+
'rgba(248,81,73,.7)'
|
| 928 |
+
],
|
| 929 |
+
borderRadius: 6
|
| 930 |
+
},
|
| 931 |
+
{
|
| 932 |
+
label: '[Dataset 2]',
|
| 933 |
+
data: [1.1, 1.4, 1.6], /* ← replace */
|
| 934 |
+
backgroundColor: ['rgba(136,146,164,.4)','rgba(136,146,164,.4)','rgba(136,146,164,.4)'],
|
| 935 |
+
borderRadius: 6
|
| 936 |
+
}
|
| 937 |
+
]
|
| 938 |
+
},
|
| 939 |
+
options:{
|
| 940 |
+
responsive:true, maintainAspectRatio:false,
|
| 941 |
+
plugins:{ legend:{labels:{color:t}}, tooltip:tip },
|
| 942 |
+
scales:{
|
| 943 |
+
x:{ ticks:{color:t}, grid:{color:g} },
|
| 944 |
+
y:{ ticks:{color:t}, grid:{color:g}, title:{display:true, text:'[Y-Axis Label]', color:t, font:{size:11}} }
|
| 945 |
+
}
|
| 946 |
+
}
|
| 947 |
+
});
|
| 948 |
+
}
|
| 949 |
+
}
|
| 950 |
+
|
| 951 |
+
function switchTab(i, el){
|
| 952 |
+
document.querySelectorAll('.chart-tab').forEach(t => t.classList.remove('active'));
|
| 953 |
+
document.querySelectorAll('.chart-panel').forEach(p => p.classList.remove('active'));
|
| 954 |
+
el.classList.add('active');
|
| 955 |
+
document.getElementById('cp' + i).classList.add('active');
|
| 956 |
+
buildChart(i);
|
| 957 |
+
}
|
| 958 |
+
|
| 959 |
+
buildChart(0); /* Build the first chart on page load */
|
| 960 |
+
</script>
|
| 961 |
+
|
| 962 |
+
</body>
|
| 963 |
+
</html>
|
mlops/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# MLOps core package
|
mlops/algorithms.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Algorithm registry for AutoMLOps — multiple categories for classification & regression."""
|
| 2 |
+
from sklearn.linear_model import (
|
| 3 |
+
LogisticRegression, RidgeClassifier, SGDClassifier,
|
| 4 |
+
PassiveAggressiveClassifier, LinearRegression, Ridge, Lasso,
|
| 5 |
+
ElasticNet, BayesianRidge, HuberRegressor, SGDRegressor,
|
| 6 |
+
)
|
| 7 |
+
from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
|
| 8 |
+
from sklearn.ensemble import (
|
| 9 |
+
RandomForestClassifier, ExtraTreesClassifier,
|
| 10 |
+
GradientBoostingClassifier, AdaBoostClassifier, BaggingClassifier,
|
| 11 |
+
RandomForestRegressor, ExtraTreesRegressor,
|
| 12 |
+
GradientBoostingRegressor, AdaBoostRegressor, BaggingRegressor,
|
| 13 |
+
)
|
| 14 |
+
from sklearn.svm import SVC, SVR, LinearSVC
|
| 15 |
+
from sklearn.naive_bayes import GaussianNB, BernoulliNB, ComplementNB
|
| 16 |
+
from sklearn.neighbors import KNeighborsClassifier, KNeighborsRegressor
|
| 17 |
+
from sklearn.neural_network import MLPClassifier, MLPRegressor
|
| 18 |
+
from sklearn.discriminant_analysis import (
|
| 19 |
+
LinearDiscriminantAnalysis, QuadraticDiscriminantAnalysis,
|
| 20 |
+
)
|
| 21 |
+
from xgboost import XGBClassifier, XGBRegressor
|
| 22 |
+
from lightgbm import LGBMClassifier, LGBMRegressor
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ── Shared verbosity helper ────────────────────────────────────────────────────
|
| 26 |
+
_SILENT = {"verbosity": 0} # XGBoost
|
| 27 |
+
_LGBM_SILENT = {"verbose": -1} # LightGBM
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
ALGORITHMS = {
|
| 31 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 32 |
+
# CLASSIFICATION
|
| 33 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 34 |
+
"classification": {
|
| 35 |
+
|
| 36 |
+
"Linear Models": {
|
| 37 |
+
"Logistic Regression": {
|
| 38 |
+
"class": LogisticRegression,
|
| 39 |
+
"params": {"max_iter": 1000, "random_state": 42},
|
| 40 |
+
"description": "L2-regularised linear classifier, interpretable baseline.",
|
| 41 |
+
"color": "#3b82f6",
|
| 42 |
+
},
|
| 43 |
+
"Logistic Regression (L1)": {
|
| 44 |
+
"class": LogisticRegression,
|
| 45 |
+
"params": {"penalty": "l1", "solver": "saga", "max_iter": 1000, "random_state": 42},
|
| 46 |
+
"description": "Sparse logistic regression via L1 regularisation.",
|
| 47 |
+
"color": "#60a5fa",
|
| 48 |
+
},
|
| 49 |
+
"Ridge Classifier": {
|
| 50 |
+
"class": RidgeClassifier,
|
| 51 |
+
"params": {"alpha": 1.0},
|
| 52 |
+
"description": "Ridge-regression-based classifier; fast on high-dim data.",
|
| 53 |
+
"color": "#93c5fd",
|
| 54 |
+
},
|
| 55 |
+
"SGD Classifier": {
|
| 56 |
+
"class": SGDClassifier,
|
| 57 |
+
"params": {"max_iter": 1000, "random_state": 42},
|
| 58 |
+
"description": "Stochastic Gradient Descent for large-scale linear classification.",
|
| 59 |
+
"color": "#bfdbfe",
|
| 60 |
+
},
|
| 61 |
+
"Passive Aggressive": {
|
| 62 |
+
"class": PassiveAggressiveClassifier,
|
| 63 |
+
"params": {"max_iter": 1000, "random_state": 42},
|
| 64 |
+
"description": "Online learning algorithm suited to text/streaming data.",
|
| 65 |
+
"color": "#dbeafe",
|
| 66 |
+
},
|
| 67 |
+
"Linear Discriminant Analysis": {
|
| 68 |
+
"class": LinearDiscriminantAnalysis,
|
| 69 |
+
"params": {},
|
| 70 |
+
"description": "Finds linear combinations that maximise class separation.",
|
| 71 |
+
"color": "#eff6ff",
|
| 72 |
+
},
|
| 73 |
+
},
|
| 74 |
+
|
| 75 |
+
"Tree-Based": {
|
| 76 |
+
"Decision Tree": {
|
| 77 |
+
"class": DecisionTreeClassifier,
|
| 78 |
+
"params": {"max_depth": 10, "random_state": 42},
|
| 79 |
+
"description": "Interpretable tree of if-else rules.",
|
| 80 |
+
"color": "#22c55e",
|
| 81 |
+
},
|
| 82 |
+
"Random Forest": {
|
| 83 |
+
"class": RandomForestClassifier,
|
| 84 |
+
"params": {"n_estimators": 100, "random_state": 42},
|
| 85 |
+
"description": "Bagging of decision trees; robust, low variance.",
|
| 86 |
+
"color": "#4ade80",
|
| 87 |
+
},
|
| 88 |
+
"Extra Trees": {
|
| 89 |
+
"class": ExtraTreesClassifier,
|
| 90 |
+
"params": {"n_estimators": 100, "random_state": 42},
|
| 91 |
+
"description": "Extremely randomised trees; faster than Random Forest.",
|
| 92 |
+
"color": "#86efac",
|
| 93 |
+
},
|
| 94 |
+
"Quadratic Discriminant Analysis": {
|
| 95 |
+
"class": QuadraticDiscriminantAnalysis,
|
| 96 |
+
"params": {},
|
| 97 |
+
"description": "Non-linear discriminant analysis with quadratic boundary.",
|
| 98 |
+
"color": "#bbf7d0",
|
| 99 |
+
},
|
| 100 |
+
},
|
| 101 |
+
|
| 102 |
+
"Ensemble / Boosting": {
|
| 103 |
+
"Gradient Boosting": {
|
| 104 |
+
"class": GradientBoostingClassifier,
|
| 105 |
+
"params": {"n_estimators": 100, "learning_rate": 0.1, "random_state": 42},
|
| 106 |
+
"description": "Sequential boosting of shallow trees; high accuracy.",
|
| 107 |
+
"color": "#f59e0b",
|
| 108 |
+
},
|
| 109 |
+
"AdaBoost": {
|
| 110 |
+
"class": AdaBoostClassifier,
|
| 111 |
+
"params": {"n_estimators": 100, "random_state": 42},
|
| 112 |
+
"description": "Adaptive boosting; up-weights misclassified samples.",
|
| 113 |
+
"color": "#fbbf24",
|
| 114 |
+
},
|
| 115 |
+
"Bagging Classifier": {
|
| 116 |
+
"class": BaggingClassifier,
|
| 117 |
+
"params": {"n_estimators": 50, "random_state": 42},
|
| 118 |
+
"description": "Bootstrap aggregating of any base estimator.",
|
| 119 |
+
"color": "#fcd34d",
|
| 120 |
+
},
|
| 121 |
+
"XGBoost": {
|
| 122 |
+
"class": XGBClassifier,
|
| 123 |
+
"params": {"n_estimators": 100, "learning_rate": 0.1, "random_state": 42, **_SILENT},
|
| 124 |
+
"description": "Optimised gradient boosting with regularisation; competition favourite.",
|
| 125 |
+
"color": "#d97706",
|
| 126 |
+
},
|
| 127 |
+
"LightGBM": {
|
| 128 |
+
"class": LGBMClassifier,
|
| 129 |
+
"params": {"n_estimators": 100, "learning_rate": 0.1, "random_state": 42, **_LGBM_SILENT},
|
| 130 |
+
"description": "Leaf-wise boosting; extremely fast on large datasets.",
|
| 131 |
+
"color": "#b45309",
|
| 132 |
+
},
|
| 133 |
+
},
|
| 134 |
+
|
| 135 |
+
"Support Vector Machines": {
|
| 136 |
+
"SVC (RBF Kernel)": {
|
| 137 |
+
"class": SVC,
|
| 138 |
+
"params": {"kernel": "rbf", "probability": True, "random_state": 42},
|
| 139 |
+
"description": "Non-linear SVM with radial basis function kernel.",
|
| 140 |
+
"color": "#a855f7",
|
| 141 |
+
},
|
| 142 |
+
"SVC (Polynomial)": {
|
| 143 |
+
"class": SVC,
|
| 144 |
+
"params": {"kernel": "poly", "degree": 3, "probability": True, "random_state": 42},
|
| 145 |
+
"description": "SVM with polynomial kernel; captures feature interactions.",
|
| 146 |
+
"color": "#c084fc",
|
| 147 |
+
},
|
| 148 |
+
"SVC (Linear)": {
|
| 149 |
+
"class": SVC,
|
| 150 |
+
"params": {"kernel": "linear", "probability": True, "random_state": 42},
|
| 151 |
+
"description": "Linear SVM; interpretable weights, good on text features.",
|
| 152 |
+
"color": "#d8b4fe",
|
| 153 |
+
},
|
| 154 |
+
"LinearSVC": {
|
| 155 |
+
"class": LinearSVC,
|
| 156 |
+
"params": {"max_iter": 2000, "random_state": 42},
|
| 157 |
+
"description": "Faster linear SVM implementation via liblinear.",
|
| 158 |
+
"color": "#ede9fe",
|
| 159 |
+
},
|
| 160 |
+
},
|
| 161 |
+
|
| 162 |
+
"Probabilistic": {
|
| 163 |
+
"Gaussian Naive Bayes": {
|
| 164 |
+
"class": GaussianNB,
|
| 165 |
+
"params": {},
|
| 166 |
+
"description": "Assumes Gaussian feature distribution; very fast baseline.",
|
| 167 |
+
"color": "#ec4899",
|
| 168 |
+
},
|
| 169 |
+
"Bernoulli Naive Bayes": {
|
| 170 |
+
"class": BernoulliNB,
|
| 171 |
+
"params": {},
|
| 172 |
+
"description": "NB for binary/boolean features; popular in text classification.",
|
| 173 |
+
"color": "#f472b6",
|
| 174 |
+
},
|
| 175 |
+
"Complement Naive Bayes": {
|
| 176 |
+
"class": ComplementNB,
|
| 177 |
+
"params": {},
|
| 178 |
+
"description": "Improved NB variant, particularly strong on imbalanced text data.",
|
| 179 |
+
"color": "#fbcfe8",
|
| 180 |
+
},
|
| 181 |
+
},
|
| 182 |
+
|
| 183 |
+
"Instance-Based (KNN)": {
|
| 184 |
+
"KNN (k=3)": {
|
| 185 |
+
"class": KNeighborsClassifier,
|
| 186 |
+
"params": {"n_neighbors": 3},
|
| 187 |
+
"description": "Majority vote from 3 nearest neighbours.",
|
| 188 |
+
"color": "#06b6d4",
|
| 189 |
+
},
|
| 190 |
+
"KNN (k=5)": {
|
| 191 |
+
"class": KNeighborsClassifier,
|
| 192 |
+
"params": {"n_neighbors": 5},
|
| 193 |
+
"description": "Majority vote from 5 nearest neighbours.",
|
| 194 |
+
"color": "#22d3ee",
|
| 195 |
+
},
|
| 196 |
+
"KNN (k=9)": {
|
| 197 |
+
"class": KNeighborsClassifier,
|
| 198 |
+
"params": {"n_neighbors": 9},
|
| 199 |
+
"description": "Majority vote from 9 nearest neighbours; smoother boundary.",
|
| 200 |
+
"color": "#67e8f9",
|
| 201 |
+
},
|
| 202 |
+
},
|
| 203 |
+
|
| 204 |
+
"Neural Networks": {
|
| 205 |
+
"MLP (Small)": {
|
| 206 |
+
"class": MLPClassifier,
|
| 207 |
+
"params": {"hidden_layer_sizes": (64,), "max_iter": 500, "random_state": 42},
|
| 208 |
+
"description": "Single hidden-layer neural network.",
|
| 209 |
+
"color": "#f43f5e",
|
| 210 |
+
},
|
| 211 |
+
"MLP (Medium)": {
|
| 212 |
+
"class": MLPClassifier,
|
| 213 |
+
"params": {"hidden_layer_sizes": (128, 64), "max_iter": 500, "random_state": 42},
|
| 214 |
+
"description": "Two hidden-layer neural network.",
|
| 215 |
+
"color": "#fb7185",
|
| 216 |
+
},
|
| 217 |
+
"MLP (Deep)": {
|
| 218 |
+
"class": MLPClassifier,
|
| 219 |
+
"params": {"hidden_layer_sizes": (256, 128, 64), "max_iter": 500, "random_state": 42},
|
| 220 |
+
"description": "Three hidden-layer neural network with ReLU activations.",
|
| 221 |
+
"color": "#fda4af",
|
| 222 |
+
},
|
| 223 |
+
},
|
| 224 |
+
},
|
| 225 |
+
|
| 226 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 227 |
+
# REGRESSION
|
| 228 |
+
# ══════════════════════════════════════════════════════════════════════
|
| 229 |
+
"regression": {
|
| 230 |
+
|
| 231 |
+
"Linear Models": {
|
| 232 |
+
"Linear Regression": {
|
| 233 |
+
"class": LinearRegression,
|
| 234 |
+
"params": {},
|
| 235 |
+
"description": "Ordinary least-squares; interpretable baseline.",
|
| 236 |
+
"color": "#3b82f6",
|
| 237 |
+
},
|
| 238 |
+
"Ridge Regression": {
|
| 239 |
+
"class": Ridge,
|
| 240 |
+
"params": {"alpha": 1.0},
|
| 241 |
+
"description": "L2-regularised linear regression; handles multicollinearity.",
|
| 242 |
+
"color": "#60a5fa",
|
| 243 |
+
},
|
| 244 |
+
"Lasso": {
|
| 245 |
+
"class": Lasso,
|
| 246 |
+
"params": {"alpha": 0.1, "max_iter": 2000},
|
| 247 |
+
"description": "L1 regularisation produces sparse feature weights.",
|
| 248 |
+
"color": "#93c5fd",
|
| 249 |
+
},
|
| 250 |
+
"ElasticNet": {
|
| 251 |
+
"class": ElasticNet,
|
| 252 |
+
"params": {"alpha": 0.1, "l1_ratio": 0.5, "max_iter": 2000},
|
| 253 |
+
"description": "Combines L1 and L2 regularisation.",
|
| 254 |
+
"color": "#bfdbfe",
|
| 255 |
+
},
|
| 256 |
+
"Bayesian Ridge": {
|
| 257 |
+
"class": BayesianRidge,
|
| 258 |
+
"params": {},
|
| 259 |
+
"description": "Probabilistic Bayesian linear regression with automatic regularisation.",
|
| 260 |
+
"color": "#dbeafe",
|
| 261 |
+
},
|
| 262 |
+
"Huber Regressor": {
|
| 263 |
+
"class": HuberRegressor,
|
| 264 |
+
"params": {"max_iter": 200},
|
| 265 |
+
"description": "Robust to outliers via Huber loss function.",
|
| 266 |
+
"color": "#eff6ff",
|
| 267 |
+
},
|
| 268 |
+
},
|
| 269 |
+
|
| 270 |
+
"Tree-Based": {
|
| 271 |
+
"Decision Tree Regressor": {
|
| 272 |
+
"class": DecisionTreeRegressor,
|
| 273 |
+
"params": {"max_depth": 10, "random_state": 42},
|
| 274 |
+
"description": "Recursive partitioning for regression.",
|
| 275 |
+
"color": "#22c55e",
|
| 276 |
+
},
|
| 277 |
+
"Random Forest Regressor": {
|
| 278 |
+
"class": RandomForestRegressor,
|
| 279 |
+
"params": {"n_estimators": 100, "random_state": 42},
|
| 280 |
+
"description": "Averaged predictions of many trees; low variance.",
|
| 281 |
+
"color": "#4ade80",
|
| 282 |
+
},
|
| 283 |
+
"Extra Trees Regressor": {
|
| 284 |
+
"class": ExtraTreesRegressor,
|
| 285 |
+
"params": {"n_estimators": 100, "random_state": 42},
|
| 286 |
+
"description": "Extremely randomised regression trees; fast.",
|
| 287 |
+
"color": "#86efac",
|
| 288 |
+
},
|
| 289 |
+
},
|
| 290 |
+
|
| 291 |
+
"Ensemble / Boosting": {
|
| 292 |
+
"Gradient Boosting Regressor": {
|
| 293 |
+
"class": GradientBoostingRegressor,
|
| 294 |
+
"params": {"n_estimators": 100, "learning_rate": 0.1, "random_state": 42},
|
| 295 |
+
"description": "Sequential boosting minimising regression loss.",
|
| 296 |
+
"color": "#f59e0b",
|
| 297 |
+
},
|
| 298 |
+
"AdaBoost Regressor": {
|
| 299 |
+
"class": AdaBoostRegressor,
|
| 300 |
+
"params": {"n_estimators": 100, "random_state": 42},
|
| 301 |
+
"description": "Adaptive boosting for regression.",
|
| 302 |
+
"color": "#fbbf24",
|
| 303 |
+
},
|
| 304 |
+
"Bagging Regressor": {
|
| 305 |
+
"class": BaggingRegressor,
|
| 306 |
+
"params": {"n_estimators": 50, "random_state": 42},
|
| 307 |
+
"description": "Bootstrap aggregating for regression.",
|
| 308 |
+
"color": "#fcd34d",
|
| 309 |
+
},
|
| 310 |
+
"XGBoost Regressor": {
|
| 311 |
+
"class": XGBRegressor,
|
| 312 |
+
"params": {"n_estimators": 100, "learning_rate": 0.1, "random_state": 42, **_SILENT},
|
| 313 |
+
"description": "Regularised gradient boosting; excellent out-of-the-box performance.",
|
| 314 |
+
"color": "#d97706",
|
| 315 |
+
},
|
| 316 |
+
"LightGBM Regressor": {
|
| 317 |
+
"class": LGBMRegressor,
|
| 318 |
+
"params": {"n_estimators": 100, "learning_rate": 0.1, "random_state": 42, **_LGBM_SILENT},
|
| 319 |
+
"description": "Leaf-wise boosting regressor; fast and memory-efficient.",
|
| 320 |
+
"color": "#b45309",
|
| 321 |
+
},
|
| 322 |
+
},
|
| 323 |
+
|
| 324 |
+
"Support Vector Machines": {
|
| 325 |
+
"SVR (RBF)": {
|
| 326 |
+
"class": SVR,
|
| 327 |
+
"params": {"kernel": "rbf"},
|
| 328 |
+
"description": "Non-linear support vector regression.",
|
| 329 |
+
"color": "#a855f7",
|
| 330 |
+
},
|
| 331 |
+
"SVR (Linear)": {
|
| 332 |
+
"class": SVR,
|
| 333 |
+
"params": {"kernel": "linear"},
|
| 334 |
+
"description": "Linear support vector regression.",
|
| 335 |
+
"color": "#c084fc",
|
| 336 |
+
},
|
| 337 |
+
},
|
| 338 |
+
|
| 339 |
+
"Instance-Based (KNN)": {
|
| 340 |
+
"KNN Regressor (k=3)": {
|
| 341 |
+
"class": KNeighborsRegressor,
|
| 342 |
+
"params": {"n_neighbors": 3},
|
| 343 |
+
"description": "Average of 3 nearest neighbours.",
|
| 344 |
+
"color": "#06b6d4",
|
| 345 |
+
},
|
| 346 |
+
"KNN Regressor (k=5)": {
|
| 347 |
+
"class": KNeighborsRegressor,
|
| 348 |
+
"params": {"n_neighbors": 5},
|
| 349 |
+
"description": "Average of 5 nearest neighbours.",
|
| 350 |
+
"color": "#22d3ee",
|
| 351 |
+
},
|
| 352 |
+
},
|
| 353 |
+
|
| 354 |
+
"Neural Networks": {
|
| 355 |
+
"MLP Regressor (Small)": {
|
| 356 |
+
"class": MLPRegressor,
|
| 357 |
+
"params": {"hidden_layer_sizes": (64,), "max_iter": 500, "random_state": 42},
|
| 358 |
+
"description": "Single hidden-layer neural network for regression.",
|
| 359 |
+
"color": "#f43f5e",
|
| 360 |
+
},
|
| 361 |
+
"MLP Regressor (Medium)": {
|
| 362 |
+
"class": MLPRegressor,
|
| 363 |
+
"params": {"hidden_layer_sizes": (128, 64), "max_iter": 500, "random_state": 42},
|
| 364 |
+
"description": "Two hidden-layer neural network for regression.",
|
| 365 |
+
"color": "#fb7185",
|
| 366 |
+
},
|
| 367 |
+
},
|
| 368 |
+
},
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def get_algorithm(task: str, category: str, name: str) -> dict:
|
| 373 |
+
"""Retrieve algorithm config by task / category / name."""
|
| 374 |
+
try:
|
| 375 |
+
return ALGORITHMS[task][category][name]
|
| 376 |
+
except KeyError:
|
| 377 |
+
raise ValueError(f"Algorithm not found: task={task}, category={category}, name={name}")
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def list_algorithms(task: str) -> dict:
|
| 381 |
+
"""Return the algorithm tree for the given task type."""
|
| 382 |
+
if task not in ALGORITHMS:
|
| 383 |
+
raise ValueError(f"Unknown task: {task}")
|
| 384 |
+
return ALGORITHMS[task]
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def all_algorithm_names(task: str) -> list[str]:
|
| 388 |
+
"""Flat list of all algorithm names for a given task."""
|
| 389 |
+
names = []
|
| 390 |
+
for cat in ALGORITHMS[task].values():
|
| 391 |
+
names.extend(cat.keys())
|
| 392 |
+
return names
|
mlops/datasets.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dataset loaders for AutoMLOps demo."""
|
| 2 |
+
import numpy as np
|
| 3 |
+
from sklearn.datasets import (
|
| 4 |
+
load_iris, load_wine, load_breast_cancer, load_digits,
|
| 5 |
+
load_diabetes, fetch_california_housing
|
| 6 |
+
)
|
| 7 |
+
from sklearn.model_selection import train_test_split
|
| 8 |
+
from sklearn.preprocessing import StandardScaler
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
DATASETS = {
|
| 12 |
+
# ── Classification ────────────────────────────────────────────────────
|
| 13 |
+
"Iris Flowers": {
|
| 14 |
+
"task": "classification",
|
| 15 |
+
"description": "Classic 3-class flower species classification (150 samples, 4 features)",
|
| 16 |
+
"loader": load_iris,
|
| 17 |
+
"icon": "🌸",
|
| 18 |
+
"difficulty": "Easy",
|
| 19 |
+
},
|
| 20 |
+
"Wine Quality": {
|
| 21 |
+
"task": "classification",
|
| 22 |
+
"description": "Wine cultivar identification from chemical analysis (178 samples, 13 features)",
|
| 23 |
+
"loader": load_wine,
|
| 24 |
+
"icon": "🍷",
|
| 25 |
+
"difficulty": "Easy",
|
| 26 |
+
},
|
| 27 |
+
"Breast Cancer": {
|
| 28 |
+
"task": "classification",
|
| 29 |
+
"description": "Tumour malignancy detection (569 samples, 30 features)",
|
| 30 |
+
"loader": load_breast_cancer,
|
| 31 |
+
"icon": "🔬",
|
| 32 |
+
"difficulty": "Medium",
|
| 33 |
+
},
|
| 34 |
+
"Handwritten Digits": {
|
| 35 |
+
"task": "classification",
|
| 36 |
+
"description": "Digit recognition 0-9 from pixel images (1797 samples, 64 features)",
|
| 37 |
+
"loader": load_digits,
|
| 38 |
+
"icon": "✍️",
|
| 39 |
+
"difficulty": "Medium",
|
| 40 |
+
},
|
| 41 |
+
# ── Regression ────────────────────────────────────────────────────────
|
| 42 |
+
"Diabetes Progression": {
|
| 43 |
+
"task": "regression",
|
| 44 |
+
"description": "Disease progression prediction from physiological measurements (442 samples, 10 features)",
|
| 45 |
+
"loader": load_diabetes,
|
| 46 |
+
"icon": "💉",
|
| 47 |
+
"difficulty": "Medium",
|
| 48 |
+
},
|
| 49 |
+
"California Housing": {
|
| 50 |
+
"task": "regression",
|
| 51 |
+
"description": "House price prediction from socio-economic data (20640 samples, 8 features)",
|
| 52 |
+
"loader": fetch_california_housing,
|
| 53 |
+
"icon": "🏠",
|
| 54 |
+
"difficulty": "Hard",
|
| 55 |
+
},
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def load_dataset(name: str, test_size: float = 0.2, random_state: int = 42):
|
| 60 |
+
"""Load a dataset and return train/test splits with metadata."""
|
| 61 |
+
if name not in DATASETS:
|
| 62 |
+
raise ValueError(f"Unknown dataset: {name}. Available: {list(DATASETS.keys())}")
|
| 63 |
+
|
| 64 |
+
cfg = DATASETS[name]
|
| 65 |
+
data = cfg["loader"]()
|
| 66 |
+
|
| 67 |
+
X, y = data.data, data.target
|
| 68 |
+
|
| 69 |
+
feature_names = (
|
| 70 |
+
list(data.feature_names) if hasattr(data, "feature_names") else
|
| 71 |
+
[f"feature_{i}" for i in range(X.shape[1])]
|
| 72 |
+
)
|
| 73 |
+
target_names = (
|
| 74 |
+
list(data.target_names) if hasattr(data, "target_names") else None
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
X_train, X_test, y_train, y_test = train_test_split(
|
| 78 |
+
X, y, test_size=test_size, random_state=random_state
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
metadata = {
|
| 82 |
+
"name": name,
|
| 83 |
+
"task": cfg["task"],
|
| 84 |
+
"description": cfg["description"],
|
| 85 |
+
"icon": cfg["icon"],
|
| 86 |
+
"difficulty": cfg["difficulty"],
|
| 87 |
+
"n_samples": X.shape[0],
|
| 88 |
+
"n_features": X.shape[1],
|
| 89 |
+
"n_train": X_train.shape[0],
|
| 90 |
+
"n_test": X_test.shape[0],
|
| 91 |
+
"feature_names": feature_names,
|
| 92 |
+
"target_names": target_names,
|
| 93 |
+
"n_classes": len(np.unique(y)) if cfg["task"] == "classification" else None,
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return X_train, X_test, y_train, y_test, metadata
|
mlops/trainer.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Background model trainer with MLflow tracking."""
|
| 2 |
+
import time
|
| 3 |
+
import uuid
|
| 4 |
+
import threading
|
| 5 |
+
import numpy as np
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
|
| 8 |
+
import mlflow
|
| 9 |
+
import mlflow.sklearn
|
| 10 |
+
from sklearn.preprocessing import StandardScaler, LabelEncoder
|
| 11 |
+
from sklearn.metrics import (
|
| 12 |
+
accuracy_score, f1_score, precision_score, recall_score,
|
| 13 |
+
r2_score, mean_absolute_error, mean_squared_error,
|
| 14 |
+
confusion_matrix, classification_report,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
from mlops.datasets import load_dataset
|
| 18 |
+
from mlops.algorithms import get_algorithm, ALGORITHMS
|
| 19 |
+
|
| 20 |
+
# ── Shared job state ──────────────────────────────────────────────────────────
|
| 21 |
+
training_jobs: dict = {}
|
| 22 |
+
automl_jobs: dict = {}
|
| 23 |
+
_lock = threading.Lock()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ── Internal helpers ──────────────────────────────────────────────────────────
|
| 27 |
+
|
| 28 |
+
def _get_or_create_experiment(name: str) -> str:
|
| 29 |
+
mlflow.set_tracking_uri("sqlite:///mlflow.db")
|
| 30 |
+
exp = mlflow.get_experiment_by_name(name)
|
| 31 |
+
if exp is None:
|
| 32 |
+
return mlflow.create_experiment(name)
|
| 33 |
+
return exp.experiment_id
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _update_job(store: dict, job_id: str, **kwargs):
|
| 37 |
+
with _lock:
|
| 38 |
+
store[job_id].update(kwargs)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _classification_metrics(y_test, y_pred) -> dict:
|
| 42 |
+
return {
|
| 43 |
+
"accuracy": round(float(accuracy_score(y_test, y_pred)), 4),
|
| 44 |
+
"f1_score": round(float(f1_score(y_test, y_pred, average="weighted", zero_division=0)), 4),
|
| 45 |
+
"precision": round(float(precision_score(y_test, y_pred, average="weighted", zero_division=0)), 4),
|
| 46 |
+
"recall": round(float(recall_score(y_test, y_pred, average="weighted", zero_division=0)), 4),
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _regression_metrics(y_test, y_pred) -> dict:
|
| 51 |
+
mse = float(mean_squared_error(y_test, y_pred))
|
| 52 |
+
return {
|
| 53 |
+
"r2_score": round(float(r2_score(y_test, y_pred)), 4),
|
| 54 |
+
"mae": round(float(mean_absolute_error(y_test, y_pred)), 4),
|
| 55 |
+
"mse": round(mse, 4),
|
| 56 |
+
"rmse": round(float(np.sqrt(mse)), 4),
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ── Single training run ───────────────────────────────────────────────────────
|
| 61 |
+
|
| 62 |
+
def _do_train(job_id: str, dataset_name: str, algorithm_name: str,
|
| 63 |
+
algorithm_category: str, task_type: str, custom_params: dict | None):
|
| 64 |
+
"""Executed in a daemon thread; updates training_jobs[job_id] in place."""
|
| 65 |
+
start_time = time.time()
|
| 66 |
+
try:
|
| 67 |
+
_update_job(training_jobs, job_id, status="running", progress=5)
|
| 68 |
+
mlflow.set_tracking_uri("sqlite:///mlflow.db")
|
| 69 |
+
|
| 70 |
+
# 1. Load data
|
| 71 |
+
X_train, X_test, y_train, y_test, meta = load_dataset(dataset_name)
|
| 72 |
+
_update_job(training_jobs, job_id, progress=20, dataset_meta=meta)
|
| 73 |
+
|
| 74 |
+
# 2. Algorithm config
|
| 75 |
+
algo_cfg = get_algorithm(task_type, algorithm_category, algorithm_name)
|
| 76 |
+
params = {**algo_cfg["params"], **(custom_params or {})}
|
| 77 |
+
|
| 78 |
+
# 3. Pre-process
|
| 79 |
+
scaler = StandardScaler()
|
| 80 |
+
X_train_s = scaler.fit_transform(X_train)
|
| 81 |
+
X_test_s = scaler.transform(X_test)
|
| 82 |
+
|
| 83 |
+
# Handle NB algorithms that can't take negative inputs
|
| 84 |
+
if "Naive Bayes" in algorithm_name or "Complement" in algorithm_name:
|
| 85 |
+
from sklearn.preprocessing import MinMaxScaler
|
| 86 |
+
mms = MinMaxScaler()
|
| 87 |
+
X_train_s = mms.fit_transform(X_train)
|
| 88 |
+
X_test_s = mms.transform(X_test)
|
| 89 |
+
|
| 90 |
+
_update_job(training_jobs, job_id, progress=35)
|
| 91 |
+
|
| 92 |
+
# 4. Train inside an MLflow run
|
| 93 |
+
exp_id = _get_or_create_experiment(dataset_name)
|
| 94 |
+
with mlflow.start_run(experiment_id=exp_id,
|
| 95 |
+
run_name=f"{algorithm_name} — {dataset_name}") as run:
|
| 96 |
+
run_id = run.info.run_id
|
| 97 |
+
_update_job(training_jobs, job_id, mlflow_run_id=run_id, progress=40)
|
| 98 |
+
|
| 99 |
+
mlflow.set_tags({
|
| 100 |
+
"algorithm": algorithm_name,
|
| 101 |
+
"category": algorithm_category,
|
| 102 |
+
"dataset": dataset_name,
|
| 103 |
+
"task_type": task_type,
|
| 104 |
+
"job_id": job_id,
|
| 105 |
+
})
|
| 106 |
+
mlflow.log_params({"algorithm": algorithm_name,
|
| 107 |
+
"category": algorithm_category,
|
| 108 |
+
"dataset": dataset_name,
|
| 109 |
+
**{k: str(v) for k, v in params.items()}})
|
| 110 |
+
|
| 111 |
+
_update_job(training_jobs, job_id, progress=50)
|
| 112 |
+
|
| 113 |
+
model = algo_cfg["class"](**params)
|
| 114 |
+
model.fit(X_train_s, y_train)
|
| 115 |
+
_update_job(training_jobs, job_id, progress=75)
|
| 116 |
+
|
| 117 |
+
y_pred = model.predict(X_test_s)
|
| 118 |
+
|
| 119 |
+
if task_type == "classification":
|
| 120 |
+
metrics = _classification_metrics(y_test, y_pred)
|
| 121 |
+
cm = confusion_matrix(y_test, y_pred).tolist()
|
| 122 |
+
extra = {"confusion_matrix": cm,
|
| 123 |
+
"report": classification_report(y_test, y_pred, output_dict=True,
|
| 124 |
+
zero_division=0)}
|
| 125 |
+
else:
|
| 126 |
+
metrics = _regression_metrics(y_test, y_pred)
|
| 127 |
+
extra = {"y_test_sample": y_test[:50].tolist(),
|
| 128 |
+
"y_pred_sample": y_pred[:50].tolist()}
|
| 129 |
+
|
| 130 |
+
mlflow.log_metrics(metrics)
|
| 131 |
+
mlflow.sklearn.log_model(model, "model")
|
| 132 |
+
_update_job(training_jobs, job_id, progress=90)
|
| 133 |
+
|
| 134 |
+
duration = round(time.time() - start_time, 2)
|
| 135 |
+
_update_job(training_jobs, job_id,
|
| 136 |
+
status="completed", progress=100,
|
| 137 |
+
metrics=metrics, extra=extra,
|
| 138 |
+
duration=duration,
|
| 139 |
+
completed_at=datetime.utcnow().isoformat())
|
| 140 |
+
|
| 141 |
+
except Exception as exc:
|
| 142 |
+
_update_job(training_jobs, job_id,
|
| 143 |
+
status="failed", error=str(exc), progress=0)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def start_training(dataset_name: str, algorithm_name: str,
|
| 147 |
+
algorithm_category: str, task_type: str,
|
| 148 |
+
custom_params: dict | None = None) -> str:
|
| 149 |
+
"""Kick off a background training job and return its job_id."""
|
| 150 |
+
job_id = str(uuid.uuid4())[:8]
|
| 151 |
+
with _lock:
|
| 152 |
+
training_jobs[job_id] = {
|
| 153 |
+
"job_id": job_id,
|
| 154 |
+
"status": "queued",
|
| 155 |
+
"progress": 0,
|
| 156 |
+
"dataset": dataset_name,
|
| 157 |
+
"algorithm": algorithm_name,
|
| 158 |
+
"category": algorithm_category,
|
| 159 |
+
"task_type": task_type,
|
| 160 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 161 |
+
}
|
| 162 |
+
t = threading.Thread(
|
| 163 |
+
target=_do_train,
|
| 164 |
+
args=(job_id, dataset_name, algorithm_name,
|
| 165 |
+
algorithm_category, task_type, custom_params),
|
| 166 |
+
daemon=True,
|
| 167 |
+
)
|
| 168 |
+
t.start()
|
| 169 |
+
return job_id
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ── AutoML: exhaustive search across all algorithms ───────────────────────────
|
| 173 |
+
|
| 174 |
+
def _do_automl(job_id: str, dataset_name: str, task_type: str,
|
| 175 |
+
optimize_metric: str, max_runs: int):
|
| 176 |
+
"""Run every algorithm for the chosen task and log the best."""
|
| 177 |
+
try:
|
| 178 |
+
_update_job(automl_jobs, job_id, status="running", progress=2)
|
| 179 |
+
mlflow.set_tracking_uri("sqlite:///mlflow.db")
|
| 180 |
+
|
| 181 |
+
X_train, X_test, y_train, y_test, meta = load_dataset(dataset_name)
|
| 182 |
+
_update_job(automl_jobs, job_id, dataset_meta=meta, progress=5)
|
| 183 |
+
|
| 184 |
+
scaler = StandardScaler()
|
| 185 |
+
X_train_s = scaler.fit_transform(X_train)
|
| 186 |
+
X_test_s = scaler.transform(X_test)
|
| 187 |
+
|
| 188 |
+
exp_id = _get_or_create_experiment(f"AutoML — {dataset_name}")
|
| 189 |
+
|
| 190 |
+
# Collect all algorithms for this task
|
| 191 |
+
all_algos = []
|
| 192 |
+
for cat_name, cat in ALGORITHMS[task_type].items():
|
| 193 |
+
for alg_name, alg_cfg in cat.items():
|
| 194 |
+
all_algos.append((cat_name, alg_name, alg_cfg))
|
| 195 |
+
|
| 196 |
+
if max_runs < len(all_algos):
|
| 197 |
+
import random
|
| 198 |
+
random.seed(42)
|
| 199 |
+
all_algos = random.sample(all_algos, max_runs)
|
| 200 |
+
|
| 201 |
+
results = []
|
| 202 |
+
total = len(all_algos)
|
| 203 |
+
|
| 204 |
+
for idx, (cat_name, alg_name, alg_cfg) in enumerate(all_algos):
|
| 205 |
+
_update_job(automl_jobs, job_id,
|
| 206 |
+
progress=int(5 + 90 * idx / total),
|
| 207 |
+
current_algo=alg_name)
|
| 208 |
+
try:
|
| 209 |
+
with mlflow.start_run(experiment_id=exp_id,
|
| 210 |
+
run_name=f"AutoML: {alg_name}") as run:
|
| 211 |
+
mlflow.set_tags({"algorithm": alg_name, "category": cat_name,
|
| 212 |
+
"automl_job": job_id, "task_type": task_type})
|
| 213 |
+
|
| 214 |
+
# NB needs non-negative values
|
| 215 |
+
X_tr = X_train_s
|
| 216 |
+
X_te = X_test_s
|
| 217 |
+
if "Naive Bayes" in alg_name or "Complement" in alg_name:
|
| 218 |
+
from sklearn.preprocessing import MinMaxScaler
|
| 219 |
+
mms = MinMaxScaler()
|
| 220 |
+
X_tr = mms.fit_transform(X_train)
|
| 221 |
+
X_te = mms.transform(X_test)
|
| 222 |
+
|
| 223 |
+
model = alg_cfg["class"](**alg_cfg["params"])
|
| 224 |
+
t0 = time.time()
|
| 225 |
+
model.fit(X_tr, y_train)
|
| 226 |
+
dur = round(time.time() - t0, 2)
|
| 227 |
+
|
| 228 |
+
y_pred = model.predict(X_te)
|
| 229 |
+
if task_type == "classification":
|
| 230 |
+
metrics = _classification_metrics(y_test, y_pred)
|
| 231 |
+
else:
|
| 232 |
+
metrics = _regression_metrics(y_test, y_pred)
|
| 233 |
+
|
| 234 |
+
mlflow.log_params({"algorithm": alg_name, "category": cat_name})
|
| 235 |
+
mlflow.log_metrics(metrics)
|
| 236 |
+
mlflow.sklearn.log_model(model, "model")
|
| 237 |
+
|
| 238 |
+
results.append({
|
| 239 |
+
"rank": idx + 1,
|
| 240 |
+
"algorithm": alg_name,
|
| 241 |
+
"category": cat_name,
|
| 242 |
+
"metrics": metrics,
|
| 243 |
+
"duration": dur,
|
| 244 |
+
"run_id": run.info.run_id,
|
| 245 |
+
"color": alg_cfg.get("color", "#8b5cf6"),
|
| 246 |
+
})
|
| 247 |
+
except Exception:
|
| 248 |
+
pass # skip failed algorithms silently
|
| 249 |
+
|
| 250 |
+
# Sort by optimise metric
|
| 251 |
+
higher_is_better = optimize_metric in ("accuracy", "f1_score", "precision",
|
| 252 |
+
"recall", "r2_score")
|
| 253 |
+
results.sort(key=lambda r: r["metrics"].get(optimize_metric, 0),
|
| 254 |
+
reverse=higher_is_better)
|
| 255 |
+
for i, r in enumerate(results):
|
| 256 |
+
r["rank"] = i + 1
|
| 257 |
+
|
| 258 |
+
_update_job(automl_jobs, job_id,
|
| 259 |
+
status="completed", progress=100,
|
| 260 |
+
results=results,
|
| 261 |
+
best=results[0] if results else None,
|
| 262 |
+
completed_at=datetime.utcnow().isoformat())
|
| 263 |
+
|
| 264 |
+
except Exception as exc:
|
| 265 |
+
_update_job(automl_jobs, job_id, status="failed", error=str(exc))
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def start_automl(dataset_name: str, task_type: str,
|
| 269 |
+
optimize_metric: str = "accuracy",
|
| 270 |
+
max_runs: int = 20) -> str:
|
| 271 |
+
"""Kick off an AutoML sweep and return the job_id."""
|
| 272 |
+
job_id = str(uuid.uuid4())[:8]
|
| 273 |
+
with _lock:
|
| 274 |
+
automl_jobs[job_id] = {
|
| 275 |
+
"job_id": job_id,
|
| 276 |
+
"status": "queued",
|
| 277 |
+
"progress": 0,
|
| 278 |
+
"dataset": dataset_name,
|
| 279 |
+
"task_type": task_type,
|
| 280 |
+
"metric": optimize_metric,
|
| 281 |
+
"results": [],
|
| 282 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 283 |
+
}
|
| 284 |
+
t = threading.Thread(
|
| 285 |
+
target=_do_automl,
|
| 286 |
+
args=(job_id, dataset_name, task_type, optimize_metric, max_runs),
|
| 287 |
+
daemon=True,
|
| 288 |
+
)
|
| 289 |
+
t.start()
|
| 290 |
+
return job_id
|
pipelines/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Pipelines package
|
pipelines/dag_engine.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lightweight DAG execution engine — inspired by Apache Airflow concepts."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import time
|
| 4 |
+
import uuid
|
| 5 |
+
import threading
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from typing import Callable
|
| 8 |
+
|
| 9 |
+
# ── Shared execution state ─────────────────────────────────────────────────────
|
| 10 |
+
pipeline_executions: dict = {}
|
| 11 |
+
_lock = threading.Lock()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Task:
|
| 15 |
+
"""A single unit of work in a DAG."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, task_id: str, name: str, description: str,
|
| 18 |
+
func: Callable, upstream: list[str] | None = None,
|
| 19 |
+
icon: str = "⚙️", layer: int = 0):
|
| 20 |
+
self.task_id = task_id
|
| 21 |
+
self.name = name
|
| 22 |
+
self.description = description
|
| 23 |
+
self.func = func
|
| 24 |
+
self.upstream = upstream or [] # list of task_ids this depends on
|
| 25 |
+
self.icon = icon
|
| 26 |
+
self.layer = layer # visual column in the DAG
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class DAG:
|
| 30 |
+
"""A directed acyclic graph of Tasks."""
|
| 31 |
+
|
| 32 |
+
def __init__(self, dag_id: str, name: str, description: str):
|
| 33 |
+
self.dag_id = dag_id
|
| 34 |
+
self.name = name
|
| 35 |
+
self.description = description
|
| 36 |
+
self.tasks: dict[str, Task] = {}
|
| 37 |
+
|
| 38 |
+
def add_task(self, task: Task):
|
| 39 |
+
self.tasks[task.task_id] = task
|
| 40 |
+
|
| 41 |
+
def topological_order(self) -> list[str]:
|
| 42 |
+
"""Kahn's algorithm — returns task_ids in execution order."""
|
| 43 |
+
in_degree = {tid: 0 for tid in self.tasks}
|
| 44 |
+
for task in self.tasks.values():
|
| 45 |
+
for up in task.upstream:
|
| 46 |
+
in_degree[task.task_id] += 1
|
| 47 |
+
|
| 48 |
+
queue = [tid for tid, deg in in_degree.items() if deg == 0]
|
| 49 |
+
order = []
|
| 50 |
+
|
| 51 |
+
while queue:
|
| 52 |
+
# Sort for determinism
|
| 53 |
+
queue.sort(key=lambda t: (self.tasks[t].layer, t))
|
| 54 |
+
tid = queue.pop(0)
|
| 55 |
+
order.append(tid)
|
| 56 |
+
for task in self.tasks.values():
|
| 57 |
+
if tid in task.upstream:
|
| 58 |
+
in_degree[task.task_id] -= 1
|
| 59 |
+
if in_degree[task.task_id] == 0:
|
| 60 |
+
queue.append(task.task_id)
|
| 61 |
+
|
| 62 |
+
return order
|
| 63 |
+
|
| 64 |
+
def to_dict(self) -> dict:
|
| 65 |
+
"""Serialise DAG structure for the frontend."""
|
| 66 |
+
return {
|
| 67 |
+
"dag_id": self.dag_id,
|
| 68 |
+
"name": self.name,
|
| 69 |
+
"description": self.description,
|
| 70 |
+
"tasks": {
|
| 71 |
+
tid: {
|
| 72 |
+
"task_id": t.task_id,
|
| 73 |
+
"name": t.name,
|
| 74 |
+
"description": t.description,
|
| 75 |
+
"upstream": t.upstream,
|
| 76 |
+
"icon": t.icon,
|
| 77 |
+
"layer": t.layer,
|
| 78 |
+
}
|
| 79 |
+
for tid, t in self.tasks.items()
|
| 80 |
+
},
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ── Execution engine ──────────────────────────────────────────────────────────
|
| 85 |
+
|
| 86 |
+
def _run_dag(exec_id: str, dag: DAG, context: dict):
|
| 87 |
+
"""Execute a DAG in a background thread."""
|
| 88 |
+
try:
|
| 89 |
+
order = dag.topological_order()
|
| 90 |
+
total = len(order)
|
| 91 |
+
task_results: dict = {}
|
| 92 |
+
|
| 93 |
+
def _upd(**kw):
|
| 94 |
+
with _lock:
|
| 95 |
+
pipeline_executions[exec_id].update(kw)
|
| 96 |
+
|
| 97 |
+
def _upd_task(tid: str, **kw):
|
| 98 |
+
with _lock:
|
| 99 |
+
pipeline_executions[exec_id]["task_states"][tid].update(kw)
|
| 100 |
+
|
| 101 |
+
_upd(status="running", progress=0)
|
| 102 |
+
|
| 103 |
+
for step_idx, tid in enumerate(order):
|
| 104 |
+
task = dag.tasks[tid]
|
| 105 |
+
|
| 106 |
+
_upd_task(tid, status="running",
|
| 107 |
+
started_at=datetime.utcnow().isoformat())
|
| 108 |
+
|
| 109 |
+
log_line = f"[{datetime.utcnow().strftime('%H:%M:%S')}] ▶ {task.name}"
|
| 110 |
+
with _lock:
|
| 111 |
+
pipeline_executions[exec_id]["logs"].append(log_line)
|
| 112 |
+
|
| 113 |
+
try:
|
| 114 |
+
result = task.func(context, task_results)
|
| 115 |
+
task_results[tid] = result
|
| 116 |
+
|
| 117 |
+
_upd_task(tid, status="success",
|
| 118 |
+
finished_at=datetime.utcnow().isoformat(),
|
| 119 |
+
result=str(result)[:200] if result is not None else "OK")
|
| 120 |
+
|
| 121 |
+
ok_line = f"[{datetime.utcnow().strftime('%H:%M:%S')}] ✔ {task.name} — OK"
|
| 122 |
+
with _lock:
|
| 123 |
+
pipeline_executions[exec_id]["logs"].append(ok_line)
|
| 124 |
+
|
| 125 |
+
except Exception as exc:
|
| 126 |
+
_upd_task(tid, status="failed",
|
| 127 |
+
finished_at=datetime.utcnow().isoformat(),
|
| 128 |
+
error=str(exc))
|
| 129 |
+
err_line = f"[{datetime.utcnow().strftime('%H:%M:%S')}] ✖ {task.name} — {exc}"
|
| 130 |
+
with _lock:
|
| 131 |
+
pipeline_executions[exec_id]["logs"].append(err_line)
|
| 132 |
+
# Continue with remaining tasks (soft failure)
|
| 133 |
+
|
| 134 |
+
progress = int(100 * (step_idx + 1) / total)
|
| 135 |
+
_upd(progress=progress)
|
| 136 |
+
|
| 137 |
+
time.sleep(0.4) # small delay so the UI can animate
|
| 138 |
+
|
| 139 |
+
_upd(status="completed", progress=100,
|
| 140 |
+
completed_at=datetime.utcnow().isoformat())
|
| 141 |
+
|
| 142 |
+
except Exception as exc:
|
| 143 |
+
with _lock:
|
| 144 |
+
pipeline_executions[exec_id]["status"] = "failed"
|
| 145 |
+
pipeline_executions[exec_id]["error"] = str(exc)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def execute_dag(dag: DAG, context: dict | None = None) -> str:
|
| 149 |
+
"""Start DAG execution in a background thread; return exec_id."""
|
| 150 |
+
exec_id = str(uuid.uuid4())[:8]
|
| 151 |
+
task_states = {
|
| 152 |
+
tid: {"status": "pending", "started_at": None,
|
| 153 |
+
"finished_at": None, "result": None, "error": None}
|
| 154 |
+
for tid in dag.tasks
|
| 155 |
+
}
|
| 156 |
+
with _lock:
|
| 157 |
+
pipeline_executions[exec_id] = {
|
| 158 |
+
"exec_id": exec_id,
|
| 159 |
+
"dag_id": dag.dag_id,
|
| 160 |
+
"dag_name": dag.name,
|
| 161 |
+
"status": "queued",
|
| 162 |
+
"progress": 0,
|
| 163 |
+
"task_states": task_states,
|
| 164 |
+
"logs": [f"[{datetime.utcnow().strftime('%H:%M:%S')}] DAG '{dag.name}' queued"],
|
| 165 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
t = threading.Thread(target=_run_dag, args=(exec_id, dag, context or {}), daemon=True)
|
| 169 |
+
t.start()
|
| 170 |
+
return exec_id
|
pipelines/pipeline_defs.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pre-built ML pipeline DAG definitions."""
|
| 2 |
+
import time
|
| 3 |
+
import numpy as np
|
| 4 |
+
from pipelines.dag_engine import DAG, Task
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
# ── Task functions ─────────────────────────────────────────────────────────────
|
| 8 |
+
|
| 9 |
+
def _load_data(ctx, _results):
|
| 10 |
+
from mlops.datasets import load_dataset
|
| 11 |
+
ds = ctx.get("dataset", "Iris Flowers")
|
| 12 |
+
_, _, _, _, meta = load_dataset(ds)
|
| 13 |
+
return f"{meta['n_samples']} samples, {meta['n_features']} features loaded"
|
| 14 |
+
|
| 15 |
+
def _validate_data(ctx, results):
|
| 16 |
+
time.sleep(0.2)
|
| 17 |
+
return "Schema OK · No nulls detected · Feature ranges valid"
|
| 18 |
+
|
| 19 |
+
def _preprocess(ctx, results):
|
| 20 |
+
time.sleep(0.3)
|
| 21 |
+
return "StandardScaler fitted · Train/test split 80/20"
|
| 22 |
+
|
| 23 |
+
def _feature_engineering(ctx, results):
|
| 24 |
+
time.sleep(0.2)
|
| 25 |
+
return "Polynomial features skipped · All features retained"
|
| 26 |
+
|
| 27 |
+
def _train_model(ctx, results):
|
| 28 |
+
from mlops.datasets import load_dataset
|
| 29 |
+
from mlops.algorithms import get_algorithm
|
| 30 |
+
from sklearn.preprocessing import StandardScaler
|
| 31 |
+
import mlflow, mlflow.sklearn
|
| 32 |
+
|
| 33 |
+
ds = ctx.get("dataset", "Iris Flowers")
|
| 34 |
+
cat = ctx.get("category", "Ensemble / Boosting")
|
| 35 |
+
alg = ctx.get("algorithm", "Random Forest")
|
| 36 |
+
task = ctx.get("task_type", "classification")
|
| 37 |
+
|
| 38 |
+
X_train, X_test, y_train, y_test, _ = load_dataset(ds)
|
| 39 |
+
scaler = StandardScaler()
|
| 40 |
+
X_tr = scaler.fit_transform(X_train)
|
| 41 |
+
X_te = scaler.transform(X_test)
|
| 42 |
+
|
| 43 |
+
cfg = get_algorithm(task, cat, alg)
|
| 44 |
+
model = cfg["class"](**cfg["params"])
|
| 45 |
+
model.fit(X_tr, y_train)
|
| 46 |
+
score = model.score(X_te, y_test)
|
| 47 |
+
return f"Model trained · score={score:.4f}"
|
| 48 |
+
|
| 49 |
+
def _evaluate_model(ctx, results):
|
| 50 |
+
time.sleep(0.2)
|
| 51 |
+
return "Accuracy / R² computed · Cross-val 5-fold done"
|
| 52 |
+
|
| 53 |
+
def _generate_report(ctx, results):
|
| 54 |
+
time.sleep(0.15)
|
| 55 |
+
return "HTML report generated · Metrics written to mlflow"
|
| 56 |
+
|
| 57 |
+
def _register_model(ctx, _results):
|
| 58 |
+
time.sleep(0.1)
|
| 59 |
+
return "Model artifact registered in MLflow Model Registry"
|
| 60 |
+
|
| 61 |
+
def _deploy_staging(ctx, _results):
|
| 62 |
+
time.sleep(0.2)
|
| 63 |
+
return "Model transitioned to Staging · REST endpoint ready"
|
| 64 |
+
|
| 65 |
+
# ── Retraining pipeline tasks ──────────────────────────────────────────────────
|
| 66 |
+
|
| 67 |
+
def _check_drift(ctx, _):
|
| 68 |
+
time.sleep(0.2)
|
| 69 |
+
drift = round(np.random.uniform(0.01, 0.08), 4)
|
| 70 |
+
return f"PSI={drift} · {'Drift detected — retraining triggered' if drift > 0.05 else 'No drift · pipeline skipped'}"
|
| 71 |
+
|
| 72 |
+
def _fetch_new_data(ctx, _):
|
| 73 |
+
time.sleep(0.3)
|
| 74 |
+
n = np.random.randint(200, 800)
|
| 75 |
+
return f"{n} new labelled samples fetched from data store"
|
| 76 |
+
|
| 77 |
+
def _merge_datasets(ctx, _):
|
| 78 |
+
time.sleep(0.2)
|
| 79 |
+
return "New data merged with historical · duplicates removed"
|
| 80 |
+
|
| 81 |
+
def _retrain_champion(ctx, _):
|
| 82 |
+
time.sleep(0.4)
|
| 83 |
+
acc = round(np.random.uniform(0.88, 0.97), 4)
|
| 84 |
+
return f"Champion model retrained · new accuracy={acc}"
|
| 85 |
+
|
| 86 |
+
def _ab_test(ctx, _):
|
| 87 |
+
time.sleep(0.2)
|
| 88 |
+
return "A/B test scheduled · 10% traffic split for 24 h"
|
| 89 |
+
|
| 90 |
+
def _promote_production(ctx, _):
|
| 91 |
+
time.sleep(0.15)
|
| 92 |
+
return "Champion model promoted to Production · old version archived"
|
| 93 |
+
|
| 94 |
+
# ── Data pipeline tasks ────────────────────────────────────────────────────────
|
| 95 |
+
|
| 96 |
+
def _ingest_raw(ctx, _):
|
| 97 |
+
time.sleep(0.2)
|
| 98 |
+
return "Raw data ingested from source"
|
| 99 |
+
|
| 100 |
+
def _clean_data(ctx, _):
|
| 101 |
+
time.sleep(0.3)
|
| 102 |
+
removed = np.random.randint(5, 40)
|
| 103 |
+
return f"{removed} anomalous rows removed · missing values imputed"
|
| 104 |
+
|
| 105 |
+
def _encode_features(ctx, _):
|
| 106 |
+
time.sleep(0.2)
|
| 107 |
+
return "Categorical features one-hot encoded · ordinals label-encoded"
|
| 108 |
+
|
| 109 |
+
def _scale_features(ctx, _):
|
| 110 |
+
time.sleep(0.2)
|
| 111 |
+
return "Numeric features scaled with StandardScaler"
|
| 112 |
+
|
| 113 |
+
def _save_processed(ctx, _):
|
| 114 |
+
time.sleep(0.1)
|
| 115 |
+
return "Processed dataset saved to feature store"
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ── DAG builders ──────────────────────────────────────────────────────────────
|
| 119 |
+
|
| 120 |
+
def build_training_pipeline() -> DAG:
|
| 121 |
+
dag = DAG("training_pipeline",
|
| 122 |
+
"Training Pipeline",
|
| 123 |
+
"End-to-end model training: ingest → preprocess → train → evaluate → register")
|
| 124 |
+
|
| 125 |
+
dag.add_task(Task("load_data", "Load Data", "Fetch dataset from registry",
|
| 126 |
+
_load_data, upstream=[], icon="📥", layer=0))
|
| 127 |
+
dag.add_task(Task("validate", "Validate Data", "Schema & quality checks",
|
| 128 |
+
_validate_data, upstream=["load_data"], icon="✅", layer=1))
|
| 129 |
+
dag.add_task(Task("preprocess", "Preprocess", "Scale & split features",
|
| 130 |
+
_preprocess, upstream=["validate"], icon="🔧", layer=2))
|
| 131 |
+
dag.add_task(Task("feat_eng", "Feature Engineering","Derive new features",
|
| 132 |
+
_feature_engineering, upstream=["preprocess"], icon="⚗️", layer=2))
|
| 133 |
+
dag.add_task(Task("train", "Train Model", "Fit model with MLflow tracking",
|
| 134 |
+
_train_model, upstream=["feat_eng"], icon="🧠", layer=3))
|
| 135 |
+
dag.add_task(Task("evaluate", "Evaluate", "Compute metrics on hold-out set",
|
| 136 |
+
_evaluate_model, upstream=["train"], icon="📊", layer=4))
|
| 137 |
+
dag.add_task(Task("report", "Generate Report", "Write evaluation artefacts",
|
| 138 |
+
_generate_report, upstream=["evaluate"], icon="📝", layer=4))
|
| 139 |
+
dag.add_task(Task("register", "Register Model", "Push model to MLflow Registry",
|
| 140 |
+
_register_model, upstream=["evaluate"], icon="📦", layer=5))
|
| 141 |
+
dag.add_task(Task("deploy_staging", "Deploy to Staging", "Transition registered model to Staging",
|
| 142 |
+
_deploy_staging, upstream=["register"], icon="🚀", layer=6))
|
| 143 |
+
return dag
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def build_retraining_pipeline() -> DAG:
|
| 147 |
+
dag = DAG("retraining_pipeline",
|
| 148 |
+
"Retraining Pipeline",
|
| 149 |
+
"Automated retraining triggered by data drift detection")
|
| 150 |
+
|
| 151 |
+
dag.add_task(Task("drift_check", "Drift Detection", "PSI & KS tests on incoming data",
|
| 152 |
+
_check_drift, upstream=[], icon="📡", layer=0))
|
| 153 |
+
dag.add_task(Task("fetch_data", "Fetch New Data", "Pull latest labelled samples",
|
| 154 |
+
_fetch_new_data, upstream=["drift_check"], icon="🗄️", layer=1))
|
| 155 |
+
dag.add_task(Task("merge", "Merge Datasets", "Combine new + historical data",
|
| 156 |
+
_merge_datasets, upstream=["fetch_data"], icon="🔗", layer=2))
|
| 157 |
+
dag.add_task(Task("retrain", "Retrain Champion", "Retrain best model on merged data",
|
| 158 |
+
_retrain_champion, upstream=["merge"], icon="🔁", layer=3))
|
| 159 |
+
dag.add_task(Task("ab_test", "A/B Test", "Shadow-deploy challenger",
|
| 160 |
+
_ab_test, upstream=["retrain"], icon="🔀", layer=4))
|
| 161 |
+
dag.add_task(Task("promote", "Promote to Prod", "Archive old, promote new champion",
|
| 162 |
+
_promote_production, upstream=["ab_test"], icon="🏆", layer=5))
|
| 163 |
+
return dag
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def build_data_pipeline() -> DAG:
|
| 167 |
+
dag = DAG("data_pipeline",
|
| 168 |
+
"Data Processing Pipeline",
|
| 169 |
+
"Automated feature engineering and data preparation pipeline")
|
| 170 |
+
|
| 171 |
+
dag.add_task(Task("ingest", "Ingest Raw Data", "Pull raw data from sources",
|
| 172 |
+
_ingest_raw, upstream=[], icon="📥", layer=0))
|
| 173 |
+
dag.add_task(Task("clean", "Clean Data", "Remove outliers & impute missing",
|
| 174 |
+
_clean_data, upstream=["ingest"], icon="🧹", layer=1))
|
| 175 |
+
dag.add_task(Task("encode", "Encode Features", "Categorical encoding",
|
| 176 |
+
_encode_features, upstream=["clean"], icon="🔢", layer=2))
|
| 177 |
+
dag.add_task(Task("scale", "Scale Features", "Normalise numeric columns",
|
| 178 |
+
_scale_features, upstream=["encode"], icon="⚖️", layer=3))
|
| 179 |
+
dag.add_task(Task("save", "Save to Feature Store","Persist processed dataset",
|
| 180 |
+
_save_processed, upstream=["scale"], icon="💾", layer=4))
|
| 181 |
+
return dag
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# Singleton instances (rebuilt on each call so context can vary)
|
| 185 |
+
PIPELINE_BUILDERS = {
|
| 186 |
+
"training_pipeline": build_training_pipeline,
|
| 187 |
+
"retraining_pipeline": build_retraining_pipeline,
|
| 188 |
+
"data_pipeline": build_data_pipeline,
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def get_pipeline(pipeline_id: str) -> DAG:
|
| 193 |
+
if pipeline_id not in PIPELINE_BUILDERS:
|
| 194 |
+
raise ValueError(f"Unknown pipeline: {pipeline_id}")
|
| 195 |
+
return PIPELINE_BUILDERS[pipeline_id]()
|
readme-template.md
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: PROJECT_NAME
|
| 3 |
+
colorFrom: COLOR_FROM
|
| 4 |
+
colorTo: COLOR_TO
|
| 5 |
+
sdk: docker
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
<div align="center">
|
| 9 |
+
|
| 10 |
+
<h1>PROJECT_EMOJI PROJECT_NAME</h1>
|
| 11 |
+
<img src="https://readme-typing-svg.demolab.com?font=Fira+Code&size=22&duration=3000&pause=1000&color=COLOR_HEX¢er=true&vCenter=true&width=700&lines=TYPING_LINE_1;TYPING_LINE_2;TYPING_LINE_3" alt="Typing SVG"/>
|
| 12 |
+
|
| 13 |
+
<br/>
|
| 14 |
+
|
| 15 |
+
[](https://www.python.org/)
|
| 16 |
+
[](https://flask.palletsprojects.com/)
|
| 17 |
+
[](https://www.docker.com/)
|
| 18 |
+
[](https://huggingface.co/mnoorchenar/spaces)
|
| 19 |
+
[](#)
|
| 20 |
+
|
| 21 |
+
<br/>
|
| 22 |
+
|
| 23 |
+
**PROJECT_EMOJI PROJECT_NAME** — PROJECT_DESCRIPTION
|
| 24 |
+
|
| 25 |
+
<br/>
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
## Table of Contents
|
| 32 |
+
|
| 33 |
+
- [Features](#-features)
|
| 34 |
+
- [Architecture](#️-architecture)
|
| 35 |
+
- [Getting Started](#-getting-started)
|
| 36 |
+
- [Docker Deployment](#-docker-deployment)
|
| 37 |
+
- [Dashboard Modules](#-dashboard-modules)
|
| 38 |
+
- [ML Models](#-ml-models)
|
| 39 |
+
- [Project Structure](#-project-structure)
|
| 40 |
+
- [Author](#-author)
|
| 41 |
+
- [Contributing](#-contributing)
|
| 42 |
+
- [Disclaimer](#disclaimer)
|
| 43 |
+
- [License](#-license)
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
## ✨ Features
|
| 48 |
+
|
| 49 |
+
<table>
|
| 50 |
+
<tr>
|
| 51 |
+
<td>FEATURE_EMOJI_1 <b>FEATURE_TITLE_1</b></td>
|
| 52 |
+
<td>FEATURE_DESCRIPTION_1</td>
|
| 53 |
+
</tr>
|
| 54 |
+
<tr>
|
| 55 |
+
<td>FEATURE_EMOJI_2 <b>FEATURE_TITLE_2</b></td>
|
| 56 |
+
<td>FEATURE_DESCRIPTION_2</td>
|
| 57 |
+
</tr>
|
| 58 |
+
<tr>
|
| 59 |
+
<td>FEATURE_EMOJI_3 <b>FEATURE_TITLE_3</b></td>
|
| 60 |
+
<td>FEATURE_DESCRIPTION_3</td>
|
| 61 |
+
</tr>
|
| 62 |
+
<tr>
|
| 63 |
+
<td>FEATURE_EMOJI_4 <b>FEATURE_TITLE_4</b></td>
|
| 64 |
+
<td>FEATURE_DESCRIPTION_4</td>
|
| 65 |
+
</tr>
|
| 66 |
+
<tr>
|
| 67 |
+
<td>🔒 <b>Secure by Design</b></td>
|
| 68 |
+
<td>Role-based access, audit logs, encrypted data pipelines</td>
|
| 69 |
+
</tr>
|
| 70 |
+
<tr>
|
| 71 |
+
<td>🐳 <b>Containerized Deployment</b></td>
|
| 72 |
+
<td>Docker-first architecture, cloud-ready and scalable</td>
|
| 73 |
+
</tr>
|
| 74 |
+
</table>
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## 🏗️ Architecture
|
| 79 |
+
|
| 80 |
+
```
|
| 81 |
+
┌─────────────────────────────────────────────────────────┐
|
| 82 |
+
│ PROJECT_NAME │
|
| 83 |
+
│ │
|
| 84 |
+
│ ┌───────────┐ ┌───────────┐ ┌───────────────┐ │
|
| 85 |
+
│ │ Data │───▶│ ML │───▶│ Flask API │ │
|
| 86 |
+
│ │ Sources │ │ Engine │ │ Backend │ │
|
| 87 |
+
│ └───────────┘ └───────────┘ └───────┬───────┘ │
|
| 88 |
+
│ │ │
|
| 89 |
+
│ ┌────────▼────────┐ │
|
| 90 |
+
│ │ Plotly Dash │ │
|
| 91 |
+
│ │ Dashboard │ │
|
| 92 |
+
│ └─────────────────┘ │
|
| 93 |
+
└─────────────────────────────────────────────────────────┘
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
## 🚀 Getting Started
|
| 99 |
+
|
| 100 |
+
### Prerequisites
|
| 101 |
+
|
| 102 |
+
- Python 3.10+
|
| 103 |
+
- Docker & Docker Compose
|
| 104 |
+
- Git
|
| 105 |
+
|
| 106 |
+
### Local Installation
|
| 107 |
+
|
| 108 |
+
```bash
|
| 109 |
+
# 1. Clone the repository
|
| 110 |
+
git clone https://github.com/mnoorchenar/PROJECT_NAME.git
|
| 111 |
+
cd PROJECT_NAME
|
| 112 |
+
|
| 113 |
+
# 2. Create a virtual environment
|
| 114 |
+
python -m venv venv
|
| 115 |
+
source venv/bin/activate # Windows: venv\Scripts\activate
|
| 116 |
+
|
| 117 |
+
# 3. Install dependencies
|
| 118 |
+
pip install -r requirements.txt
|
| 119 |
+
|
| 120 |
+
# 4. Configure environment variables
|
| 121 |
+
cp .env.example .env
|
| 122 |
+
# Edit .env with your settings
|
| 123 |
+
|
| 124 |
+
# 5. Run the application
|
| 125 |
+
python app.py
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
Open your browser at `http://localhost:7860` 🎉
|
| 129 |
+
|
| 130 |
+
---
|
| 131 |
+
|
| 132 |
+
## 🐳 Docker Deployment
|
| 133 |
+
|
| 134 |
+
```bash
|
| 135 |
+
# Build and run with Docker Compose
|
| 136 |
+
docker compose up --build
|
| 137 |
+
|
| 138 |
+
# Or pull and run the pre-built image
|
| 139 |
+
docker pull mnoorchenar/PROJECT_NAME
|
| 140 |
+
docker run -p 7860:7860 mnoorchenar/PROJECT_NAME
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
## 📊 Dashboard Modules
|
| 146 |
+
|
| 147 |
+
| Module | Description | Status |
|
| 148 |
+
|--------|-------------|--------|
|
| 149 |
+
| MODULE_EMOJI_1 MODULE_NAME_1 | MODULE_DESC_1 | ✅ Live |
|
| 150 |
+
| MODULE_EMOJI_2 MODULE_NAME_2 | MODULE_DESC_2 | ✅ Live |
|
| 151 |
+
| MODULE_EMOJI_3 MODULE_NAME_3 | MODULE_DESC_3 | ✅ Live |
|
| 152 |
+
| MODULE_EMOJI_4 MODULE_NAME_4 | MODULE_DESC_4 | 🔄 Beta |
|
| 153 |
+
| MODULE_EMOJI_5 MODULE_NAME_5 | MODULE_DESC_5 | ✅ Live |
|
| 154 |
+
| MODULE_EMOJI_6 MODULE_NAME_6 | MODULE_DESC_6 | 🗓️ Planned |
|
| 155 |
+
|
| 156 |
+
---
|
| 157 |
+
|
| 158 |
+
## 🧠 ML Models
|
| 159 |
+
|
| 160 |
+
```python
|
| 161 |
+
# Core Models Used in PROJECT_NAME
|
| 162 |
+
models = {
|
| 163 |
+
"MODEL_KEY_1": "MODEL_VALUE_1",
|
| 164 |
+
"MODEL_KEY_2": "MODEL_VALUE_2",
|
| 165 |
+
"MODEL_KEY_3": "MODEL_VALUE_3",
|
| 166 |
+
"MODEL_KEY_4": "MODEL_VALUE_4",
|
| 167 |
+
"MODEL_KEY_5": "MODEL_VALUE_5"
|
| 168 |
+
}
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
---
|
| 172 |
+
|
| 173 |
+
## 📁 Project Structure
|
| 174 |
+
|
| 175 |
+
```
|
| 176 |
+
PROJECT_NAME/
|
| 177 |
+
│
|
| 178 |
+
├── 📂 app/
|
| 179 |
+
│ ├── 📂 models/ # ML model definitions & loaders
|
| 180 |
+
│ ├── 📂 routes/ # Flask API endpoints
|
| 181 |
+
│ ├── 📂 dashboards/ # Plotly Dash layouts
|
| 182 |
+
│ └── 📂 utils/ # Helpers, preprocessing, logging
|
| 183 |
+
│
|
| 184 |
+
├── 📂 data/
|
| 185 |
+
│ ├── 📂 raw/ # Raw data sources
|
| 186 |
+
│ └── 📂 processed/ # Feature-engineered datasets
|
| 187 |
+
│
|
| 188 |
+
├── 📂 notebooks/ # Exploratory analysis & model training
|
| 189 |
+
├── 📂 tests/ # Unit and integration tests
|
| 190 |
+
├── 📄 app.py # Application entry point
|
| 191 |
+
├── 📄 Dockerfile # Container definition
|
| 192 |
+
├── 📄 docker-compose.yml # Multi-service orchestration
|
| 193 |
+
├── 📄 requirements.txt # Python dependencies
|
| 194 |
+
└── 📄 .env.example # Environment variable template
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
---
|
| 198 |
+
|
| 199 |
+
## 👨💻 Author
|
| 200 |
+
|
| 201 |
+
<div align="center">
|
| 202 |
+
|
| 203 |
+
<table>
|
| 204 |
+
<tr>
|
| 205 |
+
<td align="center" width="100%">
|
| 206 |
+
|
| 207 |
+
<img src="https://avatars.githubusercontent.com/mnoorchenar" width="120" style="border-radius:50%; border: 3px solid #4f46e5;" alt="Mohammad Noorchenarboo"/>
|
| 208 |
+
|
| 209 |
+
<h3>Mohammad Noorchenarboo</h3>
|
| 210 |
+
|
| 211 |
+
<code>Data Scientist</code> | <code>AI Researcher</code> | <code>Biostatistician</code>
|
| 212 |
+
|
| 213 |
+
📍 Ontario, Canada 📧 [mohammadnoorchenarboo@gmail.com](mailto:mohammadnoorchenarboo@gmail.com)
|
| 214 |
+
|
| 215 |
+
──────────────────────────────────────
|
| 216 |
+
|
| 217 |
+
[](https://www.linkedin.com/in/mnoorchenar)
|
| 218 |
+
[](https://mnoorchenar.github.io/)
|
| 219 |
+
[](https://huggingface.co/mnoorchenar/spaces)
|
| 220 |
+
[](https://scholar.google.ca/citations?user=nn_Toq0AAAAJ&hl=en)
|
| 221 |
+
[](https://github.com/mnoorchenar)
|
| 222 |
+
|
| 223 |
+
</td>
|
| 224 |
+
</tr>
|
| 225 |
+
</table>
|
| 226 |
+
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
---
|
| 230 |
+
|
| 231 |
+
## 🤝 Contributing
|
| 232 |
+
|
| 233 |
+
Contributions are welcome! Please follow these steps:
|
| 234 |
+
|
| 235 |
+
1. **Fork** the repository
|
| 236 |
+
2. **Create** a feature branch: `git checkout -b feature/amazing-feature`
|
| 237 |
+
3. **Commit** your changes: `git commit -m 'Add amazing feature'`
|
| 238 |
+
4. **Push** to the branch: `git push origin feature/amazing-feature`
|
| 239 |
+
5. **Open** a Pull Request
|
| 240 |
+
|
| 241 |
+
---
|
| 242 |
+
|
| 243 |
+
## Disclaimer
|
| 244 |
+
|
| 245 |
+
<span style="color:red">This project is developed strictly for educational and research purposes and does not constitute professional advice of any kind. All datasets used are either synthetically generated or publicly available — no real user data is stored. This software is provided "as is" without warranty of any kind; use at your own risk.</span>
|
| 246 |
+
|
| 247 |
+
---
|
| 248 |
+
|
| 249 |
+
## 📜 License
|
| 250 |
+
|
| 251 |
+
Distributed under the **MIT License**. See [`LICENSE`](LICENSE) for more information.
|
| 252 |
+
|
| 253 |
+
---
|
| 254 |
+
|
| 255 |
+
<div align="center">
|
| 256 |
+
|
| 257 |
+
<img src="https://capsule-render.vercel.app/api?type=waving&color=0:3b82f6,100:4f46e5&height=120§ion=footer&text=Made%20with%20%E2%9D%A4%EF%B8%8F%20by%20Mohammad%20Noorchenarboo&fontColor=ffffff&fontSize=18&fontAlignY=80" width="100%"/>
|
| 258 |
+
|
| 259 |
+
[](https://github.com/mnoorchenar/PROJECT_NAME)
|
| 260 |
+
[](https://github.com/mnoorchenar/PROJECT_NAME/fork)
|
| 261 |
+
|
| 262 |
+
<sub>The name "PROJECT_NAME" is used purely for academic and research purposes. Any similarity to existing company names, products, or trademarks is entirely coincidental and unintentional. This project has no affiliation with any commercial entity.</sub>
|
| 263 |
+
|
| 264 |
+
</div>
|
requirements.txt
CHANGED
|
@@ -1 +1,10 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask==3.0.0
|
| 2 |
+
mlflow==2.14.1
|
| 3 |
+
scikit-learn==1.4.2
|
| 4 |
+
pandas==2.2.2
|
| 5 |
+
numpy==1.26.4
|
| 6 |
+
plotly==5.22.0
|
| 7 |
+
xgboost==2.0.3
|
| 8 |
+
lightgbm==4.3.0
|
| 9 |
+
gunicorn==22.0.0
|
| 10 |
+
scipy==1.13.0
|
static/css/style.css
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ── AutoMLOps — Dark Theme ─────────────────────────────────────────────── */
|
| 2 |
+
:root {
|
| 3 |
+
--bg-primary: #0d1117;
|
| 4 |
+
--bg-secondary: #161b22;
|
| 5 |
+
--bg-tertiary: #21262d;
|
| 6 |
+
--bg-hover: #30363d44;
|
| 7 |
+
--border-color: #30363d;
|
| 8 |
+
--text-primary: #e6edf3;
|
| 9 |
+
--text-secondary:#8b949e;
|
| 10 |
+
--text-muted: #656d76;
|
| 11 |
+
--accent: #8b5cf6;
|
| 12 |
+
--accent-light: #a78bfa;
|
| 13 |
+
--accent-blue: #3b82f6;
|
| 14 |
+
--success: #22c55e;
|
| 15 |
+
--warning: #f59e0b;
|
| 16 |
+
--danger: #ef4444;
|
| 17 |
+
--cyan: #06b6d4;
|
| 18 |
+
--sidebar-w: 240px;
|
| 19 |
+
--navbar-h: 56px;
|
| 20 |
+
--radius: 10px;
|
| 21 |
+
--radius-sm: 6px;
|
| 22 |
+
--shadow: 0 4px 24px rgba(0,0,0,.45);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/* ── Reset / base ─────────────────────────────────────────────────────────── */
|
| 26 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 27 |
+
html { font-size: 15px; }
|
| 28 |
+
body {
|
| 29 |
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
| 30 |
+
background: var(--bg-primary);
|
| 31 |
+
color: var(--text-primary);
|
| 32 |
+
min-height: 100vh;
|
| 33 |
+
overflow-x: hidden;
|
| 34 |
+
}
|
| 35 |
+
a { color: var(--accent-light); text-decoration: none; }
|
| 36 |
+
a:hover { color: var(--accent); }
|
| 37 |
+
|
| 38 |
+
/* ── Scrollbar ────────────────────────────────────────────────────────────── */
|
| 39 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 40 |
+
::-webkit-scrollbar-track { background: var(--bg-secondary); }
|
| 41 |
+
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
|
| 42 |
+
|
| 43 |
+
/* ── Sidebar ──────────────────────────────────────────────────────────────── */
|
| 44 |
+
.sidebar {
|
| 45 |
+
position: fixed;
|
| 46 |
+
top: 0; left: 0;
|
| 47 |
+
width: var(--sidebar-w);
|
| 48 |
+
height: 100vh;
|
| 49 |
+
background: var(--bg-secondary);
|
| 50 |
+
border-right: 1px solid var(--border-color);
|
| 51 |
+
display: flex;
|
| 52 |
+
flex-direction: column;
|
| 53 |
+
z-index: 100;
|
| 54 |
+
transition: transform .25s ease;
|
| 55 |
+
}
|
| 56 |
+
.sidebar-logo {
|
| 57 |
+
display: flex; align-items: center; gap: 10px;
|
| 58 |
+
padding: 18px 20px 16px;
|
| 59 |
+
border-bottom: 1px solid var(--border-color);
|
| 60 |
+
}
|
| 61 |
+
.sidebar-logo-icon {
|
| 62 |
+
width: 34px; height: 34px; border-radius: 8px;
|
| 63 |
+
background: linear-gradient(135deg, var(--accent), var(--accent-blue));
|
| 64 |
+
display: flex; align-items: center; justify-content: center;
|
| 65 |
+
font-size: 17px;
|
| 66 |
+
}
|
| 67 |
+
.sidebar-logo-text { font-weight: 700; font-size: 1rem; letter-spacing: -.3px; }
|
| 68 |
+
.sidebar-logo-sub { font-size: .68rem; color: var(--text-muted); }
|
| 69 |
+
|
| 70 |
+
.sidebar-nav { flex: 1; overflow-y: auto; padding: 12px 0; }
|
| 71 |
+
.nav-section-label {
|
| 72 |
+
padding: 14px 20px 4px;
|
| 73 |
+
font-size: .68rem;
|
| 74 |
+
font-weight: 600;
|
| 75 |
+
letter-spacing: .08em;
|
| 76 |
+
text-transform: uppercase;
|
| 77 |
+
color: var(--text-muted);
|
| 78 |
+
}
|
| 79 |
+
.nav-item {
|
| 80 |
+
display: flex; align-items: center; gap: 10px;
|
| 81 |
+
padding: 8px 20px;
|
| 82 |
+
color: var(--text-secondary);
|
| 83 |
+
cursor: pointer;
|
| 84 |
+
border-radius: 0;
|
| 85 |
+
transition: background .15s, color .15s;
|
| 86 |
+
font-size: .9rem;
|
| 87 |
+
}
|
| 88 |
+
.nav-item:hover { background: var(--bg-hover); color: var(--text-primary); }
|
| 89 |
+
.nav-item.active { color: var(--accent-light); background: #8b5cf618; }
|
| 90 |
+
.nav-item .nav-icon { width: 18px; text-align: center; font-size: .95rem; }
|
| 91 |
+
.nav-badge {
|
| 92 |
+
margin-left: auto;
|
| 93 |
+
background: var(--accent);
|
| 94 |
+
color: #fff;
|
| 95 |
+
font-size: .62rem;
|
| 96 |
+
font-weight: 700;
|
| 97 |
+
padding: 1px 6px;
|
| 98 |
+
border-radius: 10px;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.sidebar-footer {
|
| 102 |
+
padding: 14px 20px;
|
| 103 |
+
border-top: 1px solid var(--border-color);
|
| 104 |
+
font-size: .78rem;
|
| 105 |
+
color: var(--text-muted);
|
| 106 |
+
}
|
| 107 |
+
.sidebar-footer a { color: var(--text-muted); }
|
| 108 |
+
.sidebar-footer a:hover { color: var(--accent-light); }
|
| 109 |
+
|
| 110 |
+
/* ── Top navbar ───────────────────────────────────────────────────────────── */
|
| 111 |
+
.topnav {
|
| 112 |
+
position: fixed;
|
| 113 |
+
top: 0; left: var(--sidebar-w); right: 0;
|
| 114 |
+
height: var(--navbar-h);
|
| 115 |
+
background: var(--bg-secondary);
|
| 116 |
+
border-bottom: 1px solid var(--border-color);
|
| 117 |
+
display: flex; align-items: center;
|
| 118 |
+
padding: 0 24px;
|
| 119 |
+
z-index: 90;
|
| 120 |
+
gap: 12px;
|
| 121 |
+
}
|
| 122 |
+
.topnav-title { font-weight: 600; font-size: 1rem; flex: 1; }
|
| 123 |
+
.topnav-badge {
|
| 124 |
+
background: linear-gradient(135deg, var(--accent), var(--accent-blue));
|
| 125 |
+
color: #fff;
|
| 126 |
+
font-size: .7rem;
|
| 127 |
+
font-weight: 700;
|
| 128 |
+
padding: 3px 10px;
|
| 129 |
+
border-radius: 20px;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/* ── Main content ─────────────────────────────────────────────────────────── */
|
| 133 |
+
.main {
|
| 134 |
+
margin-left: var(--sidebar-w);
|
| 135 |
+
padding-top: var(--navbar-h);
|
| 136 |
+
min-height: 100vh;
|
| 137 |
+
}
|
| 138 |
+
.page-content { padding: 28px 32px; }
|
| 139 |
+
|
| 140 |
+
/* ── Cards ────────────────���───────────────────────────────────────────────── */
|
| 141 |
+
.card {
|
| 142 |
+
background: var(--bg-secondary);
|
| 143 |
+
border: 1px solid var(--border-color);
|
| 144 |
+
border-radius: var(--radius);
|
| 145 |
+
padding: 20px;
|
| 146 |
+
}
|
| 147 |
+
.card-header {
|
| 148 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 149 |
+
margin-bottom: 16px;
|
| 150 |
+
}
|
| 151 |
+
.card-title { font-weight: 600; font-size: .95rem; display: flex; align-items: center; gap: 8px; }
|
| 152 |
+
.card-sm { padding: 14px 18px; }
|
| 153 |
+
|
| 154 |
+
/* ── Stat cards ───────────────────────────────────────────────────────────── */
|
| 155 |
+
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
| 156 |
+
.stat-card {
|
| 157 |
+
background: var(--bg-secondary);
|
| 158 |
+
border: 1px solid var(--border-color);
|
| 159 |
+
border-radius: var(--radius);
|
| 160 |
+
padding: 20px 22px;
|
| 161 |
+
position: relative;
|
| 162 |
+
overflow: hidden;
|
| 163 |
+
}
|
| 164 |
+
.stat-card::before {
|
| 165 |
+
content: '';
|
| 166 |
+
position: absolute;
|
| 167 |
+
top: 0; left: 0; right: 0;
|
| 168 |
+
height: 3px;
|
| 169 |
+
}
|
| 170 |
+
.stat-card.purple::before { background: linear-gradient(90deg, var(--accent), var(--accent-light)); }
|
| 171 |
+
.stat-card.blue::before { background: linear-gradient(90deg, var(--accent-blue), var(--cyan)); }
|
| 172 |
+
.stat-card.green::before { background: linear-gradient(90deg, var(--success), #4ade80); }
|
| 173 |
+
.stat-card.yellow::before { background: linear-gradient(90deg, var(--warning), #fcd34d); }
|
| 174 |
+
.stat-label { font-size: .78rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; margin-bottom: 6px; }
|
| 175 |
+
.stat-value { font-size: 2rem; font-weight: 700; letter-spacing: -.5px; }
|
| 176 |
+
.stat-sub { font-size: .78rem; color: var(--text-secondary); margin-top: 4px; }
|
| 177 |
+
|
| 178 |
+
/* ── Badges / pills ───────────────────────────────────────────────────────── */
|
| 179 |
+
.badge {
|
| 180 |
+
display: inline-block;
|
| 181 |
+
padding: 2px 9px;
|
| 182 |
+
border-radius: 12px;
|
| 183 |
+
font-size: .72rem;
|
| 184 |
+
font-weight: 600;
|
| 185 |
+
}
|
| 186 |
+
.badge-success { background: #22c55e22; color: var(--success); }
|
| 187 |
+
.badge-warning { background: #f59e0b22; color: var(--warning); }
|
| 188 |
+
.badge-danger { background: #ef444422; color: var(--danger); }
|
| 189 |
+
.badge-info { background: #3b82f622; color: var(--accent-blue); }
|
| 190 |
+
.badge-purple { background: #8b5cf622; color: var(--accent-light); }
|
| 191 |
+
.badge-muted { background: var(--bg-tertiary); color: var(--text-secondary); }
|
| 192 |
+
|
| 193 |
+
.stage-production { background: #22c55e22; color: var(--success); }
|
| 194 |
+
.stage-staging { background: #f59e0b22; color: var(--warning); }
|
| 195 |
+
.stage-archived { background: #ef444422; color: var(--danger); }
|
| 196 |
+
.stage-none { background: var(--bg-tertiary); color: var(--text-muted); }
|
| 197 |
+
|
| 198 |
+
/* ── Buttons ──────────────────────────────────────────────────────────────── */
|
| 199 |
+
.btn {
|
| 200 |
+
display: inline-flex; align-items: center; gap: 7px;
|
| 201 |
+
padding: 8px 18px;
|
| 202 |
+
border-radius: var(--radius-sm);
|
| 203 |
+
font-size: .85rem;
|
| 204 |
+
font-weight: 500;
|
| 205 |
+
cursor: pointer;
|
| 206 |
+
border: none;
|
| 207 |
+
transition: opacity .15s, transform .1s;
|
| 208 |
+
white-space: nowrap;
|
| 209 |
+
}
|
| 210 |
+
.btn:hover { opacity: .88; }
|
| 211 |
+
.btn:active { transform: scale(.97); }
|
| 212 |
+
.btn-primary { background: var(--accent); color: #fff; }
|
| 213 |
+
.btn-blue { background: var(--accent-blue); color: #fff; }
|
| 214 |
+
.btn-success { background: var(--success); color: #fff; }
|
| 215 |
+
.btn-warning { background: var(--warning); color: #000; }
|
| 216 |
+
.btn-danger { background: var(--danger); color: #fff; }
|
| 217 |
+
.btn-ghost { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-color); }
|
| 218 |
+
.btn-ghost:hover { color: var(--text-primary); border-color: var(--text-secondary); background: var(--bg-hover); }
|
| 219 |
+
.btn-sm { padding: 5px 12px; font-size: .8rem; }
|
| 220 |
+
.btn-lg { padding: 11px 24px; font-size: .95rem; }
|
| 221 |
+
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
| 222 |
+
|
| 223 |
+
/* ── Tables ───────────────────────────────────────────────────────────────── */
|
| 224 |
+
.table-wrap { overflow-x: auto; }
|
| 225 |
+
table { width: 100%; border-collapse: collapse; }
|
| 226 |
+
thead tr { border-bottom: 1px solid var(--border-color); }
|
| 227 |
+
th {
|
| 228 |
+
text-align: left;
|
| 229 |
+
padding: 10px 14px;
|
| 230 |
+
font-size: .78rem;
|
| 231 |
+
font-weight: 600;
|
| 232 |
+
color: var(--text-muted);
|
| 233 |
+
text-transform: uppercase;
|
| 234 |
+
letter-spacing: .05em;
|
| 235 |
+
white-space: nowrap;
|
| 236 |
+
}
|
| 237 |
+
td { padding: 11px 14px; font-size: .88rem; border-bottom: 1px solid #30363d55; }
|
| 238 |
+
tr:last-child td { border-bottom: none; }
|
| 239 |
+
tr:hover td { background: var(--bg-hover); }
|
| 240 |
+
|
| 241 |
+
/* ── Forms ─────────────────────────────────────────────────────────────��──── */
|
| 242 |
+
.form-group { margin-bottom: 16px; }
|
| 243 |
+
.form-label { display: block; font-size: .82rem; font-weight: 500; color: var(--text-secondary); margin-bottom: 6px; }
|
| 244 |
+
.form-control, .form-select {
|
| 245 |
+
width: 100%;
|
| 246 |
+
background: var(--bg-tertiary);
|
| 247 |
+
border: 1px solid var(--border-color);
|
| 248 |
+
border-radius: var(--radius-sm);
|
| 249 |
+
color: var(--text-primary);
|
| 250 |
+
padding: 9px 12px;
|
| 251 |
+
font-size: .88rem;
|
| 252 |
+
outline: none;
|
| 253 |
+
transition: border-color .15s;
|
| 254 |
+
appearance: none;
|
| 255 |
+
}
|
| 256 |
+
.form-control:focus, .form-select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px #8b5cf620; }
|
| 257 |
+
.form-select {
|
| 258 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b949e' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
| 259 |
+
background-repeat: no-repeat;
|
| 260 |
+
background-position: right 10px center;
|
| 261 |
+
padding-right: 32px;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
/* ── Progress bars ────────────────────────────────────────────────────────── */
|
| 265 |
+
.progress-bar-wrap {
|
| 266 |
+
background: var(--bg-tertiary);
|
| 267 |
+
border-radius: 99px;
|
| 268 |
+
height: 8px;
|
| 269 |
+
overflow: hidden;
|
| 270 |
+
}
|
| 271 |
+
.progress-bar {
|
| 272 |
+
height: 100%;
|
| 273 |
+
border-radius: 99px;
|
| 274 |
+
background: linear-gradient(90deg, var(--accent), var(--accent-blue));
|
| 275 |
+
transition: width .4s ease;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
/* ── Tabs ─────────────────────────────────────────────────────────────────── */
|
| 279 |
+
.tab-bar {
|
| 280 |
+
display: flex;
|
| 281 |
+
gap: 2px;
|
| 282 |
+
border-bottom: 1px solid var(--border-color);
|
| 283 |
+
margin-bottom: 20px;
|
| 284 |
+
}
|
| 285 |
+
.tab-btn {
|
| 286 |
+
padding: 9px 18px;
|
| 287 |
+
font-size: .87rem;
|
| 288 |
+
font-weight: 500;
|
| 289 |
+
color: var(--text-secondary);
|
| 290 |
+
background: transparent;
|
| 291 |
+
border: none;
|
| 292 |
+
border-bottom: 2px solid transparent;
|
| 293 |
+
cursor: pointer;
|
| 294 |
+
transition: color .15s, border-color .15s;
|
| 295 |
+
margin-bottom: -1px;
|
| 296 |
+
}
|
| 297 |
+
.tab-btn:hover { color: var(--text-primary); }
|
| 298 |
+
.tab-btn.active { color: var(--accent-light); border-bottom-color: var(--accent); }
|
| 299 |
+
.tab-panel { display: none; }
|
| 300 |
+
.tab-panel.active { display: block; }
|
| 301 |
+
|
| 302 |
+
/* ── Modals ───────────────────────────────────────────────────────────────── */
|
| 303 |
+
.modal-overlay {
|
| 304 |
+
position: fixed; inset: 0;
|
| 305 |
+
background: rgba(0,0,0,.65);
|
| 306 |
+
display: flex; align-items: center; justify-content: center;
|
| 307 |
+
z-index: 200;
|
| 308 |
+
opacity: 0; pointer-events: none;
|
| 309 |
+
transition: opacity .2s;
|
| 310 |
+
}
|
| 311 |
+
.modal-overlay.open { opacity: 1; pointer-events: all; }
|
| 312 |
+
.modal {
|
| 313 |
+
background: var(--bg-secondary);
|
| 314 |
+
border: 1px solid var(--border-color);
|
| 315 |
+
border-radius: var(--radius);
|
| 316 |
+
padding: 24px;
|
| 317 |
+
width: min(540px, 95vw);
|
| 318 |
+
max-height: 90vh;
|
| 319 |
+
overflow-y: auto;
|
| 320 |
+
transform: translateY(12px);
|
| 321 |
+
transition: transform .2s;
|
| 322 |
+
}
|
| 323 |
+
.modal-overlay.open .modal { transform: translateY(0); }
|
| 324 |
+
.modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
|
| 325 |
+
.modal-title { font-size: 1.05rem; font-weight: 600; }
|
| 326 |
+
.modal-close { background: none; border: none; color: var(--text-secondary); cursor: pointer; font-size: 1.1rem; }
|
| 327 |
+
.modal-close:hover { color: var(--danger); }
|
| 328 |
+
.modal-footer { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
| 329 |
+
|
| 330 |
+
/* ── Toast notifications ──────────────────────────────────────────────────── */
|
| 331 |
+
#toast-area {
|
| 332 |
+
position: fixed; bottom: 24px; right: 24px;
|
| 333 |
+
z-index: 300; display: flex; flex-direction: column; gap: 10px;
|
| 334 |
+
}
|
| 335 |
+
.toast {
|
| 336 |
+
background: var(--bg-tertiary);
|
| 337 |
+
border: 1px solid var(--border-color);
|
| 338 |
+
border-radius: var(--radius-sm);
|
| 339 |
+
padding: 12px 18px;
|
| 340 |
+
font-size: .86rem;
|
| 341 |
+
box-shadow: var(--shadow);
|
| 342 |
+
display: flex; align-items: center; gap: 10px;
|
| 343 |
+
animation: slideIn .25s ease;
|
| 344 |
+
min-width: 240px;
|
| 345 |
+
}
|
| 346 |
+
.toast.success { border-left: 3px solid var(--success); }
|
| 347 |
+
.toast.error { border-left: 3px solid var(--danger); }
|
| 348 |
+
.toast.info { border-left: 3px solid var(--accent); }
|
| 349 |
+
@keyframes slideIn { from { transform: translateX(40px); opacity: 0; } to { transform: none; opacity: 1; } }
|
| 350 |
+
|
| 351 |
+
/* ── Pipeline / DAG canvas ────────────────────────────────────────────────── */
|
| 352 |
+
.dag-canvas { background: var(--bg-primary); border-radius: var(--radius); min-height: 300px; }
|
| 353 |
+
.pipeline-log {
|
| 354 |
+
background: var(--bg-primary);
|
| 355 |
+
border: 1px solid var(--border-color);
|
| 356 |
+
border-radius: var(--radius-sm);
|
| 357 |
+
padding: 14px;
|
| 358 |
+
font-family: 'Fira Code', 'Courier New', monospace;
|
| 359 |
+
font-size: .82rem;
|
| 360 |
+
color: var(--text-secondary);
|
| 361 |
+
max-height: 240px;
|
| 362 |
+
overflow-y: auto;
|
| 363 |
+
line-height: 1.7;
|
| 364 |
+
}
|
| 365 |
+
.log-line-ok { color: var(--success); }
|
| 366 |
+
.log-line-err { color: var(--danger); }
|
| 367 |
+
.log-line-info { color: var(--accent-light); }
|
| 368 |
+
|
| 369 |
+
/* ── AutoML leaderboard ───────────────────────────────────────────────────── */
|
| 370 |
+
.leaderboard-row {
|
| 371 |
+
display: grid;
|
| 372 |
+
grid-template-columns: 30px 1fr auto auto;
|
| 373 |
+
align-items: center;
|
| 374 |
+
gap: 12px;
|
| 375 |
+
padding: 10px 14px;
|
| 376 |
+
border-radius: var(--radius-sm);
|
| 377 |
+
transition: background .15s;
|
| 378 |
+
}
|
| 379 |
+
.leaderboard-row:hover { background: var(--bg-hover); }
|
| 380 |
+
.rank-badge {
|
| 381 |
+
width: 26px; height: 26px; border-radius: 50%;
|
| 382 |
+
display: flex; align-items: center; justify-content: center;
|
| 383 |
+
font-weight: 700; font-size: .8rem;
|
| 384 |
+
}
|
| 385 |
+
.rank-1 { background: #f59e0b33; color: var(--warning); }
|
| 386 |
+
.rank-2 { background: #8b949e33; color: var(--text-secondary); }
|
| 387 |
+
.rank-3 { background: #d97706aa; color: #d97706; }
|
| 388 |
+
.rank-n { background: var(--bg-tertiary); color: var(--text-muted); }
|
| 389 |
+
|
| 390 |
+
/* ── Metric value highlight ───────────────────────────────────────────────── */
|
| 391 |
+
.metric-val {
|
| 392 |
+
font-weight: 700;
|
| 393 |
+
font-size: .95rem;
|
| 394 |
+
font-variant-numeric: tabular-nums;
|
| 395 |
+
}
|
| 396 |
+
.metric-good { color: var(--success); }
|
| 397 |
+
.metric-medium { color: var(--warning); }
|
| 398 |
+
.metric-bad { color: var(--danger); }
|
| 399 |
+
|
| 400 |
+
/* ── Section headings ─────────────────────────────────────────────────────── */
|
| 401 |
+
.page-title { font-size: 1.4rem; font-weight: 700; margin-bottom: 4px; }
|
| 402 |
+
.page-sub { font-size: .88rem; color: var(--text-secondary); margin-bottom: 24px; }
|
| 403 |
+
|
| 404 |
+
/* ── Grid helpers ─────────────────────────────────────────────────────────── */
|
| 405 |
+
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
| 406 |
+
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
| 407 |
+
.flex-between { display: flex; align-items: center; justify-content: space-between; }
|
| 408 |
+
.flex-gap { display: flex; align-items: center; gap: 10px; }
|
| 409 |
+
.mt-4 { margin-top: 4px; }
|
| 410 |
+
.mt-8 { margin-top: 8px; }
|
| 411 |
+
.mt-12 { margin-top: 12px; }
|
| 412 |
+
.mt-20 { margin-top: 20px; }
|
| 413 |
+
.mb-20 { margin-bottom: 20px; }
|
| 414 |
+
.gap-12 { gap: 12px; }
|
| 415 |
+
|
| 416 |
+
/* ── Responsive ───────────────────────────────────────────────────────────── */
|
| 417 |
+
@media (max-width: 768px) {
|
| 418 |
+
.sidebar { transform: translateX(-100%); }
|
| 419 |
+
.sidebar.open { transform: none; }
|
| 420 |
+
.main, .topnav { margin-left: 0; left: 0; }
|
| 421 |
+
.page-content { padding: 16px; }
|
| 422 |
+
.grid-2, .grid-3 { grid-template-columns: 1fr; }
|
| 423 |
+
.stat-grid { grid-template-columns: 1fr 1fr; }
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
/* ── Plotly overrides ─────────────────────────────────────────────────────── */
|
| 427 |
+
.js-plotly-plot .plotly { border-radius: var(--radius-sm); }
|
| 428 |
+
.modebar { display: none !important; }
|
| 429 |
+
|
| 430 |
+
/* ── Category pill row ────────────────────────────────────────────────────── */
|
| 431 |
+
.category-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
|
| 432 |
+
.category-pill {
|
| 433 |
+
padding: 4px 12px;
|
| 434 |
+
border-radius: 20px;
|
| 435 |
+
font-size: .78rem;
|
| 436 |
+
font-weight: 500;
|
| 437 |
+
background: var(--bg-tertiary);
|
| 438 |
+
color: var(--text-secondary);
|
| 439 |
+
border: 1px solid var(--border-color);
|
| 440 |
+
cursor: pointer;
|
| 441 |
+
transition: all .15s;
|
| 442 |
+
}
|
| 443 |
+
.category-pill:hover, .category-pill.active {
|
| 444 |
+
background: var(--accent);
|
| 445 |
+
color: #fff;
|
| 446 |
+
border-color: var(--accent);
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
/* ── Algorithm card grid ──────────────────────────────────────────────────── */
|
| 450 |
+
.algo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; }
|
| 451 |
+
.algo-card {
|
| 452 |
+
background: var(--bg-tertiary);
|
| 453 |
+
border: 1px solid var(--border-color);
|
| 454 |
+
border-radius: var(--radius-sm);
|
| 455 |
+
padding: 12px 14px;
|
| 456 |
+
cursor: pointer;
|
| 457 |
+
transition: border-color .15s, background .15s;
|
| 458 |
+
position: relative;
|
| 459 |
+
}
|
| 460 |
+
.algo-card:hover { border-color: var(--accent); background: #8b5cf610; }
|
| 461 |
+
.algo-card.selected { border-color: var(--accent); background: #8b5cf618; }
|
| 462 |
+
.algo-card .algo-name { font-weight: 500; font-size: .85rem; margin-bottom: 3px; }
|
| 463 |
+
.algo-card .algo-desc { font-size: .75rem; color: var(--text-muted); line-height: 1.4; }
|
| 464 |
+
.algo-card .algo-dot {
|
| 465 |
+
width: 8px; height: 8px; border-radius: 50%;
|
| 466 |
+
position: absolute; top: 12px; right: 12px;
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
/* ── Spinning loader ──────────────────────────────────────────────────────── */
|
| 470 |
+
.spinner {
|
| 471 |
+
width: 20px; height: 20px; border-radius: 50%;
|
| 472 |
+
border: 2px solid var(--border-color);
|
| 473 |
+
border-top-color: var(--accent);
|
| 474 |
+
animation: spin .6s linear infinite;
|
| 475 |
+
display: inline-block;
|
| 476 |
+
}
|
| 477 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 478 |
+
|
| 479 |
+
/* ── Empty state ──────────────────────────────────────────────────────────── */
|
| 480 |
+
.empty-state {
|
| 481 |
+
text-align: center;
|
| 482 |
+
padding: 48px 20px;
|
| 483 |
+
color: var(--text-muted);
|
| 484 |
+
}
|
| 485 |
+
.empty-state-icon { font-size: 3rem; margin-bottom: 12px; }
|
| 486 |
+
.empty-state-title { font-size: 1rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
|
static/js/app.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* AutoMLOps — shared utilities */
|
| 2 |
+
|
| 3 |
+
// ── Toast notifications ────────────────────────────────────────────────────
|
| 4 |
+
function showToast(message, type = 'info', duration = 3500) {
|
| 5 |
+
const area = document.getElementById('toast-area');
|
| 6 |
+
if (!area) return;
|
| 7 |
+
|
| 8 |
+
const icons = { success: '✅', error: '❌', info: 'ℹ️' };
|
| 9 |
+
const toast = document.createElement('div');
|
| 10 |
+
toast.className = `toast ${type}`;
|
| 11 |
+
toast.innerHTML = `<span>${icons[type] || 'ℹ️'}</span><span>${message}</span>`;
|
| 12 |
+
area.appendChild(toast);
|
| 13 |
+
|
| 14 |
+
setTimeout(() => {
|
| 15 |
+
toast.style.opacity = '0';
|
| 16 |
+
toast.style.transform = 'translateX(40px)';
|
| 17 |
+
toast.style.transition = 'opacity .3s, transform .3s';
|
| 18 |
+
setTimeout(() => toast.remove(), 300);
|
| 19 |
+
}, duration);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// ── Responsive sidebar toggle ──────────────────────────────────────────────
|
| 23 |
+
(function () {
|
| 24 |
+
function checkWidth() {
|
| 25 |
+
const toggle = document.getElementById('sidebar-toggle');
|
| 26 |
+
if (toggle) toggle.style.display = window.innerWidth <= 768 ? 'inline-flex' : 'none';
|
| 27 |
+
}
|
| 28 |
+
window.addEventListener('resize', checkWidth);
|
| 29 |
+
document.addEventListener('DOMContentLoaded', checkWidth);
|
| 30 |
+
|
| 31 |
+
// Close sidebar when clicking outside on mobile
|
| 32 |
+
document.addEventListener('click', (e) => {
|
| 33 |
+
const sidebar = document.getElementById('sidebar');
|
| 34 |
+
const toggle = document.getElementById('sidebar-toggle');
|
| 35 |
+
if (window.innerWidth <= 768 && sidebar && sidebar.classList.contains('open')) {
|
| 36 |
+
if (!sidebar.contains(e.target) && e.target !== toggle) {
|
| 37 |
+
sidebar.classList.remove('open');
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
});
|
| 41 |
+
})();
|
| 42 |
+
|
| 43 |
+
// ── Generic tab switcher ───────────────────────────────────────────────────
|
| 44 |
+
function activateTab(panelId, btn, groupClass) {
|
| 45 |
+
document.querySelectorAll(`.${groupClass}`).forEach(p => p.classList.remove('active'));
|
| 46 |
+
document.querySelectorAll(`[data-tab-group="${groupClass}"]`).forEach(b => b.classList.remove('active'));
|
| 47 |
+
document.getElementById(panelId)?.classList.add('active');
|
| 48 |
+
if (btn) btn.classList.add('active');
|
| 49 |
+
}
|
templates/automl.html
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% set active_page = "automl" %}
|
| 3 |
+
|
| 4 |
+
{% block title %}AutoML{% endblock %}
|
| 5 |
+
{% block page_title %}<i class="fa-solid fa-wand-magic-sparkles" style="color:var(--accent)"></i> AutoML{% endblock %}
|
| 6 |
+
|
| 7 |
+
{% block content %}
|
| 8 |
+
<div class="page-title">AutoML Sweep</div>
|
| 9 |
+
<div class="page-sub">Automatically benchmark every algorithm category against your dataset and rank by chosen metric</div>
|
| 10 |
+
|
| 11 |
+
<div class="grid-2" style="align-items:start">
|
| 12 |
+
|
| 13 |
+
<!-- Config panel -->
|
| 14 |
+
<div class="card">
|
| 15 |
+
<div class="card-title" style="margin-bottom:16px"><i class="fa-solid fa-sliders" style="color:var(--accent)"></i> Configuration</div>
|
| 16 |
+
|
| 17 |
+
<div class="form-group">
|
| 18 |
+
<label class="form-label">Dataset</label>
|
| 19 |
+
<select class="form-select" id="aml-dataset" onchange="updateTaskFromDataset()">
|
| 20 |
+
{% for name, cfg in datasets.items() %}
|
| 21 |
+
<option value="{{ name }}" data-task="{{ cfg.task }}">{{ cfg.icon }} {{ name }}</option>
|
| 22 |
+
{% endfor %}
|
| 23 |
+
</select>
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<div class="form-group">
|
| 27 |
+
<label class="form-label">Task Type</label>
|
| 28 |
+
<select class="form-select" id="aml-task" onchange="updateMetrics()">
|
| 29 |
+
<option value="classification">Classification</option>
|
| 30 |
+
<option value="regression">Regression</option>
|
| 31 |
+
</select>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<div class="form-group">
|
| 35 |
+
<label class="form-label">Optimise Metric</label>
|
| 36 |
+
<select class="form-select" id="aml-metric">
|
| 37 |
+
<option value="accuracy">Accuracy</option>
|
| 38 |
+
<option value="f1_score">F1 Score</option>
|
| 39 |
+
<option value="precision">Precision</option>
|
| 40 |
+
<option value="recall">Recall</option>
|
| 41 |
+
</select>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div class="form-group">
|
| 45 |
+
<label class="form-label">Max Algorithms to Run</label>
|
| 46 |
+
<select class="form-select" id="aml-max-runs">
|
| 47 |
+
<option value="10">10 — Quick sweep</option>
|
| 48 |
+
<option value="20" selected>20 — Standard</option>
|
| 49 |
+
<option value="35">35 — Exhaustive</option>
|
| 50 |
+
</select>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<!-- Coverage preview -->
|
| 54 |
+
<div style="background:var(--bg-tertiary);border-radius:8px;padding:12px;margin-bottom:16px">
|
| 55 |
+
<div style="font-size:.78rem;color:var(--text-muted);margin-bottom:8px;text-transform:uppercase;letter-spacing:.05em">Algorithm Categories</div>
|
| 56 |
+
<div id="cat-pills" class="category-pills" style="margin-bottom:0"></div>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<!-- Progress -->
|
| 60 |
+
<div id="aml-progress-wrap" style="display:none;margin-bottom:14px">
|
| 61 |
+
<div class="flex-between" style="margin-bottom:6px">
|
| 62 |
+
<span style="font-size:.82rem;color:var(--text-secondary)" id="aml-status-text">Starting…</span>
|
| 63 |
+
<span style="font-size:.82rem;font-weight:600" id="aml-pct">0%</span>
|
| 64 |
+
</div>
|
| 65 |
+
<div class="progress-bar-wrap"><div class="progress-bar" id="aml-bar" style="width:0%"></div></div>
|
| 66 |
+
<div id="aml-current-algo" style="font-size:.75rem;color:var(--text-muted);margin-top:4px"></div>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
<button class="btn btn-primary" id="btn-aml-run" onclick="runAutoML()" style="width:100%">
|
| 70 |
+
<i class="fa-solid fa-wand-magic-sparkles"></i> Run AutoML Sweep
|
| 71 |
+
</button>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<!-- Results panel -->
|
| 75 |
+
<div style="display:flex;flex-direction:column;gap:16px">
|
| 76 |
+
|
| 77 |
+
<!-- Best model highlight -->
|
| 78 |
+
<div class="card" id="aml-best-card" style="display:none;border-color:var(--accent)">
|
| 79 |
+
<div class="card-title" style="margin-bottom:10px;color:var(--accent-light)">
|
| 80 |
+
<i class="fa-solid fa-trophy" style="color:var(--warning)"></i> Best Model
|
| 81 |
+
</div>
|
| 82 |
+
<div id="aml-best-content"></div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<!-- Score chart -->
|
| 86 |
+
<div class="card" id="aml-chart-card" style="display:none">
|
| 87 |
+
<div class="card-header">
|
| 88 |
+
<div class="card-title"><i class="fa-solid fa-chart-bar" style="color:var(--accent-blue)"></i> Score Comparison</div>
|
| 89 |
+
</div>
|
| 90 |
+
<div id="aml-chart" style="height:260px"></div>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<!-- Leaderboard -->
|
| 94 |
+
<div class="card">
|
| 95 |
+
<div class="card-header">
|
| 96 |
+
<div class="card-title"><i class="fa-solid fa-ranking-star" style="color:var(--warning)"></i> Leaderboard</div>
|
| 97 |
+
<span id="aml-count" style="font-size:.8rem;color:var(--text-muted)"></span>
|
| 98 |
+
</div>
|
| 99 |
+
<div id="aml-leaderboard">
|
| 100 |
+
<div class="empty-state" style="padding:32px 16px">
|
| 101 |
+
<div class="empty-state-icon" style="font-size:2rem">🏁</div>
|
| 102 |
+
<div class="empty-state-title">No results yet</div>
|
| 103 |
+
<div>Configure and run the AutoML sweep to see rankings</div>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
{% endblock %}
|
| 110 |
+
|
| 111 |
+
{% block scripts %}
|
| 112 |
+
<script>
|
| 113 |
+
const ALGO_DATA_AML = {{ algorithms | tojson }};
|
| 114 |
+
|
| 115 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 116 |
+
updateTaskFromDataset();
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
function updateTaskFromDataset() {
|
| 120 |
+
const ds = document.getElementById('aml-dataset');
|
| 121 |
+
const opt = ds.options[ds.selectedIndex];
|
| 122 |
+
document.getElementById('aml-task').value = opt.dataset.task || 'classification';
|
| 123 |
+
updateMetrics();
|
| 124 |
+
updateCategoryPills();
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function updateMetrics() {
|
| 128 |
+
const task = document.getElementById('aml-task').value;
|
| 129 |
+
const sel = document.getElementById('aml-metric');
|
| 130 |
+
sel.innerHTML = task === 'classification'
|
| 131 |
+
? `<option value="accuracy">Accuracy</option>
|
| 132 |
+
<option value="f1_score">F1 Score</option>
|
| 133 |
+
<option value="precision">Precision</option>
|
| 134 |
+
<option value="recall">Recall</option>`
|
| 135 |
+
: `<option value="r2_score">R² Score</option>
|
| 136 |
+
<option value="mae">MAE (lower = better)</option>
|
| 137 |
+
<option value="rmse">RMSE (lower = better)</option>`;
|
| 138 |
+
updateCategoryPills();
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function updateCategoryPills() {
|
| 142 |
+
const task = document.getElementById('aml-task').value;
|
| 143 |
+
const cats = Object.keys(ALGO_DATA_AML[task] || {});
|
| 144 |
+
const el = document.getElementById('cat-pills');
|
| 145 |
+
el.innerHTML = cats.map(c => `<span class="category-pill active">${c}</span>`).join('');
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
async function runAutoML() {
|
| 149 |
+
const dataset = document.getElementById('aml-dataset').value;
|
| 150 |
+
const task_type = document.getElementById('aml-task').value;
|
| 151 |
+
const metric = document.getElementById('aml-metric').value;
|
| 152 |
+
const max_runs = parseInt(document.getElementById('aml-max-runs').value);
|
| 153 |
+
|
| 154 |
+
document.getElementById('btn-aml-run').disabled = true;
|
| 155 |
+
document.getElementById('btn-aml-run').innerHTML = '<span class="spinner"></span> Sweeping…';
|
| 156 |
+
document.getElementById('aml-progress-wrap').style.display = 'block';
|
| 157 |
+
document.getElementById('aml-best-card').style.display = 'none';
|
| 158 |
+
document.getElementById('aml-chart-card').style.display = 'none';
|
| 159 |
+
document.getElementById('aml-leaderboard').innerHTML =
|
| 160 |
+
'<div style="text-align:center;padding:20px;color:var(--text-muted)"><span class="spinner"></span> Running sweep…</div>';
|
| 161 |
+
|
| 162 |
+
try {
|
| 163 |
+
const res = await fetch('/api/automl', {
|
| 164 |
+
method: 'POST', headers:{'Content-Type':'application/json'},
|
| 165 |
+
body: JSON.stringify({ dataset, task_type, metric, max_runs }),
|
| 166 |
+
});
|
| 167 |
+
const data = await res.json();
|
| 168 |
+
if (data.error) { showToast(data.error, 'error'); resetAML(); return; }
|
| 169 |
+
pollAutoML(data.job_id, metric);
|
| 170 |
+
} catch(e) { showToast('Failed to start AutoML', 'error'); resetAML(); }
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function pollAutoML(jobId, metric) {
|
| 174 |
+
const bar = document.getElementById('aml-bar');
|
| 175 |
+
const pct = document.getElementById('aml-pct');
|
| 176 |
+
const statusT = document.getElementById('aml-status-text');
|
| 177 |
+
const curAlgo = document.getElementById('aml-current-algo');
|
| 178 |
+
|
| 179 |
+
const iv = setInterval(async () => {
|
| 180 |
+
const res = await fetch(`/api/automl/status/${jobId}`);
|
| 181 |
+
const job = await res.json();
|
| 182 |
+
|
| 183 |
+
bar.style.width = job.progress + '%';
|
| 184 |
+
pct.textContent = job.progress + '%';
|
| 185 |
+
statusT.textContent = job.status === 'running' ? 'Running sweep…' : job.status;
|
| 186 |
+
if (job.current_algo) curAlgo.textContent = `▶ ${job.current_algo}`;
|
| 187 |
+
|
| 188 |
+
const results = job.results || [];
|
| 189 |
+
if (results.length > 0) {
|
| 190 |
+
renderLeaderboard(results, metric);
|
| 191 |
+
document.getElementById('aml-count').textContent = `${results.length} models`;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
if (job.status === 'completed') {
|
| 195 |
+
clearInterval(iv);
|
| 196 |
+
statusT.textContent = 'Completed';
|
| 197 |
+
curAlgo.textContent = '';
|
| 198 |
+
renderLeaderboard(results, metric);
|
| 199 |
+
if (job.best) renderBest(job.best, metric);
|
| 200 |
+
renderChart(results, metric);
|
| 201 |
+
document.getElementById('btn-aml-run').disabled = false;
|
| 202 |
+
document.getElementById('btn-aml-run').innerHTML = '<i class="fa-solid fa-rotate-right"></i> Re-run Sweep';
|
| 203 |
+
showToast(`AutoML complete — best: ${job.best?.algorithm} (${metric}: ${job.best?.metrics[metric]})`, 'success');
|
| 204 |
+
} else if (job.status === 'failed') {
|
| 205 |
+
clearInterval(iv);
|
| 206 |
+
showToast('AutoML failed: ' + (job.error || 'unknown'), 'error');
|
| 207 |
+
resetAML();
|
| 208 |
+
}
|
| 209 |
+
}, 1200);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
function renderLeaderboard(results, metric) {
|
| 213 |
+
const el = document.getElementById('aml-leaderboard');
|
| 214 |
+
if (!results.length) return;
|
| 215 |
+
el.innerHTML = results.map(r => {
|
| 216 |
+
const rankClass = r.rank === 1 ? 'rank-1' : r.rank === 2 ? 'rank-2' : r.rank === 3 ? 'rank-3' : 'rank-n';
|
| 217 |
+
const v = r.metrics[metric];
|
| 218 |
+
const isGood = v >= 0.9;
|
| 219 |
+
const isMed = v >= 0.7;
|
| 220 |
+
const metricClass = isGood ? 'metric-good' : isMed ? 'metric-medium' : 'metric-bad';
|
| 221 |
+
return `<div class="leaderboard-row">
|
| 222 |
+
<div class="rank-badge ${rankClass}">${r.rank}</div>
|
| 223 |
+
<div>
|
| 224 |
+
<div style="font-weight:500;font-size:.88rem">${r.algorithm}</div>
|
| 225 |
+
<div style="font-size:.75rem;color:var(--text-muted)">${r.category} · ${r.duration}s</div>
|
| 226 |
+
</div>
|
| 227 |
+
<span class="badge badge-purple" style="font-size:.72rem">${r.category.split(' ')[0]}</span>
|
| 228 |
+
<div class="metric-val ${metricClass}" style="min-width:52px;text-align:right">${typeof v === 'number' ? v.toFixed(4) : '—'}</div>
|
| 229 |
+
</div>`;
|
| 230 |
+
}).join('');
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
function renderBest(best, metric) {
|
| 234 |
+
const card = document.getElementById('aml-best-card');
|
| 235 |
+
const v = best.metrics[metric];
|
| 236 |
+
document.getElementById('aml-best-content').innerHTML = `
|
| 237 |
+
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
|
| 238 |
+
<div>
|
| 239 |
+
<div style="font-size:1.4rem;font-weight:700">${best.algorithm}</div>
|
| 240 |
+
<div style="font-size:.82rem;color:var(--text-secondary)">${best.category}</div>
|
| 241 |
+
</div>
|
| 242 |
+
<div style="margin-left:auto;text-align:right">
|
| 243 |
+
<div style="font-size:.75rem;color:var(--text-muted);text-transform:uppercase">${metric}</div>
|
| 244 |
+
<div style="font-size:2rem;font-weight:700;color:var(--success)">${typeof v === 'number' ? v.toFixed(4) : '—'}</div>
|
| 245 |
+
</div>
|
| 246 |
+
</div>
|
| 247 |
+
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
|
| 248 |
+
${Object.entries(best.metrics).map(([k,val]) =>
|
| 249 |
+
`<div style="background:var(--bg-tertiary);padding:6px 12px;border-radius:6px;font-size:.82rem">
|
| 250 |
+
<span style="color:var(--text-muted)">${k}:</span>
|
| 251 |
+
<strong>${typeof val === 'number' ? val.toFixed(4) : val}</strong>
|
| 252 |
+
</div>`).join('')}
|
| 253 |
+
</div>`;
|
| 254 |
+
card.style.display = 'block';
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
function renderChart(results, metric) {
|
| 258 |
+
const top = results.slice(0, 15);
|
| 259 |
+
const bg2 = '#161b22', border = '#30363d', txt = '#8b949e';
|
| 260 |
+
const colors = top.map(r => r.color || '#8b5cf6');
|
| 261 |
+
|
| 262 |
+
Plotly.newPlot('aml-chart', [{
|
| 263 |
+
type: 'bar', orientation: 'h',
|
| 264 |
+
y: top.map(r => r.algorithm).reverse(),
|
| 265 |
+
x: top.map(r => r.metrics[metric] || 0).reverse(),
|
| 266 |
+
marker: { color: colors.slice().reverse() },
|
| 267 |
+
hovertemplate: '<b>%{y}</b><br>' + metric + ': %{x:.4f}<extra></extra>',
|
| 268 |
+
}], {
|
| 269 |
+
paper_bgcolor: bg2, plot_bgcolor: bg2,
|
| 270 |
+
margin: { t: 8, b: 40, l: 8, r: 16 },
|
| 271 |
+
xaxis: { gridcolor: border, color: txt, tickfont:{size:10} },
|
| 272 |
+
yaxis: { color: txt, tickfont:{size:10}, automargin: true },
|
| 273 |
+
bargap: .3,
|
| 274 |
+
}, { responsive: true, displayModeBar: false });
|
| 275 |
+
|
| 276 |
+
document.getElementById('aml-chart-card').style.display = 'block';
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
function resetAML() {
|
| 280 |
+
document.getElementById('btn-aml-run').disabled = false;
|
| 281 |
+
document.getElementById('btn-aml-run').innerHTML = '<i class="fa-solid fa-wand-magic-sparkles"></i> Run AutoML Sweep';
|
| 282 |
+
}
|
| 283 |
+
</script>
|
| 284 |
+
{% endblock %}
|
templates/base.html
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{% block title %}AutoMLOps{% endblock %} — ML Experiment Platform</title>
|
| 7 |
+
|
| 8 |
+
<!-- Google Font -->
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
|
| 12 |
+
|
| 13 |
+
<!-- Font Awesome -->
|
| 14 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 15 |
+
|
| 16 |
+
<!-- Plotly.js -->
|
| 17 |
+
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js" defer></script>
|
| 18 |
+
|
| 19 |
+
<link rel="stylesheet" href="/static/css/style.css">
|
| 20 |
+
|
| 21 |
+
{% block head_extra %}{% endblock %}
|
| 22 |
+
</head>
|
| 23 |
+
<body>
|
| 24 |
+
|
| 25 |
+
<!-- ── Sidebar ──────────────────────────────────────────────────────────── -->
|
| 26 |
+
<aside class="sidebar" id="sidebar">
|
| 27 |
+
<div class="sidebar-logo">
|
| 28 |
+
<div class="sidebar-logo-icon">🤖</div>
|
| 29 |
+
<div>
|
| 30 |
+
<div class="sidebar-logo-text">AutoMLOps</div>
|
| 31 |
+
<div class="sidebar-logo-sub">ML Experiment Platform</div>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
<nav class="sidebar-nav">
|
| 36 |
+
<div class="nav-section-label">Overview</div>
|
| 37 |
+
<a href="/" class="nav-item {% if active_page == 'dashboard' %}active{% endif %}">
|
| 38 |
+
<span class="nav-icon"><i class="fa-solid fa-gauge-high"></i></span> Dashboard
|
| 39 |
+
</a>
|
| 40 |
+
|
| 41 |
+
<div class="nav-section-label">Experiments</div>
|
| 42 |
+
<a href="/experiments" class="nav-item {% if active_page == 'experiments' %}active{% endif %}">
|
| 43 |
+
<span class="nav-icon"><i class="fa-solid fa-flask"></i></span> Experiments
|
| 44 |
+
</a>
|
| 45 |
+
<a href="/automl" class="nav-item {% if active_page == 'automl' %}active{% endif %}">
|
| 46 |
+
<span class="nav-icon"><i class="fa-solid fa-wand-magic-sparkles"></i></span> AutoML
|
| 47 |
+
<span class="nav-badge">NEW</span>
|
| 48 |
+
</a>
|
| 49 |
+
|
| 50 |
+
<div class="nav-section-label">Operations</div>
|
| 51 |
+
<a href="/pipeline" class="nav-item {% if active_page == 'pipeline' %}active{% endif %}">
|
| 52 |
+
<span class="nav-icon"><i class="fa-solid fa-diagram-project"></i></span> Pipelines
|
| 53 |
+
</a>
|
| 54 |
+
<a href="/models" class="nav-item {% if active_page == 'models' %}active{% endif %}">
|
| 55 |
+
<span class="nav-icon"><i class="fa-solid fa-box-archive"></i></span> Model Registry
|
| 56 |
+
</a>
|
| 57 |
+
</nav>
|
| 58 |
+
|
| 59 |
+
<div class="sidebar-footer">
|
| 60 |
+
<div>Powered by <a href="https://mlflow.org" target="_blank">MLflow</a> & sklearn</div>
|
| 61 |
+
<div class="mt-4" style="color: var(--text-muted); font-size:.72rem;">
|
| 62 |
+
<i class="fa-brands fa-python"></i> Python 3.11 ·
|
| 63 |
+
<i class="fa-solid fa-code-branch"></i> v1.0
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
</aside>
|
| 67 |
+
|
| 68 |
+
<!-- ── Top navbar ────────────────────────────────────────────────────────── -->
|
| 69 |
+
<header class="topnav">
|
| 70 |
+
<button class="btn btn-ghost btn-sm" id="sidebar-toggle" style="display:none"
|
| 71 |
+
onclick="document.getElementById('sidebar').classList.toggle('open')">
|
| 72 |
+
<i class="fa-solid fa-bars"></i>
|
| 73 |
+
</button>
|
| 74 |
+
<span class="topnav-title">{% block page_title %}AutoMLOps{% endblock %}</span>
|
| 75 |
+
<span class="topnav-badge"><i class="fa-solid fa-circle" style="color:#22c55e;font-size:.55rem"></i> Live</span>
|
| 76 |
+
{% block topnav_actions %}{% endblock %}
|
| 77 |
+
</header>
|
| 78 |
+
|
| 79 |
+
<!-- ── Main ──────────────────────────────────────────────────────────────── -->
|
| 80 |
+
<main class="main">
|
| 81 |
+
<div class="page-content">
|
| 82 |
+
{% block content %}{% endblock %}
|
| 83 |
+
</div>
|
| 84 |
+
</main>
|
| 85 |
+
|
| 86 |
+
<!-- ── Toast area ────────────────────────────────────────────────────────── -->
|
| 87 |
+
<div id="toast-area"></div>
|
| 88 |
+
|
| 89 |
+
<!-- ── Global JS ─────────────────────────────────────────────────────────── -->
|
| 90 |
+
<script src="/static/js/app.js"></script>
|
| 91 |
+
{% block scripts %}{% endblock %}
|
| 92 |
+
</body>
|
| 93 |
+
</html>
|
templates/dashboard.html
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% set active_page = "dashboard" %}
|
| 3 |
+
|
| 4 |
+
{% block title %}Dashboard{% endblock %}
|
| 5 |
+
{% block page_title %}<i class="fa-solid fa-gauge-high" style="color:var(--accent)"></i> Dashboard{% endblock %}
|
| 6 |
+
|
| 7 |
+
{% block topnav_actions %}
|
| 8 |
+
<button class="btn btn-primary btn-sm" onclick="openTrainModal()">
|
| 9 |
+
<i class="fa-solid fa-play"></i> New Run
|
| 10 |
+
</button>
|
| 11 |
+
{% endblock %}
|
| 12 |
+
|
| 13 |
+
{% block content %}
|
| 14 |
+
<div class="page-title">Overview</div>
|
| 15 |
+
<div class="page-sub">Real-time ML experiment tracking powered by MLflow</div>
|
| 16 |
+
|
| 17 |
+
<!-- Stat cards -->
|
| 18 |
+
<div class="stat-grid">
|
| 19 |
+
<div class="stat-card purple">
|
| 20 |
+
<div class="stat-label">Total Runs</div>
|
| 21 |
+
<div class="stat-value" id="stat-total">{{ total_runs }}</div>
|
| 22 |
+
<div class="stat-sub"><i class="fa-solid fa-arrow-trend-up"></i> all time</div>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="stat-card blue">
|
| 25 |
+
<div class="stat-label">Completed</div>
|
| 26 |
+
<div class="stat-value" id="stat-completed">{{ completed_runs }}</div>
|
| 27 |
+
<div class="stat-sub">finished successfully</div>
|
| 28 |
+
</div>
|
| 29 |
+
<div class="stat-card green">
|
| 30 |
+
<div class="stat-label">Best Score</div>
|
| 31 |
+
<div class="stat-value" id="stat-best">{{ best_metric }}</div>
|
| 32 |
+
<div class="stat-sub">accuracy / R²</div>
|
| 33 |
+
</div>
|
| 34 |
+
<div class="stat-card yellow">
|
| 35 |
+
<div class="stat-label">Experiments</div>
|
| 36 |
+
<div class="stat-value" id="stat-exps">{{ n_experiments }}</div>
|
| 37 |
+
<div class="stat-sub">active datasets</div>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<!-- Charts row -->
|
| 42 |
+
<div class="grid-2 mb-20">
|
| 43 |
+
<div class="card">
|
| 44 |
+
<div class="card-header">
|
| 45 |
+
<div class="card-title"><i class="fa-solid fa-chart-pie" style="color:var(--accent)"></i> Algorithm Categories</div>
|
| 46 |
+
</div>
|
| 47 |
+
<div id="chart-algo" style="height:220px"></div>
|
| 48 |
+
</div>
|
| 49 |
+
<div class="card">
|
| 50 |
+
<div class="card-header">
|
| 51 |
+
<div class="card-title"><i class="fa-solid fa-database" style="color:var(--accent-blue)"></i> Runs by Dataset</div>
|
| 52 |
+
</div>
|
| 53 |
+
<div id="chart-ds" style="height:220px"></div>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<!-- Recent runs -->
|
| 58 |
+
<div class="card">
|
| 59 |
+
<div class="card-header">
|
| 60 |
+
<div class="card-title"><i class="fa-solid fa-clock-rotate-left" style="color:var(--warning)"></i> Recent Runs</div>
|
| 61 |
+
<a href="/experiments" class="btn btn-ghost btn-sm">View all <i class="fa-solid fa-arrow-right"></i></a>
|
| 62 |
+
</div>
|
| 63 |
+
<div class="table-wrap">
|
| 64 |
+
<table id="recent-table">
|
| 65 |
+
<thead>
|
| 66 |
+
<tr>
|
| 67 |
+
<th>Run ID</th><th>Algorithm</th><th>Category</th><th>Dataset</th>
|
| 68 |
+
<th>Primary Metric</th><th>Duration</th><th>Status</th>
|
| 69 |
+
</tr>
|
| 70 |
+
</thead>
|
| 71 |
+
<tbody>
|
| 72 |
+
{% for r in recent_runs %}
|
| 73 |
+
<tr>
|
| 74 |
+
<td><code style="font-size:.8rem;color:var(--accent-light)">{{ r.run_id }}</code></td>
|
| 75 |
+
<td><strong>{{ r.algorithm }}</strong></td>
|
| 76 |
+
<td><span class="badge badge-purple">{{ r.category }}</span></td>
|
| 77 |
+
<td>{{ r.dataset }}</td>
|
| 78 |
+
<td>
|
| 79 |
+
<span class="metric-val {% if r.primary_metric >= 0.9 %}metric-good{% elif r.primary_metric >= 0.7 %}metric-medium{% else %}metric-bad{% endif %}">
|
| 80 |
+
{{ r.primary_metric }}
|
| 81 |
+
</span>
|
| 82 |
+
</td>
|
| 83 |
+
<td>{{ r.duration }}s</td>
|
| 84 |
+
<td>
|
| 85 |
+
{% if r.status == 'FINISHED' %}
|
| 86 |
+
<span class="badge badge-success"><i class="fa-solid fa-check"></i> Done</span>
|
| 87 |
+
{% elif r.status == 'RUNNING' %}
|
| 88 |
+
<span class="badge badge-info"><span class="spinner" style="width:10px;height:10px;border-width:1.5px"></span> Running</span>
|
| 89 |
+
{% else %}
|
| 90 |
+
<span class="badge badge-muted">{{ r.status }}</span>
|
| 91 |
+
{% endif %}
|
| 92 |
+
</td>
|
| 93 |
+
</tr>
|
| 94 |
+
{% else %}
|
| 95 |
+
<tr><td colspan="7">
|
| 96 |
+
<div class="empty-state">
|
| 97 |
+
<div class="empty-state-icon">🔬</div>
|
| 98 |
+
<div class="empty-state-title">No runs yet</div>
|
| 99 |
+
<div>Click <strong>New Run</strong> to train your first model</div>
|
| 100 |
+
</div>
|
| 101 |
+
</td></tr>
|
| 102 |
+
{% endfor %}
|
| 103 |
+
</tbody>
|
| 104 |
+
</table>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
<!-- ── Quick Train Modal ──────────────────────────────────────────────────── -->
|
| 109 |
+
<div class="modal-overlay" id="train-modal">
|
| 110 |
+
<div class="modal">
|
| 111 |
+
<div class="modal-header">
|
| 112 |
+
<div class="modal-title"><i class="fa-solid fa-play" style="color:var(--accent)"></i> New Training Run</div>
|
| 113 |
+
<button class="modal-close" onclick="closeTrainModal()"><i class="fa-solid fa-xmark"></i></button>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<div class="form-group">
|
| 117 |
+
<label class="form-label">Dataset</label>
|
| 118 |
+
<select class="form-select" id="sel-dataset" onchange="updateTaskType()">
|
| 119 |
+
{% for name, cfg in datasets.items() %}
|
| 120 |
+
<option value="{{ name }}" data-task="{{ cfg.task }}">{{ cfg.icon }} {{ name }} — {{ cfg.description[:55] }}…</option>
|
| 121 |
+
{% endfor %}
|
| 122 |
+
</select>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<div class="form-group">
|
| 126 |
+
<label class="form-label">Task Type</label>
|
| 127 |
+
<select class="form-select" id="sel-task" onchange="populateCategories()">
|
| 128 |
+
<option value="classification">Classification</option>
|
| 129 |
+
<option value="regression">Regression</option>
|
| 130 |
+
</select>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<div class="form-group">
|
| 134 |
+
<label class="form-label">Algorithm Category</label>
|
| 135 |
+
<select class="form-select" id="sel-category" onchange="populateAlgorithms()"></select>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<div class="form-group">
|
| 139 |
+
<label class="form-label">Algorithm</label>
|
| 140 |
+
<select class="form-select" id="sel-algorithm"></select>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
<!-- Progress (hidden until running) -->
|
| 144 |
+
<div id="train-progress-wrap" style="display:none;margin-top:12px">
|
| 145 |
+
<div class="flex-between mb-20" style="margin-bottom:6px">
|
| 146 |
+
<span style="font-size:.85rem;color:var(--text-secondary)" id="train-status-text">Initialising…</span>
|
| 147 |
+
<span style="font-size:.85rem;font-weight:600" id="train-pct">0%</span>
|
| 148 |
+
</div>
|
| 149 |
+
<div class="progress-bar-wrap"><div class="progress-bar" id="train-bar" style="width:0%"></div></div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<!-- Result (hidden until done) -->
|
| 153 |
+
<div id="train-result" style="display:none;margin-top:14px"></div>
|
| 154 |
+
|
| 155 |
+
<div class="modal-footer">
|
| 156 |
+
<button class="btn btn-ghost" onclick="closeTrainModal()">Cancel</button>
|
| 157 |
+
<button class="btn btn-primary" id="btn-start-train" onclick="startTraining()">
|
| 158 |
+
<i class="fa-solid fa-play"></i> Train
|
| 159 |
+
</button>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
{% endblock %}
|
| 164 |
+
|
| 165 |
+
{% block scripts %}
|
| 166 |
+
<script>
|
| 167 |
+
const ALGO_DATA = {{ algorithms | tojson }};
|
| 168 |
+
const ALGO_COUNTS = {{ algo_counts }};
|
| 169 |
+
const DS_COUNTS = {{ ds_counts }};
|
| 170 |
+
|
| 171 |
+
// ── Charts ────────────────────────────────────────────────────────────────
|
| 172 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 173 |
+
const dark = '#0d1117', bg2 = '#161b22', border = '#30363d', txt = '#8b949e';
|
| 174 |
+
|
| 175 |
+
// Donut — algorithm categories
|
| 176 |
+
const cats = Object.keys(ALGO_COUNTS);
|
| 177 |
+
const vals = Object.values(ALGO_COUNTS);
|
| 178 |
+
const COLORS = ['#8b5cf6','#3b82f6','#22c55e','#f59e0b','#ef4444','#06b6d4','#ec4899','#a855f7'];
|
| 179 |
+
Plotly.newPlot('chart-algo', [{
|
| 180 |
+
type: 'pie', hole: .55,
|
| 181 |
+
labels: cats, values: vals,
|
| 182 |
+
marker: { colors: COLORS.slice(0, cats.length) },
|
| 183 |
+
textinfo: 'none',
|
| 184 |
+
hovertemplate: '<b>%{label}</b><br>%{value} runs<extra></extra>',
|
| 185 |
+
}], {
|
| 186 |
+
paper_bgcolor: bg2, plot_bgcolor: bg2,
|
| 187 |
+
margin: { t:8, b:8, l:8, r:8 },
|
| 188 |
+
legend: { font: { color: txt, size: 11 }, bgcolor: 'transparent', x: 1.05 },
|
| 189 |
+
showlegend: cats.length > 0,
|
| 190 |
+
annotations: [{ text: `<b>${vals.reduce((a,b)=>a+b,0)}</b><br><span style="font-size:10px">runs</span>`,
|
| 191 |
+
x:.5, y:.5, font:{size:14,color:'#e6edf3'}, showarrow:false }],
|
| 192 |
+
}, { responsive: true, displayModeBar: false });
|
| 193 |
+
|
| 194 |
+
// Bar — datasets
|
| 195 |
+
const dsKeys = Object.keys(DS_COUNTS);
|
| 196 |
+
const dsVals = Object.values(DS_COUNTS);
|
| 197 |
+
Plotly.newPlot('chart-ds', [{
|
| 198 |
+
type: 'bar', orientation: 'h',
|
| 199 |
+
y: dsKeys, x: dsVals,
|
| 200 |
+
marker: { color: '#3b82f6', opacity: .85 },
|
| 201 |
+
hovertemplate: '<b>%{y}</b>: %{x} runs<extra></extra>',
|
| 202 |
+
}], {
|
| 203 |
+
paper_bgcolor: bg2, plot_bgcolor: bg2,
|
| 204 |
+
margin: { t:8, b:24, l:8, r:16 },
|
| 205 |
+
xaxis: { gridcolor: border, color: txt, tickfont:{size:10} },
|
| 206 |
+
yaxis: { color: txt, tickfont:{size:10}, automargin:true },
|
| 207 |
+
bargap: .35,
|
| 208 |
+
}, { responsive: true, displayModeBar: false });
|
| 209 |
+
|
| 210 |
+
populateCategories();
|
| 211 |
+
});
|
| 212 |
+
|
| 213 |
+
// ── Train modal helpers ────────────────────────────────────────────────────
|
| 214 |
+
function updateTaskType() {
|
| 215 |
+
const ds = document.getElementById('sel-dataset');
|
| 216 |
+
const opt = ds.options[ds.selectedIndex];
|
| 217 |
+
document.getElementById('sel-task').value = opt.dataset.task || 'classification';
|
| 218 |
+
populateCategories();
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
function populateCategories() {
|
| 222 |
+
const task = document.getElementById('sel-task').value;
|
| 223 |
+
const sel = document.getElementById('sel-category');
|
| 224 |
+
sel.innerHTML = '';
|
| 225 |
+
const cats = Object.keys(ALGO_DATA[task] || {});
|
| 226 |
+
cats.forEach(c => { const o = document.createElement('option'); o.value = c; o.text = c; sel.add(o); });
|
| 227 |
+
populateAlgorithms();
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
function populateAlgorithms() {
|
| 231 |
+
const task = document.getElementById('sel-task').value;
|
| 232 |
+
const cat = document.getElementById('sel-category').value;
|
| 233 |
+
const sel = document.getElementById('sel-algorithm');
|
| 234 |
+
sel.innerHTML = '';
|
| 235 |
+
const algos = Object.keys((ALGO_DATA[task] || {})[cat] || {});
|
| 236 |
+
algos.forEach(a => { const o = document.createElement('option'); o.value = a; o.text = a; sel.add(o); });
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
function openTrainModal() { document.getElementById('train-modal').classList.add('open'); resetTrainModal(); }
|
| 240 |
+
function closeTrainModal() { document.getElementById('train-modal').classList.remove('open'); }
|
| 241 |
+
|
| 242 |
+
function resetTrainModal() {
|
| 243 |
+
document.getElementById('train-progress-wrap').style.display = 'none';
|
| 244 |
+
document.getElementById('train-result').style.display = 'none';
|
| 245 |
+
document.getElementById('btn-start-train').disabled = false;
|
| 246 |
+
document.getElementById('btn-start-train').innerHTML = '<i class="fa-solid fa-play"></i> Train';
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
async function startTraining() {
|
| 250 |
+
const dataset = document.getElementById('sel-dataset').value;
|
| 251 |
+
const task_type = document.getElementById('sel-task').value;
|
| 252 |
+
const category = document.getElementById('sel-category').value;
|
| 253 |
+
const algorithm = document.getElementById('sel-algorithm').value;
|
| 254 |
+
|
| 255 |
+
document.getElementById('btn-start-train').disabled = true;
|
| 256 |
+
document.getElementById('btn-start-train').innerHTML = '<span class="spinner"></span> Running…';
|
| 257 |
+
document.getElementById('train-progress-wrap').style.display = 'block';
|
| 258 |
+
document.getElementById('train-result').style.display = 'none';
|
| 259 |
+
|
| 260 |
+
try {
|
| 261 |
+
const res = await fetch('/api/train', {
|
| 262 |
+
method: 'POST', headers:{'Content-Type':'application/json'},
|
| 263 |
+
body: JSON.stringify({ dataset, task_type, category, algorithm }),
|
| 264 |
+
});
|
| 265 |
+
const data = await res.json();
|
| 266 |
+
if (data.error) { showToast(data.error, 'error'); resetTrainModal(); return; }
|
| 267 |
+
pollTraining(data.job_id);
|
| 268 |
+
} catch(e) { showToast('Request failed', 'error'); resetTrainModal(); }
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
function pollTraining(jobId) {
|
| 272 |
+
const bar = document.getElementById('train-bar');
|
| 273 |
+
const pct = document.getElementById('train-pct');
|
| 274 |
+
const statusT = document.getElementById('train-status-text');
|
| 275 |
+
const resultD = document.getElementById('train-result');
|
| 276 |
+
|
| 277 |
+
const iv = setInterval(async () => {
|
| 278 |
+
const res = await fetch(`/api/run/${jobId}/status`);
|
| 279 |
+
const job = await res.json();
|
| 280 |
+
|
| 281 |
+
bar.style.width = job.progress + '%';
|
| 282 |
+
pct.textContent = job.progress + '%';
|
| 283 |
+
statusT.textContent = job.status.charAt(0).toUpperCase() + job.status.slice(1) + '…';
|
| 284 |
+
|
| 285 |
+
if (job.status === 'completed') {
|
| 286 |
+
clearInterval(iv);
|
| 287 |
+
statusT.textContent = 'Completed';
|
| 288 |
+
const m = job.metrics || {};
|
| 289 |
+
const keys = Object.keys(m);
|
| 290 |
+
const primary = keys[0];
|
| 291 |
+
resultD.style.display = 'block';
|
| 292 |
+
resultD.innerHTML = `
|
| 293 |
+
<div style="background:var(--bg-tertiary);border-radius:8px;padding:14px">
|
| 294 |
+
<div style="font-weight:600;margin-bottom:10px;color:var(--success)">
|
| 295 |
+
<i class="fa-solid fa-circle-check"></i> Training complete
|
| 296 |
+
</div>
|
| 297 |
+
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px">
|
| 298 |
+
${keys.map(k=>`<div style="background:var(--bg-secondary);padding:8px 10px;border-radius:6px">
|
| 299 |
+
<div style="font-size:.72rem;color:var(--text-muted);text-transform:uppercase">${k}</div>
|
| 300 |
+
<div style="font-size:1.1rem;font-weight:700;color:var(--success)">${m[k]}</div>
|
| 301 |
+
</div>`).join('')}
|
| 302 |
+
</div>
|
| 303 |
+
</div>`;
|
| 304 |
+
document.getElementById('btn-start-train').innerHTML = '<i class="fa-solid fa-rotate-right"></i> Run Again';
|
| 305 |
+
document.getElementById('btn-start-train').disabled = false;
|
| 306 |
+
showToast(`${job.algorithm} → ${primary}: ${m[primary]}`, 'success');
|
| 307 |
+
refreshStats();
|
| 308 |
+
} else if (job.status === 'failed') {
|
| 309 |
+
clearInterval(iv);
|
| 310 |
+
showToast('Training failed: ' + (job.error || 'unknown'), 'error');
|
| 311 |
+
resetTrainModal();
|
| 312 |
+
}
|
| 313 |
+
}, 1000);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
async function refreshStats() {
|
| 317 |
+
try {
|
| 318 |
+
const r = await fetch('/api/stats');
|
| 319 |
+
const s = await r.json();
|
| 320 |
+
document.getElementById('stat-total').textContent = s.total_runs;
|
| 321 |
+
document.getElementById('stat-completed').textContent = s.completed_runs;
|
| 322 |
+
document.getElementById('stat-best').textContent = s.best_metric;
|
| 323 |
+
document.getElementById('stat-exps').textContent = s.n_experiments;
|
| 324 |
+
} catch(_) {}
|
| 325 |
+
}
|
| 326 |
+
</script>
|
| 327 |
+
{% endblock %}
|
templates/experiments.html
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% set active_page = "experiments" %}
|
| 3 |
+
|
| 4 |
+
{% block title %}Experiments{% endblock %}
|
| 5 |
+
{% block page_title %}<i class="fa-solid fa-flask" style="color:var(--accent-blue)"></i> Experiments{% endblock %}
|
| 6 |
+
|
| 7 |
+
{% block content %}
|
| 8 |
+
<div class="page-title">Experiment Tracker</div>
|
| 9 |
+
<div class="page-sub">Browse all MLflow experiments and compare runs side-by-side</div>
|
| 10 |
+
|
| 11 |
+
{% if experiments %}
|
| 12 |
+
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px">
|
| 13 |
+
{% for exp in experiments %}
|
| 14 |
+
<a href="/experiments/{{ exp.experiment_id }}" style="text-decoration:none">
|
| 15 |
+
<div class="card" style="cursor:pointer;transition:border-color .15s" onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border-color)'">
|
| 16 |
+
<div class="flex-between mb-20" style="margin-bottom:12px">
|
| 17 |
+
<div style="font-weight:600;font-size:.95rem">{{ exp.name }}</div>
|
| 18 |
+
<span class="badge badge-info">{{ exp.run_count }} runs</span>
|
| 19 |
+
</div>
|
| 20 |
+
<div style="display:flex;gap:20px;font-size:.85rem">
|
| 21 |
+
<div>
|
| 22 |
+
<div style="color:var(--text-muted);font-size:.75rem;margin-bottom:2px">Best Score</div>
|
| 23 |
+
<div class="metric-val {% if exp.best_metric >= 0.9 %}metric-good{% elif exp.best_metric >= 0.7 %}metric-medium{% else %}metric-bad{% endif %}">
|
| 24 |
+
{{ exp.best_metric if exp.best_metric > 0 else '—' }}
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
<div>
|
| 28 |
+
<div style="color:var(--text-muted);font-size:.75rem;margin-bottom:2px">Created</div>
|
| 29 |
+
<div style="color:var(--text-secondary)">{{ exp.created_at }}</div>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
</a>
|
| 34 |
+
{% endfor %}
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
{% else %}
|
| 38 |
+
<div class="card">
|
| 39 |
+
<div class="empty-state">
|
| 40 |
+
<div class="empty-state-icon">🔬</div>
|
| 41 |
+
<div class="empty-state-title">No experiments yet</div>
|
| 42 |
+
<div style="margin-bottom:16px">Start a training run from the Dashboard to create your first experiment.</div>
|
| 43 |
+
<a href="/" class="btn btn-primary"><i class="fa-solid fa-play"></i> Go to Dashboard</a>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
{% endif %}
|
| 47 |
+
{% endblock %}
|
templates/models.html
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% set active_page = "models" %}
|
| 3 |
+
|
| 4 |
+
{% block title %}Model Registry{% endblock %}
|
| 5 |
+
{% block page_title %}<i class="fa-solid fa-box-archive" style="color:var(--warning)"></i> Model Registry{% endblock %}
|
| 6 |
+
|
| 7 |
+
{% block content %}
|
| 8 |
+
<div class="page-title">Model Registry</div>
|
| 9 |
+
<div class="page-sub">Track model versions and manage stage transitions: None → Staging → Production → Archived</div>
|
| 10 |
+
|
| 11 |
+
{% if models %}
|
| 12 |
+
<div style="display:flex;flex-direction:column;gap:16px">
|
| 13 |
+
{% for model in models %}
|
| 14 |
+
<div class="card">
|
| 15 |
+
<div class="flex-between" style="margin-bottom:14px">
|
| 16 |
+
<div>
|
| 17 |
+
<div style="font-weight:700;font-size:1rem">{{ model.name }}</div>
|
| 18 |
+
<div style="font-size:.82rem;color:var(--text-secondary);margin-top:2px">{{ model.description }}</div>
|
| 19 |
+
</div>
|
| 20 |
+
<span class="badge stage-{{ model.latest_stage | lower }}">
|
| 21 |
+
{% if model.latest_stage == 'Production' %}<i class="fa-solid fa-rocket"></i>
|
| 22 |
+
{% elif model.latest_stage == 'Staging' %}<i class="fa-solid fa-flask"></i>
|
| 23 |
+
{% elif model.latest_stage == 'Archived' %}<i class="fa-solid fa-archive"></i>
|
| 24 |
+
{% else %}<i class="fa-solid fa-box"></i>{% endif %}
|
| 25 |
+
{{ model.latest_stage }}
|
| 26 |
+
</span>
|
| 27 |
+
</div>
|
| 28 |
+
<div class="table-wrap">
|
| 29 |
+
<table>
|
| 30 |
+
<thead>
|
| 31 |
+
<tr><th>Version</th><th>Stage</th><th>Run ID</th><th>Key Metrics</th><th>Registered</th><th>Actions</th></tr>
|
| 32 |
+
</thead>
|
| 33 |
+
<tbody>
|
| 34 |
+
{% for v in model.versions %}
|
| 35 |
+
<tr>
|
| 36 |
+
<td><span class="badge badge-muted">v{{ v.version }}</span></td>
|
| 37 |
+
<td>
|
| 38 |
+
<span class="badge stage-{{ v.stage | lower }}">
|
| 39 |
+
{{ v.stage }}
|
| 40 |
+
</span>
|
| 41 |
+
</td>
|
| 42 |
+
<td><code style="font-size:.8rem;color:var(--accent-light)">{{ v.run_id }}</code></td>
|
| 43 |
+
<td>
|
| 44 |
+
{% if v.metrics %}
|
| 45 |
+
{% for k, val in v.metrics.items() %}
|
| 46 |
+
<span style="font-size:.8rem;margin-right:8px">
|
| 47 |
+
<span style="color:var(--text-muted)">{{ k }}:</span>
|
| 48 |
+
<strong>{{ val }}</strong>
|
| 49 |
+
</span>
|
| 50 |
+
{% endfor %}
|
| 51 |
+
{% else %}—{% endif %}
|
| 52 |
+
</td>
|
| 53 |
+
<td style="font-size:.8rem;color:var(--text-muted)">{{ v.created_at }}</td>
|
| 54 |
+
<td>
|
| 55 |
+
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
| 56 |
+
{% if v.stage != 'Staging' and v.stage != 'Production' %}
|
| 57 |
+
<button class="btn btn-ghost btn-sm"
|
| 58 |
+
onclick="transitionStage('{{ model.name }}','{{ v.version }}','Staging', this)">
|
| 59 |
+
→ Staging
|
| 60 |
+
</button>
|
| 61 |
+
{% endif %}
|
| 62 |
+
{% if v.stage == 'Staging' %}
|
| 63 |
+
<button class="btn btn-success btn-sm"
|
| 64 |
+
onclick="transitionStage('{{ model.name }}','{{ v.version }}','Production', this)">
|
| 65 |
+
<i class="fa-solid fa-rocket"></i> Promote
|
| 66 |
+
</button>
|
| 67 |
+
{% endif %}
|
| 68 |
+
{% if v.stage == 'Production' %}
|
| 69 |
+
<button class="btn btn-warning btn-sm"
|
| 70 |
+
onclick="transitionStage('{{ model.name }}','{{ v.version }}','Archived', this)">
|
| 71 |
+
Archive
|
| 72 |
+
</button>
|
| 73 |
+
{% endif %}
|
| 74 |
+
</div>
|
| 75 |
+
</td>
|
| 76 |
+
</tr>
|
| 77 |
+
{% endfor %}
|
| 78 |
+
</tbody>
|
| 79 |
+
</table>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
{% endfor %}
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
{% else %}
|
| 86 |
+
<div class="card">
|
| 87 |
+
<div class="empty-state">
|
| 88 |
+
<div class="empty-state-icon">📦</div>
|
| 89 |
+
<div class="empty-state-title">No registered models yet</div>
|
| 90 |
+
<div style="margin-bottom:16px;max-width:400px;margin-left:auto;margin-right:auto">
|
| 91 |
+
Run the <strong>Training Pipeline</strong> or use the AutoML sweep,
|
| 92 |
+
then register the best model from the Experiments page.
|
| 93 |
+
</div>
|
| 94 |
+
<a href="/pipeline" class="btn btn-primary"><i class="fa-solid fa-diagram-project"></i> Go to Pipelines</a>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
{% endif %}
|
| 98 |
+
|
| 99 |
+
<!-- Register modal -->
|
| 100 |
+
<div class="modal-overlay" id="register-modal">
|
| 101 |
+
<div class="modal">
|
| 102 |
+
<div class="modal-header">
|
| 103 |
+
<div class="modal-title"><i class="fa-solid fa-plus" style="color:var(--accent)"></i> Register Model</div>
|
| 104 |
+
<button class="modal-close" onclick="document.getElementById('register-modal').classList.remove('open')"><i class="fa-solid fa-xmark"></i></button>
|
| 105 |
+
</div>
|
| 106 |
+
<div class="form-group">
|
| 107 |
+
<label class="form-label">MLflow Run ID</label>
|
| 108 |
+
<input type="text" class="form-control" id="reg-run-id" placeholder="e.g. abc12345…">
|
| 109 |
+
</div>
|
| 110 |
+
<div class="form-group">
|
| 111 |
+
<label class="form-label">Model Name</label>
|
| 112 |
+
<input type="text" class="form-control" id="reg-name" placeholder="e.g. iris-classifier">
|
| 113 |
+
</div>
|
| 114 |
+
<div class="modal-footer">
|
| 115 |
+
<button class="btn btn-ghost" onclick="document.getElementById('register-modal').classList.remove('open')">Cancel</button>
|
| 116 |
+
<button class="btn btn-primary" onclick="registerModel()"><i class="fa-solid fa-box-archive"></i> Register</button>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
{% endblock %}
|
| 121 |
+
|
| 122 |
+
{% block scripts %}
|
| 123 |
+
<script>
|
| 124 |
+
async function transitionStage(name, version, stage, btn) {
|
| 125 |
+
btn.disabled = true;
|
| 126 |
+
btn.innerHTML = '<span class="spinner" style="width:12px;height:12px;border-width:1.5px"></span>';
|
| 127 |
+
try {
|
| 128 |
+
const res = await fetch(`/api/models/${encodeURIComponent(name)}/${version}/stage`, {
|
| 129 |
+
method: 'POST', headers:{'Content-Type':'application/json'},
|
| 130 |
+
body: JSON.stringify({ stage }),
|
| 131 |
+
});
|
| 132 |
+
const data = await res.json();
|
| 133 |
+
if (data.error) { showToast(data.error, 'error'); btn.disabled = false; return; }
|
| 134 |
+
showToast(`${name} v${version} → ${stage}`, 'success');
|
| 135 |
+
setTimeout(() => location.reload(), 900);
|
| 136 |
+
} catch(e) { showToast('Request failed', 'error'); btn.disabled = false; }
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
async function registerModel() {
|
| 140 |
+
const runId = document.getElementById('reg-run-id').value.trim();
|
| 141 |
+
const name = document.getElementById('reg-name').value.trim();
|
| 142 |
+
if (!runId || !name) { showToast('Run ID and name are required', 'error'); return; }
|
| 143 |
+
try {
|
| 144 |
+
const res = await fetch('/api/models/register', {
|
| 145 |
+
method: 'POST', headers:{'Content-Type':'application/json'},
|
| 146 |
+
body: JSON.stringify({ run_id: runId, name }),
|
| 147 |
+
});
|
| 148 |
+
const data = await res.json();
|
| 149 |
+
if (data.error) { showToast(data.error, 'error'); return; }
|
| 150 |
+
showToast(`Registered ${data.name} v${data.version}`, 'success');
|
| 151 |
+
setTimeout(() => location.reload(), 900);
|
| 152 |
+
} catch(e) { showToast('Request failed', 'error'); }
|
| 153 |
+
}
|
| 154 |
+
</script>
|
| 155 |
+
{% endblock %}
|
templates/pipeline.html
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% set active_page = "pipeline" %}
|
| 3 |
+
|
| 4 |
+
{% block title %}Pipelines{% endblock %}
|
| 5 |
+
{% block page_title %}<i class="fa-solid fa-diagram-project" style="color:var(--cyan)"></i> Pipelines{% endblock %}
|
| 6 |
+
|
| 7 |
+
{% block content %}
|
| 8 |
+
<div class="page-title">Pipeline Orchestration</div>
|
| 9 |
+
<div class="page-sub">Airflow-style DAG visualisation and execution — click any pipeline to inspect and run</div>
|
| 10 |
+
|
| 11 |
+
<!-- Pipeline selector tabs -->
|
| 12 |
+
<div class="tab-bar" id="pipeline-tabs">
|
| 13 |
+
<button class="tab-btn active" onclick="switchPipeline('training_pipeline', this)">
|
| 14 |
+
<i class="fa-solid fa-brain"></i> Training Pipeline
|
| 15 |
+
</button>
|
| 16 |
+
<button class="tab-btn" onclick="switchPipeline('retraining_pipeline', this)">
|
| 17 |
+
<i class="fa-solid fa-rotate"></i> Retraining Pipeline
|
| 18 |
+
</button>
|
| 19 |
+
<button class="tab-btn" onclick="switchPipeline('data_pipeline', this)">
|
| 20 |
+
<i class="fa-solid fa-database"></i> Data Pipeline
|
| 21 |
+
</button>
|
| 22 |
+
</div>
|
| 23 |
+
|
| 24 |
+
<!-- Pipeline description + run button -->
|
| 25 |
+
<div class="card mb-20" style="margin-bottom:16px">
|
| 26 |
+
<div class="flex-between">
|
| 27 |
+
<div>
|
| 28 |
+
<div class="card-title" id="pipeline-name" style="font-size:1rem;margin-bottom:4px">Training Pipeline</div>
|
| 29 |
+
<div id="pipeline-desc" style="font-size:.85rem;color:var(--text-secondary)">
|
| 30 |
+
End-to-end model training: ingest → preprocess → train → evaluate → register
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
<div class="flex-gap">
|
| 34 |
+
<!-- Context form for training pipeline -->
|
| 35 |
+
<div id="ctx-form" style="display:flex;gap:8px;flex-wrap:wrap">
|
| 36 |
+
<select class="form-select" id="ctx-dataset" style="width:auto;padding:6px 28px 6px 10px;font-size:.82rem">
|
| 37 |
+
{% for name in datasets %}<option>{{ name }}</option>{% endfor %}
|
| 38 |
+
</select>
|
| 39 |
+
</div>
|
| 40 |
+
<button class="btn btn-primary" id="btn-run-pipeline" onclick="runPipeline()">
|
| 41 |
+
<i class="fa-solid fa-play"></i> Execute DAG
|
| 42 |
+
</button>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<!-- DAG canvas -->
|
| 48 |
+
<div class="card mb-20" style="margin-bottom:16px">
|
| 49 |
+
<div class="card-header">
|
| 50 |
+
<div class="card-title"><i class="fa-solid fa-sitemap" style="color:var(--accent)"></i> DAG Graph</div>
|
| 51 |
+
<span id="exec-status-badge"></span>
|
| 52 |
+
</div>
|
| 53 |
+
<div id="dag-canvas" class="dag-canvas" style="height:320px"></div>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<!-- Execution state grid + log -->
|
| 57 |
+
<div class="grid-2">
|
| 58 |
+
<div class="card">
|
| 59 |
+
<div class="card-header">
|
| 60 |
+
<div class="card-title"><i class="fa-solid fa-list-check" style="color:var(--success)"></i> Task Status</div>
|
| 61 |
+
<div id="exec-progress-wrap" style="display:flex;align-items:center;gap:8px;font-size:.82rem;color:var(--text-muted)">
|
| 62 |
+
<span id="exec-pct">—</span>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
<div id="task-list" style="display:flex;flex-direction:column;gap:6px"></div>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<div class="card">
|
| 69 |
+
<div class="card-header">
|
| 70 |
+
<div class="card-title"><i class="fa-solid fa-terminal" style="color:var(--warning)"></i> Execution Log</div>
|
| 71 |
+
<button class="btn btn-ghost btn-sm" onclick="clearLog()"><i class="fa-solid fa-trash"></i></button>
|
| 72 |
+
</div>
|
| 73 |
+
<div class="pipeline-log" id="exec-log">Waiting for execution…</div>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
{% endblock %}
|
| 77 |
+
|
| 78 |
+
{% block scripts %}
|
| 79 |
+
<script>
|
| 80 |
+
const DAGS = {{ dags | safe }};
|
| 81 |
+
let currentPipeline = 'training_pipeline';
|
| 82 |
+
let currentExecId = null;
|
| 83 |
+
let pollIv = null;
|
| 84 |
+
let currentDag = null;
|
| 85 |
+
|
| 86 |
+
const STATUS_COLORS = {
|
| 87 |
+
pending: '#30363d', running: '#f59e0b', success: '#22c55e', failed: '#ef4444',
|
| 88 |
+
};
|
| 89 |
+
const STATUS_ICONS = {
|
| 90 |
+
pending: '⏳', running: '⚡', success: '✅', failed: '❌',
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
// ── Init ─────────────────────────────────────────────────────────────────────
|
| 94 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 95 |
+
switchPipeline('training_pipeline',
|
| 96 |
+
document.querySelector('.tab-btn.active'));
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
function switchPipeline(id, btn) {
|
| 100 |
+
currentPipeline = id;
|
| 101 |
+
currentExecId = null;
|
| 102 |
+
if (pollIv) { clearInterval(pollIv); pollIv = null; }
|
| 103 |
+
|
| 104 |
+
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
| 105 |
+
btn.classList.add('active');
|
| 106 |
+
|
| 107 |
+
const dag = DAGS[id];
|
| 108 |
+
currentDag = dag;
|
| 109 |
+
|
| 110 |
+
document.getElementById('pipeline-name').textContent = dag.name;
|
| 111 |
+
document.getElementById('pipeline-desc').textContent = dag.description;
|
| 112 |
+
|
| 113 |
+
// Show dataset selector only for training pipeline
|
| 114 |
+
document.getElementById('ctx-form').style.display =
|
| 115 |
+
id === 'training_pipeline' ? 'flex' : 'none';
|
| 116 |
+
|
| 117 |
+
renderDAG(dag);
|
| 118 |
+
renderTaskList(dag, {});
|
| 119 |
+
document.getElementById('exec-log').textContent = 'Waiting for execution…';
|
| 120 |
+
document.getElementById('exec-status-badge').innerHTML = '';
|
| 121 |
+
document.getElementById('exec-pct').textContent = '—';
|
| 122 |
+
document.getElementById('btn-run-pipeline').disabled = false;
|
| 123 |
+
document.getElementById('btn-run-pipeline').innerHTML = '<i class="fa-solid fa-play"></i> Execute DAG';
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// ── DAG rendering with Plotly ─────────────────────────────────────────────────
|
| 127 |
+
function renderDAG(dag, taskStates) {
|
| 128 |
+
taskStates = taskStates || {};
|
| 129 |
+
const tasks = Object.values(dag.tasks);
|
| 130 |
+
const layers = {};
|
| 131 |
+
tasks.forEach(t => { layers[t.layer] = (layers[t.layer] || []); layers[t.layer].push(t); });
|
| 132 |
+
|
| 133 |
+
const nodeX = {}, nodeY = {};
|
| 134 |
+
const maxLayer = Math.max(...tasks.map(t => t.layer));
|
| 135 |
+
const xStep = 1 / (maxLayer + 1);
|
| 136 |
+
|
| 137 |
+
Object.entries(layers).forEach(([layer, ts]) => {
|
| 138 |
+
const xPos = (parseInt(layer) + 0.5) * xStep;
|
| 139 |
+
const yStep = 1 / (ts.length + 1);
|
| 140 |
+
ts.forEach((t, i) => {
|
| 141 |
+
nodeX[t.task_id] = xPos;
|
| 142 |
+
nodeY[t.task_id] = (i + 1) * yStep;
|
| 143 |
+
});
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
// Build edge traces
|
| 147 |
+
const edgeTraces = [];
|
| 148 |
+
tasks.forEach(t => {
|
| 149 |
+
t.upstream.forEach(upId => {
|
| 150 |
+
if (!nodeX[upId]) return;
|
| 151 |
+
edgeTraces.push({
|
| 152 |
+
type: 'scatter', mode: 'lines',
|
| 153 |
+
x: [nodeX[upId], nodeX[t.task_id]],
|
| 154 |
+
y: [nodeY[upId], nodeY[t.task_id]],
|
| 155 |
+
line: { color: '#30363d', width: 2 },
|
| 156 |
+
hoverinfo: 'none', showlegend: false,
|
| 157 |
+
});
|
| 158 |
+
});
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
// Node trace
|
| 162 |
+
const nodeColors = tasks.map(t => {
|
| 163 |
+
const st = (taskStates[t.task_id] || {}).status || 'pending';
|
| 164 |
+
return STATUS_COLORS[st] || '#30363d';
|
| 165 |
+
});
|
| 166 |
+
const nodeText = tasks.map(t => {
|
| 167 |
+
const st = (taskStates[t.task_id] || {}).status || 'pending';
|
| 168 |
+
return `${t.icon} ${t.name}<br><span style="font-size:10px">${STATUS_ICONS[st]} ${st}</span>`;
|
| 169 |
+
});
|
| 170 |
+
const nodeHover = tasks.map(t => {
|
| 171 |
+
const st = (taskStates[t.task_id] || {}).status || 'pending';
|
| 172 |
+
return `<b>${t.name}</b><br>${t.description}<br>Status: ${st}`;
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
const nodeTrace = {
|
| 176 |
+
type: 'scatter', mode: 'markers+text',
|
| 177 |
+
x: tasks.map(t => nodeX[t.task_id]),
|
| 178 |
+
y: tasks.map(t => nodeY[t.task_id]),
|
| 179 |
+
text: nodeText,
|
| 180 |
+
textposition: 'bottom center',
|
| 181 |
+
textfont: { color: '#e6edf3', size: 11 },
|
| 182 |
+
marker: {
|
| 183 |
+
size: 36,
|
| 184 |
+
color: nodeColors,
|
| 185 |
+
line: { color: '#e6edf3', width: 1.5 },
|
| 186 |
+
symbol: 'circle',
|
| 187 |
+
},
|
| 188 |
+
hovertemplate: nodeHover.map(h => h + '<extra></extra>'),
|
| 189 |
+
showlegend: false,
|
| 190 |
+
};
|
| 191 |
+
|
| 192 |
+
const bg = '#0d1117';
|
| 193 |
+
Plotly.react('dag-canvas', [...edgeTraces, nodeTrace], {
|
| 194 |
+
paper_bgcolor: bg, plot_bgcolor: bg,
|
| 195 |
+
margin: { t: 20, b: 40, l: 20, r: 20 },
|
| 196 |
+
xaxis: { showgrid: false, zeroline: false, showticklabels: false, range: [0,1] },
|
| 197 |
+
yaxis: { showgrid: false, zeroline: false, showticklabels: false, range: [0,1] },
|
| 198 |
+
dragmode: false,
|
| 199 |
+
}, { responsive: true, displayModeBar: false });
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// ── Task status list ──────────────────────────────────────────────────────────
|
| 203 |
+
function renderTaskList(dag, taskStates) {
|
| 204 |
+
const tasks = Object.values(dag.tasks).sort((a,b) => a.layer - b.layer);
|
| 205 |
+
const el = document.getElementById('task-list');
|
| 206 |
+
el.innerHTML = tasks.map(t => {
|
| 207 |
+
const st = (taskStates[t.task_id] || {}).status || 'pending';
|
| 208 |
+
const res = (taskStates[t.task_id] || {}).result || '';
|
| 209 |
+
const clr = STATUS_COLORS[st] || '#30363d';
|
| 210 |
+
return `<div style="display:flex;align-items:flex-start;gap:10px;padding:8px 10px;border-radius:6px;background:var(--bg-tertiary)">
|
| 211 |
+
<div style="width:10px;height:10px;border-radius:50%;background:${clr};margin-top:4px;flex-shrink:0"></div>
|
| 212 |
+
<div style="flex:1;min-width:0">
|
| 213 |
+
<div style="font-size:.85rem;font-weight:500">${t.icon} ${t.name}</div>
|
| 214 |
+
${res ? `<div style="font-size:.75rem;color:var(--text-muted);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${res}">${res}</div>` : ''}
|
| 215 |
+
</div>
|
| 216 |
+
<span style="font-size:.72rem;padding:2px 7px;border-radius:10px;background:${clr}22;color:${clr};white-space:nowrap">${STATUS_ICONS[st]} ${st}</span>
|
| 217 |
+
</div>`;
|
| 218 |
+
}).join('');
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
// ── Execute pipeline ──────────────────────────────────────────────────────────
|
| 222 |
+
async function runPipeline() {
|
| 223 |
+
document.getElementById('btn-run-pipeline').disabled = true;
|
| 224 |
+
document.getElementById('btn-run-pipeline').innerHTML = '<span class="spinner"></span> Running…';
|
| 225 |
+
document.getElementById('exec-log').textContent = '';
|
| 226 |
+
document.getElementById('exec-status-badge').innerHTML =
|
| 227 |
+
'<span class="badge badge-warning"><span class="spinner" style="width:9px;height:9px;border-width:1.5px"></span> Running</span>';
|
| 228 |
+
|
| 229 |
+
const ctx = {};
|
| 230 |
+
if (currentPipeline === 'training_pipeline') {
|
| 231 |
+
ctx.dataset = document.getElementById('ctx-dataset').value;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
try {
|
| 235 |
+
const res = await fetch(`/api/pipeline/${currentPipeline}/execute`, {
|
| 236 |
+
method: 'POST', headers:{'Content-Type':'application/json'},
|
| 237 |
+
body: JSON.stringify(ctx),
|
| 238 |
+
});
|
| 239 |
+
const data = await res.json();
|
| 240 |
+
currentExecId = data.exec_id;
|
| 241 |
+
pollExecution();
|
| 242 |
+
} catch(e) {
|
| 243 |
+
showToast('Failed to start pipeline', 'error');
|
| 244 |
+
document.getElementById('btn-run-pipeline').disabled = false;
|
| 245 |
+
document.getElementById('btn-run-pipeline').innerHTML = '<i class="fa-solid fa-play"></i> Execute DAG';
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
function pollExecution() {
|
| 250 |
+
if (pollIv) clearInterval(pollIv);
|
| 251 |
+
pollIv = setInterval(async () => {
|
| 252 |
+
const res = await fetch(`/api/pipeline/status/${currentExecId}`);
|
| 253 |
+
const exec = await res.json();
|
| 254 |
+
|
| 255 |
+
// Update progress
|
| 256 |
+
document.getElementById('exec-pct').textContent = exec.progress + '%';
|
| 257 |
+
|
| 258 |
+
// Update log
|
| 259 |
+
const logEl = document.getElementById('exec-log');
|
| 260 |
+
logEl.innerHTML = (exec.logs || []).map(line => {
|
| 261 |
+
let cls = '';
|
| 262 |
+
if (line.includes('✔')) cls = 'log-line-ok';
|
| 263 |
+
else if (line.includes('✖')) cls = 'log-line-err';
|
| 264 |
+
else if (line.includes('▶')) cls = 'log-line-info';
|
| 265 |
+
return `<div class="${cls}">${line}</div>`;
|
| 266 |
+
}).join('');
|
| 267 |
+
logEl.scrollTop = logEl.scrollHeight;
|
| 268 |
+
|
| 269 |
+
// Update DAG
|
| 270 |
+
if (exec.task_states) {
|
| 271 |
+
renderDAG(currentDag, exec.task_states);
|
| 272 |
+
renderTaskList(currentDag, exec.task_states);
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
if (exec.status === 'completed') {
|
| 276 |
+
clearInterval(pollIv); pollIv = null;
|
| 277 |
+
document.getElementById('exec-status-badge').innerHTML =
|
| 278 |
+
'<span class="badge badge-success"><i class="fa-solid fa-check"></i> Completed</span>';
|
| 279 |
+
document.getElementById('btn-run-pipeline').disabled = false;
|
| 280 |
+
document.getElementById('btn-run-pipeline').innerHTML = '<i class="fa-solid fa-rotate-right"></i> Run Again';
|
| 281 |
+
showToast(`Pipeline "${currentDag.name}" completed`, 'success');
|
| 282 |
+
} else if (exec.status === 'failed') {
|
| 283 |
+
clearInterval(pollIv); pollIv = null;
|
| 284 |
+
document.getElementById('exec-status-badge').innerHTML =
|
| 285 |
+
'<span class="badge badge-danger"><i class="fa-solid fa-xmark"></i> Failed</span>';
|
| 286 |
+
document.getElementById('btn-run-pipeline').disabled = false;
|
| 287 |
+
document.getElementById('btn-run-pipeline').innerHTML = '<i class="fa-solid fa-play"></i> Retry';
|
| 288 |
+
showToast('Pipeline failed: ' + (exec.error || 'unknown'), 'error');
|
| 289 |
+
}
|
| 290 |
+
}, 800);
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
function clearLog() { document.getElementById('exec-log').textContent = 'Cleared.'; }
|
| 294 |
+
</script>
|
| 295 |
+
{% endblock %}
|
templates/runs.html
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% set active_page = "experiments" %}
|
| 3 |
+
|
| 4 |
+
{% block title %}{{ experiment.name }} Runs{% endblock %}
|
| 5 |
+
{% block page_title %}<i class="fa-solid fa-flask" style="color:var(--accent-blue)"></i> {{ experiment.name }}{% endblock %}
|
| 6 |
+
|
| 7 |
+
{% block topnav_actions %}
|
| 8 |
+
<button class="btn btn-ghost btn-sm" id="btn-compare" onclick="compareSelected()" disabled>
|
| 9 |
+
<i class="fa-solid fa-code-compare"></i> Compare Selected
|
| 10 |
+
</button>
|
| 11 |
+
{% endblock %}
|
| 12 |
+
|
| 13 |
+
{% block content %}
|
| 14 |
+
<div class="flex-gap mb-20" style="margin-bottom:16px">
|
| 15 |
+
<a href="/experiments" class="btn btn-ghost btn-sm"><i class="fa-solid fa-arrow-left"></i> All Experiments</a>
|
| 16 |
+
<span class="badge badge-info">{{ runs | length }} runs</span>
|
| 17 |
+
</div>
|
| 18 |
+
|
| 19 |
+
<!-- Filter bar -->
|
| 20 |
+
<div class="card card-sm mb-20" style="margin-bottom:16px">
|
| 21 |
+
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
|
| 22 |
+
<div style="font-size:.82rem;color:var(--text-muted);margin-right:4px"><i class="fa-solid fa-filter"></i> Filter:</div>
|
| 23 |
+
<select class="form-select" id="filter-task" style="width:auto;padding:5px 28px 5px 10px;font-size:.82rem" onchange="filterRuns()">
|
| 24 |
+
<option value="">All tasks</option>
|
| 25 |
+
<option value="classification">Classification</option>
|
| 26 |
+
<option value="regression">Regression</option>
|
| 27 |
+
</select>
|
| 28 |
+
<select class="form-select" id="filter-cat" style="width:auto;padding:5px 28px 5px 10px;font-size:.82rem" onchange="filterRuns()">
|
| 29 |
+
<option value="">All categories</option>
|
| 30 |
+
{% set cats = runs | map(attribute='category') | unique | list %}
|
| 31 |
+
{% for cat in cats %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}
|
| 32 |
+
</select>
|
| 33 |
+
<input type="text" class="form-control" id="filter-search" placeholder="Search algorithm…"
|
| 34 |
+
style="width:200px;padding:5px 10px;font-size:.82rem" oninput="filterRuns()">
|
| 35 |
+
<button class="btn btn-ghost btn-sm" onclick="clearFilters()"><i class="fa-solid fa-xmark"></i> Clear</button>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<!-- Runs table -->
|
| 40 |
+
<div class="card">
|
| 41 |
+
<div class="table-wrap">
|
| 42 |
+
<table id="runs-table">
|
| 43 |
+
<thead>
|
| 44 |
+
<tr>
|
| 45 |
+
<th style="width:32px"><input type="checkbox" id="chk-all" onchange="toggleAll(this)"></th>
|
| 46 |
+
<th>Run ID</th><th>Algorithm</th><th>Category</th><th>Task</th>
|
| 47 |
+
{% if runs and runs[0].task_type == 'classification' %}
|
| 48 |
+
<th>Accuracy</th><th>F1</th><th>Precision</th><th>Recall</th>
|
| 49 |
+
{% else %}
|
| 50 |
+
<th>R²</th><th>MAE</th><th>RMSE</th>
|
| 51 |
+
{% endif %}
|
| 52 |
+
<th>Duration</th><th>Started</th>
|
| 53 |
+
</tr>
|
| 54 |
+
</thead>
|
| 55 |
+
<tbody id="runs-tbody">
|
| 56 |
+
{% for r in runs %}
|
| 57 |
+
<tr data-task="{{ r.task_type }}" data-cat="{{ r.category }}" data-alg="{{ r.algorithm | lower }}"
|
| 58 |
+
data-run="{{ r.run_id }}">
|
| 59 |
+
<td><input type="checkbox" class="run-chk" value="{{ r.run_id }}" onchange="updateCompareBtn()"></td>
|
| 60 |
+
<td><code style="font-size:.78rem;color:var(--accent-light)">{{ r.run_id_short }}</code></td>
|
| 61 |
+
<td><strong>{{ r.algorithm }}</strong></td>
|
| 62 |
+
<td><span class="badge badge-purple">{{ r.category }}</span></td>
|
| 63 |
+
<td><span class="badge {% if r.task_type == 'classification' %}badge-info{% else %}badge-warning{% endif %}">{{ r.task_type }}</span></td>
|
| 64 |
+
{% if r.task_type == 'classification' %}
|
| 65 |
+
{% set acc = r.metrics.get('accuracy', 0) %}
|
| 66 |
+
<td><span class="metric-val {% if acc >= 0.9 %}metric-good{% elif acc >= 0.7 %}metric-medium{% else %}metric-bad{% endif %}">{{ acc }}</span></td>
|
| 67 |
+
<td>{{ r.metrics.get('f1_score', '—') }}</td>
|
| 68 |
+
<td>{{ r.metrics.get('precision', '—') }}</td>
|
| 69 |
+
<td>{{ r.metrics.get('recall', '—') }}</td>
|
| 70 |
+
{% else %}
|
| 71 |
+
{% set r2 = r.metrics.get('r2_score', 0) %}
|
| 72 |
+
<td><span class="metric-val {% if r2 >= 0.8 %}metric-good{% elif r2 >= 0.5 %}metric-medium{% else %}metric-bad{% endif %}">{{ r2 }}</span></td>
|
| 73 |
+
<td>{{ r.metrics.get('mae', '—') }}</td>
|
| 74 |
+
<td>{{ r.metrics.get('rmse', '—') }}</td>
|
| 75 |
+
{% endif %}
|
| 76 |
+
<td>{{ r.duration }}s</td>
|
| 77 |
+
<td style="color:var(--text-muted);font-size:.8rem">{{ r.start_time }}</td>
|
| 78 |
+
</tr>
|
| 79 |
+
{% else %}
|
| 80 |
+
<tr><td colspan="12">
|
| 81 |
+
<div class="empty-state"><div class="empty-state-icon">📊</div><div class="empty-state-title">No runs in this experiment</div></div>
|
| 82 |
+
</td></tr>
|
| 83 |
+
{% endfor %}
|
| 84 |
+
</tbody>
|
| 85 |
+
</table>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<!-- Compare Modal -->
|
| 90 |
+
<div class="modal-overlay" id="compare-modal">
|
| 91 |
+
<div class="modal" style="width:min(820px,96vw)">
|
| 92 |
+
<div class="modal-header">
|
| 93 |
+
<div class="modal-title"><i class="fa-solid fa-code-compare" style="color:var(--accent)"></i> Run Comparison</div>
|
| 94 |
+
<button class="modal-close" onclick="document.getElementById('compare-modal').classList.remove('open')"><i class="fa-solid fa-xmark"></i></button>
|
| 95 |
+
</div>
|
| 96 |
+
<div id="compare-chart" style="height:320px"></div>
|
| 97 |
+
<div id="compare-table" style="margin-top:16px"></div>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
{% endblock %}
|
| 101 |
+
|
| 102 |
+
{% block scripts %}
|
| 103 |
+
<script>
|
| 104 |
+
const ALL_RUNS = {{ runs | tojson }};
|
| 105 |
+
|
| 106 |
+
function filterRuns() {
|
| 107 |
+
const task = document.getElementById('filter-task').value.toLowerCase();
|
| 108 |
+
const cat = document.getElementById('filter-cat').value.toLowerCase();
|
| 109 |
+
const search = document.getElementById('filter-search').value.toLowerCase();
|
| 110 |
+
document.querySelectorAll('#runs-tbody tr[data-run]').forEach(row => {
|
| 111 |
+
const matchTask = !task || row.dataset.task === task;
|
| 112 |
+
const matchCat = !cat || row.dataset.cat.toLowerCase() === cat;
|
| 113 |
+
const matchAlg = !search || row.dataset.alg.includes(search);
|
| 114 |
+
row.style.display = (matchTask && matchCat && matchAlg) ? '' : 'none';
|
| 115 |
+
});
|
| 116 |
+
}
|
| 117 |
+
function clearFilters() {
|
| 118 |
+
document.getElementById('filter-task').value = '';
|
| 119 |
+
document.getElementById('filter-cat').value = '';
|
| 120 |
+
document.getElementById('filter-search').value = '';
|
| 121 |
+
filterRuns();
|
| 122 |
+
}
|
| 123 |
+
function toggleAll(cb) {
|
| 124 |
+
document.querySelectorAll('.run-chk').forEach(c => c.checked = cb.checked);
|
| 125 |
+
updateCompareBtn();
|
| 126 |
+
}
|
| 127 |
+
function updateCompareBtn() {
|
| 128 |
+
const count = document.querySelectorAll('.run-chk:checked').length;
|
| 129 |
+
document.getElementById('btn-compare').disabled = count < 2;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function compareSelected() {
|
| 133 |
+
const checked = [...document.querySelectorAll('.run-chk:checked')].map(c => c.value);
|
| 134 |
+
const selected = ALL_RUNS.filter(r => checked.includes(r.run_id));
|
| 135 |
+
if (selected.length < 2) return;
|
| 136 |
+
|
| 137 |
+
const isClass = selected[0].task_type === 'classification';
|
| 138 |
+
const metricKeys = isClass
|
| 139 |
+
? ['accuracy','f1_score','precision','recall']
|
| 140 |
+
: ['r2_score','mae','mse','rmse'];
|
| 141 |
+
|
| 142 |
+
const colors = ['#8b5cf6','#3b82f6','#22c55e','#f59e0b','#ef4444','#06b6d4'];
|
| 143 |
+
const traces = metricKeys.map((mk, i) => ({
|
| 144 |
+
type: 'bar',
|
| 145 |
+
name: mk,
|
| 146 |
+
x: selected.map(r => r.algorithm),
|
| 147 |
+
y: selected.map(r => r.metrics[mk] || 0),
|
| 148 |
+
marker: { color: colors[i] },
|
| 149 |
+
hovertemplate: `<b>%{x}</b><br>${mk}: %{y:.4f}<extra></extra>`,
|
| 150 |
+
}));
|
| 151 |
+
|
| 152 |
+
const bg2 = '#161b22', border = '#30363d', txt = '#8b949e';
|
| 153 |
+
Plotly.newPlot('compare-chart', traces, {
|
| 154 |
+
paper_bgcolor: bg2, plot_bgcolor: bg2, barmode: 'group',
|
| 155 |
+
margin: { t:12, b:60, l:48, r:12 },
|
| 156 |
+
xaxis: { color: txt, tickfont:{size:10}, gridcolor: border },
|
| 157 |
+
yaxis: { color: txt, tickfont:{size:10}, gridcolor: border },
|
| 158 |
+
legend: { font:{color: txt, size:11}, bgcolor:'transparent' },
|
| 159 |
+
}, { responsive:true, displayModeBar:false });
|
| 160 |
+
|
| 161 |
+
// Table
|
| 162 |
+
let html = `<div class="table-wrap"><table><thead><tr><th>Algorithm</th><th>Dataset</th>`;
|
| 163 |
+
metricKeys.forEach(mk => html += `<th>${mk}</th>`);
|
| 164 |
+
html += '</tr></thead><tbody>';
|
| 165 |
+
selected.forEach(r => {
|
| 166 |
+
html += `<tr><td><strong>${r.algorithm}</strong></td><td>${r.dataset}</td>`;
|
| 167 |
+
metricKeys.forEach(mk => {
|
| 168 |
+
const v = r.metrics[mk];
|
| 169 |
+
html += `<td>${v !== undefined ? v : '—'}</td>`;
|
| 170 |
+
});
|
| 171 |
+
html += '</tr>';
|
| 172 |
+
});
|
| 173 |
+
html += '</tbody></table></div>';
|
| 174 |
+
document.getElementById('compare-table').innerHTML = html;
|
| 175 |
+
|
| 176 |
+
document.getElementById('compare-modal').classList.add('open');
|
| 177 |
+
}
|
| 178 |
+
</script>
|
| 179 |
+
{% endblock %}
|