diff --git a/README.md b/README.md
index 9035d44..b4b7b7b 100644
--- a/README.md
+++ b/README.md
@@ -20,8 +20,8 @@ A full-stack tool for generating, indexing, and visualizing embedding
clusters from CSV data. Feed it a CSV with image URLs or text fields,
and it generates vector embeddings using CLIP (images) and
SentenceTransformer (text) models, stores them in ChromaDB, clusters
-via k-means, and renders an interactive 3D scatter plot using t-SNE
-dimensionality reduction.
+via k-means, and renders an interactive 3D scatter plot with
+selectable dimensionality reduction (t-SNE, UMAP, or PCA).
The project supports three running modes: CLI-based batch indexing,
CLI-based Dash visualization, and a full web application with a
@@ -39,7 +39,8 @@ FastAPI backend and React frontend.
curve) and silhouette score analysis across k=2..30, with an
interactive chart to review the trade-off before applying.
* Visualize clustering results in an interactive 3D scatter plot
- using t-SNE dimensionality reduction.
+ with selectable dimensionality reduction: t-SNE, UMAP, or PCA,
+ each with configurable algorithm-specific parameters.
* Web UI with FastAPI backend and React frontend for browser-based
indexing and visualization.
* Three switchable 3D render modes: colored particles, image sprites,
@@ -71,7 +72,7 @@ FastAPI backend and React frontend.
|
StandardScaler + KMeans
|
- t-SNE (3D projection)
+ t-SNE / UMAP / PCA (3D projection)
|
3D Scatter Plot
(Dash CLI / React Web UI)
@@ -102,6 +103,9 @@ pre-commit hooks, commitizen
> Models are downloaded from [HuggingFace](https://huggingface.co)
> on first run. Ensure network access to `huggingface.co`.
+> UMAP support is optional. To install it:
+> `uv sync --extra umap` or `uv pip install umap-learn`.
+> t-SNE and PCA work without extra dependencies.
## Usage
@@ -172,6 +176,12 @@ collection name is the prefix combined with the embedded field name.
| `GPT_GENERATE_CLUSTER_NAME` | Use GPT to name clusters (needs `OPENAI_API_KEY`) | No | `False` |
| `GPT_DEFAULT_MODEL` | GPT model for cluster naming | No | `gpt-3.5-turbo` |
| `GPT_DEFAULT_TEMPERATURE` | GPT temperature for cluster naming | No | `0.51` |
+| `REDUCTION_ALGORITHM` | Dimensionality reduction (`tsne`, `umap`, `pca`) | No | `tsne` |
+| `TSNE_PERPLEXITY` | t-SNE perplexity (5--50) | No | `30.0` |
+| `TSNE_LEARNING_RATE` | t-SNE learning rate (`auto` or numeric) | No | `auto` |
+| `UMAP_N_NEIGHBORS` | UMAP neighbor count (2--100) | No | `15` |
+| `UMAP_MIN_DIST` | UMAP minimum distance (0--1) | No | `0.1` |
+| `UMAP_METRIC` | UMAP distance metric | No | `cosine` |
```bash
RUNNING_MODE=PLOT \
diff --git a/embedding_cluster/scatter_plot.py b/embedding_cluster/scatter_plot.py
index 49ad5a9..da1b173 100644
--- a/embedding_cluster/scatter_plot.py
+++ b/embedding_cluster/scatter_plot.py
@@ -10,6 +10,7 @@
from dash import Dash, Input, Output, callback, dcc, html, no_update
from openai import OpenAI
from sklearn.cluster import KMeans
+from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler
@@ -24,6 +25,52 @@
logger = logging.getLogger(__name__)
+def reduce_dimensions(
+ embeddings: np.ndarray,
+ algorithm: str = "tsne",
+ n_components: int = 3,
+ random_state: int = 171,
+ **kwargs: Any,
+) -> np.ndarray:
+ """Reduce embedding dimensions using the specified algorithm."""
+ if algorithm == "tsne":
+ perplexity = kwargs.get("perplexity", 30.0)
+ learning_rate = kwargs.get("learning_rate", "auto")
+ reducer = TSNE(
+ n_components=n_components,
+ perplexity=perplexity,
+ learning_rate=learning_rate,
+ random_state=random_state,
+ verbose=1,
+ max_iter=1000,
+ )
+ elif algorithm == "umap":
+ try:
+ import umap
+ except ImportError as exc:
+ msg = (
+ "umap-learn is not installed. Install it with: uv pip install umap-learn"
+ )
+ raise ImportError(msg) from exc
+ n_neighbors = kwargs.get("n_neighbors", 15)
+ min_dist = kwargs.get("min_dist", 0.1)
+ metric = kwargs.get("metric", "cosine")
+ reducer = umap.UMAP(
+ n_components=n_components,
+ n_neighbors=n_neighbors,
+ min_dist=min_dist,
+ metric=metric,
+ random_state=random_state,
+ )
+ elif algorithm == "pca":
+ reducer = PCA(n_components=n_components)
+ else:
+ msg = f"Unknown reduction algorithm: '{algorithm}'. Supported: tsne, umap, pca"
+ raise ValueError(msg)
+ result: np.ndarray = reducer.fit_transform(embeddings)
+ return result
+
+
def gpt_get_cluster_name(info: str, settings: Settings) -> str:
openai_client = OpenAI()
messages: list[dict[str, str]] = [
@@ -196,7 +243,7 @@ def generate_cluster_props(
def compute_plot_data(settings: Settings) -> dict[str, Any]:
- """Compute t-SNE + k-means and return raw data (no Plotly/Dash)."""
+ """Compute dimensionality reduction + k-means and return raw data."""
random_state = 171
n_iter = 1000
collection_content_text_display: list[str] = []
@@ -211,15 +258,23 @@ def compute_plot_data(settings: Settings) -> dict[str, Any]:
collection_content_vectors = collection_content["embeddings"]
np_embeddings_arr = np.array(collection_content_vectors)
- logger.info("Calculating t-SNE ...")
- tsne = TSNE(
- verbose=1,
- learning_rate="auto",
- max_iter=n_iter,
- perplexity=30,
+ algorithm = settings.reduction_algorithm
+ logger.info("Calculating %s ...", algorithm.upper())
+ reduction_kwargs: dict[str, Any] = {}
+ if algorithm == "tsne":
+ reduction_kwargs["perplexity"] = settings.tsne_perplexity
+ reduction_kwargs["learning_rate"] = settings.tsne_learning_rate
+ elif algorithm == "umap":
+ reduction_kwargs["n_neighbors"] = settings.umap_n_neighbors
+ reduction_kwargs["min_dist"] = settings.umap_min_dist
+ reduction_kwargs["metric"] = settings.umap_metric
+ reduced = reduce_dimensions(
+ np_embeddings_arr,
+ algorithm=algorithm,
n_components=3,
random_state=random_state,
- ).fit_transform(np_embeddings_arr)
+ **reduction_kwargs,
+ )
common_params: dict[str, Any] = {
"n_init": "auto",
@@ -262,9 +317,9 @@ def compute_plot_data(settings: Settings) -> dict[str, Any]:
)
points.append(
{
- "x": float(tsne[idx, 0]),
- "y": float(tsne[idx, 1]),
- "z": float(tsne[idx, 2]),
+ "x": float(reduced[idx, 0]),
+ "y": float(reduced[idx, 1]),
+ "z": float(reduced[idx, 2]),
"cluster": cluster_i,
"metadata": metadata,
"id": point_id,
diff --git a/embedding_cluster/server/models.py b/embedding_cluster/server/models.py
index a5f0665..8de9ca2 100644
--- a/embedding_cluster/server/models.py
+++ b/embedding_cluster/server/models.py
@@ -1,6 +1,8 @@
from __future__ import annotations
-from pydantic import BaseModel
+from typing import Literal
+
+from pydantic import BaseModel, field_validator
class CollectionInfo(BaseModel):
@@ -71,6 +73,24 @@ class PlotRequest(BaseModel):
gpt_generate_cluster_name: bool = False
gpt_default_model: str = "gpt-3.5-turbo"
gpt_default_temperature: float = 0.51
+ reduction_algorithm: Literal["tsne", "umap", "pca"] = "tsne"
+ tsne_perplexity: float = 30.0
+ tsne_learning_rate: str = "auto"
+ umap_n_neighbors: int = 15
+ umap_min_dist: float = 0.1
+ umap_metric: str = "cosine"
+
+ @field_validator("reduction_algorithm")
+ @classmethod
+ def validate_algorithm(cls, v: str) -> str:
+ allowed = {"tsne", "umap", "pca"}
+ if v not in allowed:
+ msg = (
+ f"Invalid reduction algorithm: '{v}'. "
+ f"Must be one of: {', '.join(sorted(allowed))}"
+ )
+ raise ValueError(msg)
+ return v
class SuggestClustersRequest(BaseModel):
diff --git a/embedding_cluster/server/routes/plot.py b/embedding_cluster/server/routes/plot.py
index 5213060..cba9303 100644
--- a/embedding_cluster/server/routes/plot.py
+++ b/embedding_cluster/server/routes/plot.py
@@ -36,6 +36,12 @@ async def _run_compute(task_state: TaskState, request: PlotRequest) -> None:
gpt_generate_cluster_name=request.gpt_generate_cluster_name,
gpt_default_model=request.gpt_default_model,
gpt_default_temperature=request.gpt_default_temperature,
+ reduction_algorithm=request.reduction_algorithm,
+ tsne_perplexity=request.tsne_perplexity,
+ tsne_learning_rate=request.tsne_learning_rate,
+ umap_n_neighbors=request.umap_n_neighbors,
+ umap_min_dist=request.umap_min_dist,
+ umap_metric=request.umap_metric,
)
task_state.status = TaskStatus.RUNNING
result = await asyncio.to_thread(compute_plot_data, settings)
diff --git a/embedding_cluster/settings.py b/embedding_cluster/settings.py
index 82d5798..a7677ec 100644
--- a/embedding_cluster/settings.py
+++ b/embedding_cluster/settings.py
@@ -66,6 +66,16 @@ class Settings(BaseSettings):
default=None, description="field name for the image to present on plot"
)
+ reduction_algorithm: str = Field(
+ default="tsne",
+ description="Dimensionality reduction algorithm: tsne, umap, or pca",
+ )
+ tsne_perplexity: float = Field(default=30.0, description="t-SNE perplexity parameter")
+ tsne_learning_rate: str = Field(default="auto", description="t-SNE learning rate")
+ umap_n_neighbors: int = Field(default=15, description="UMAP number of neighbors")
+ umap_min_dist: float = Field(default=0.1, description="UMAP minimum distance")
+ umap_metric: str = Field(default="cosine", description="UMAP distance metric")
+
gpt_generate_cluster_name: bool = Field(
default=False, description="Generate cluster names using GPT"
)
diff --git a/frontend/src/components/plot/PlotControls.tsx b/frontend/src/components/plot/PlotControls.tsx
index 75684b5..7de1ad7 100644
--- a/frontend/src/components/plot/PlotControls.tsx
+++ b/frontend/src/components/plot/PlotControls.tsx
@@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { fetchCollections, fetchCollection } from '../../api/collections'
import { usePlotStore } from '../../stores/plotStore'
-import type { PlotRequest } from '../../types'
+import type { PlotRequest, ReductionAlgorithm } from '../../types'
import ClusterSuggestion from './ClusterSuggestion'
interface PlotControlsProps {
@@ -21,7 +21,15 @@ export default function PlotControls({ onCompute, isComputing }: PlotControlsPro
const [gptModel, setGptModel] = useState('gpt-3.5-turbo')
const [gptTemperature, setGptTemperature] = useState(0.51)
- const { renderMode, setRenderMode, pointSize, setPointSize } = usePlotStore()
+ const {
+ renderMode, setRenderMode, pointSize, setPointSize,
+ reductionAlgorithm, setReductionAlgorithm,
+ tsnePerplexity, setTsnePerplexity,
+ tsneLearningRate, setTsneLearningRate,
+ umapNNeighbors, setUmapNNeighbors,
+ umapMinDist, setUmapMinDist,
+ umapMetric, setUmapMetric,
+ } = usePlotStore()
// 1. Fetch collection list
const { data: collections } = useQuery({
@@ -61,6 +69,16 @@ export default function PlotControls({ onCompute, isComputing }: PlotControlsPro
gpt_generate_cluster_name: gptEnabled,
gpt_default_model: gptEnabled ? gptModel : undefined,
gpt_default_temperature: gptEnabled ? gptTemperature : undefined,
+ reduction_algorithm: reductionAlgorithm,
+ ...(reductionAlgorithm === 'tsne' && {
+ tsne_perplexity: tsnePerplexity,
+ tsne_learning_rate: tsneLearningRate,
+ }),
+ ...(reductionAlgorithm === 'umap' && {
+ umap_n_neighbors: umapNNeighbors,
+ umap_min_dist: umapMinDist,
+ umap_metric: umapMetric,
+ }),
}
onCompute(request)
}
@@ -115,6 +133,115 @@ export default function PlotControls({ onCompute, isComputing }: PlotControlsPro
/>
+ {/* Reduction Algorithm */}
+
+
+
+ {(['tsne', 'umap', 'pca'] as const).map((algo) => (
+
+ ))}
+
+
+ {/* t-SNE parameters */}
+ {reductionAlgorithm === 'tsne' && (
+
+
+
+ setTsnePerplexity(Number(e.target.value))}
+ className="w-full"
+ />
+
+
+
+
+
+
+ )}
+
+ {/* UMAP parameters */}
+ {reductionAlgorithm === 'umap' && (
+
+
+
+ setUmapNNeighbors(Number(e.target.value))}
+ className="w-full"
+ />
+
+
+
+ setUmapMinDist(Number(e.target.value))}
+ className="w-full"
+ />
+
+
+
+
+
+
+ )}
+
+ {/* PCA has no extra parameters */}
+ {reductionAlgorithm === 'pca' && (
+
+ PCA has no additional parameters.
+
+ )}
+
+
{/* Text Display Fields */}
diff --git a/frontend/src/stores/plotStore.ts b/frontend/src/stores/plotStore.ts
index 3b6d9da..157cc9a 100644
--- a/frontend/src/stores/plotStore.ts
+++ b/frontend/src/stores/plotStore.ts
@@ -1,5 +1,5 @@
import { create } from 'zustand'
-import type { PlotResponse, SearchResult } from '../types'
+import type { PlotResponse, ReductionAlgorithm, SearchResult } from '../types'
interface PlotState {
plotData: PlotResponse | null
@@ -11,6 +11,12 @@ interface PlotState {
highlightedIds: Set
isSearching: boolean
queryPoint: { x: number; y: number; z: number } | null
+ reductionAlgorithm: ReductionAlgorithm
+ tsnePerplexity: number
+ tsneLearningRate: string
+ umapNNeighbors: number
+ umapMinDist: number
+ umapMetric: string
// actions
setPlotData: (data: PlotResponse | null) => void
toggleCluster: (index: number) => void
@@ -23,6 +29,12 @@ interface PlotState {
setIsSearching: (searching: boolean) => void
clearSearch: () => void
setQueryPoint: (point: { x: number; y: number; z: number } | null) => void
+ setReductionAlgorithm: (algorithm: ReductionAlgorithm) => void
+ setTsnePerplexity: (perplexity: number) => void
+ setTsneLearningRate: (rate: string) => void
+ setUmapNNeighbors: (n: number) => void
+ setUmapMinDist: (dist: number) => void
+ setUmapMetric: (metric: string) => void
}
export const CLUSTER_COLORS = [
@@ -42,6 +54,12 @@ export const usePlotStore = create((set) => ({
highlightedIds: new Set(),
isSearching: false,
queryPoint: null,
+ reductionAlgorithm: 'tsne',
+ tsnePerplexity: 30,
+ tsneLearningRate: 'auto',
+ umapNNeighbors: 15,
+ umapMinDist: 0.1,
+ umapMetric: 'cosine',
setPlotData: (data) => set({ plotData: data }),
@@ -80,4 +98,10 @@ export const usePlotStore = create((set) => ({
isSearching: false,
queryPoint: null,
}),
+ setReductionAlgorithm: (algorithm) => set({ reductionAlgorithm: algorithm }),
+ setTsnePerplexity: (perplexity) => set({ tsnePerplexity: perplexity }),
+ setTsneLearningRate: (rate) => set({ tsneLearningRate: rate }),
+ setUmapNNeighbors: (n) => set({ umapNNeighbors: n }),
+ setUmapMinDist: (dist) => set({ umapMinDist: dist }),
+ setUmapMetric: (metric) => set({ umapMetric: metric }),
}))
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index b569381..a1839ab 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -58,14 +58,22 @@ export interface IndexStatusResponse {
}
// Plot
+export type ReductionAlgorithm = 'tsne' | 'umap' | 'pca'
+
export interface PlotRequest {
- chromadb_collection_name: string;
- num_clusters?: number;
- text_display_fields?: string[];
- image_field?: string;
- gpt_generate_cluster_name?: boolean;
- gpt_default_model?: string;
- gpt_default_temperature?: number;
+ chromadb_collection_name: string
+ num_clusters?: number
+ text_display_fields?: string[]
+ image_field?: string
+ gpt_generate_cluster_name?: boolean
+ gpt_default_model?: string
+ gpt_default_temperature?: number
+ reduction_algorithm?: ReductionAlgorithm
+ tsne_perplexity?: number
+ tsne_learning_rate?: string
+ umap_n_neighbors?: number
+ umap_min_dist?: number
+ umap_metric?: string
}
export interface PlotPoint {
diff --git a/pyproject.toml b/pyproject.toml
index e489a6c..61f6c42 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,6 +35,9 @@ dev = [
"pre-commit>=4,<5",
"httpx>=0.28,<1",
]
+umap = [
+ "umap-learn>=0.5",
+]
[tool.ruff]
target-version = "py313"
@@ -82,6 +85,7 @@ module = [
"PIL.*",
"openai.*",
"aiohttp.*",
+ "umap.*",
]
ignore_missing_imports = true
diff --git a/tests/test_scatter_plot.py b/tests/test_scatter_plot.py
index e3d322f..110ed83 100644
--- a/tests/test_scatter_plot.py
+++ b/tests/test_scatter_plot.py
@@ -230,16 +230,13 @@ def test_prepare_data(self, monkeypatch: pytest.MonkeyPatch) -> None:
"embedding_cluster.scatter_plot.load_chromadb_collection",
return_value=fake_collection,
),
- patch("embedding_cluster.scatter_plot.TSNE") as mock_tsne_cls,
+ patch(
+ "embedding_cluster.scatter_plot.reduce_dimensions",
+ return_value=np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]]),
+ ),
patch("embedding_cluster.scatter_plot.KMeans") as mock_kmeans_cls,
patch("embedding_cluster.scatter_plot.StandardScaler") as mock_scaler_cls,
):
- mock_tsne = MagicMock()
- mock_tsne.fit_transform.return_value = np.array(
- [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]]
- )
- mock_tsne_cls.return_value = mock_tsne
-
mock_kmeans = MagicMock()
mock_kmeans.fit_predict.return_value = [0, 0, 1, 1]
mock_kmeans_cls.return_value = mock_kmeans
@@ -251,7 +248,6 @@ def test_prepare_data(self, monkeypatch: pytest.MonkeyPatch) -> None:
mock_scaler_cls.return_value = mock_scaler
fig = prepare_data(settings)
-
assert isinstance(fig, go.Figure)
assert len(sp.cluster_images) == 2
assert len(sp.cluster_item_names) == 2
@@ -420,3 +416,100 @@ def test_large_dataset_uses_sample(self) -> None:
assert result["k_values"] == list(range(2, 6))
assert 2 <= result["suggested_k"] <= 5
+
+
+class TestReduceDimensions:
+ def test_tsne_output_shape(self) -> None:
+ from embedding_cluster.scatter_plot import reduce_dimensions
+
+ rng = np.random.default_rng(42)
+ embeddings = rng.random((50, 10))
+
+ result = reduce_dimensions(embeddings, algorithm="tsne", n_components=3)
+
+ assert result.shape == (50, 3)
+
+ def test_pca_output_shape(self) -> None:
+ from embedding_cluster.scatter_plot import reduce_dimensions
+
+ rng = np.random.default_rng(42)
+ embeddings = rng.random((30, 10))
+
+ result = reduce_dimensions(embeddings, algorithm="pca", n_components=3)
+
+ assert result.shape == (30, 3)
+
+ def test_pca_deterministic(self) -> None:
+ from embedding_cluster.scatter_plot import reduce_dimensions
+
+ rng = np.random.default_rng(42)
+ embeddings = rng.random((30, 10))
+
+ result1 = reduce_dimensions(embeddings, algorithm="pca", n_components=3)
+ result2 = reduce_dimensions(embeddings, algorithm="pca", n_components=3)
+
+ np.testing.assert_array_equal(result1, result2)
+
+ def test_tsne_custom_perplexity(self) -> None:
+ from embedding_cluster.scatter_plot import reduce_dimensions
+
+ rng = np.random.default_rng(42)
+ embeddings = rng.random((30, 10))
+
+ result = reduce_dimensions(
+ embeddings,
+ algorithm="tsne",
+ n_components=3,
+ perplexity=10.0,
+ )
+
+ assert result.shape == (30, 3)
+
+ def test_umap_output_shape(self) -> None:
+ from embedding_cluster.scatter_plot import reduce_dimensions
+
+ rng = np.random.default_rng(42)
+ embeddings = rng.random((30, 10))
+
+ try:
+ result = reduce_dimensions(
+ embeddings,
+ algorithm="umap",
+ n_components=3,
+ n_neighbors=5,
+ min_dist=0.1,
+ )
+ assert result.shape == (30, 3)
+ except ImportError:
+ pytest.skip("umap-learn not installed")
+
+ def test_umap_import_guard(self) -> None:
+ from embedding_cluster.scatter_plot import reduce_dimensions
+
+ rng = np.random.default_rng(42)
+ embeddings = rng.random((30, 10))
+
+ with (
+ patch.dict("sys.modules", {"umap": None}),
+ pytest.raises(ImportError, match="umap-learn is not installed"),
+ ):
+ reduce_dimensions(embeddings, algorithm="umap", n_components=3)
+
+ def test_invalid_algorithm_raises(self) -> None:
+ from embedding_cluster.scatter_plot import reduce_dimensions
+
+ rng = np.random.default_rng(42)
+ embeddings = rng.random((30, 10))
+
+ with pytest.raises(ValueError, match="Unknown reduction algorithm"):
+ reduce_dimensions(embeddings, algorithm="invalid", n_components=3)
+
+ def test_pca_two_components(self) -> None:
+ from embedding_cluster.scatter_plot import reduce_dimensions
+
+ rng = np.random.default_rng(42)
+ embeddings = rng.random((30, 10))
+
+ result = reduce_dimensions(embeddings, algorithm="pca", n_components=2)
+
+ assert result.shape == (30, 2)
diff --git a/tests/test_settings.py b/tests/test_settings.py
index a65063a..8d7dfed 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -47,6 +47,21 @@ def test_default_model_names(self) -> None:
assert s.image_model_name == "openai/clip-vit-base-patch32"
assert s.text_model_name == "BAAI/bge-small-en-v1.5"
+ def test_default_reduction_algorithm(self) -> None:
+ s = Settings()
+ assert s.reduction_algorithm == "tsne"
+
+ def test_default_tsne_params(self) -> None:
+ s = Settings()
+ assert s.tsne_perplexity == pytest.approx(30.0)
+ assert s.tsne_learning_rate == "auto"
+
+ def test_default_umap_params(self) -> None:
+ s = Settings()
+ assert s.umap_n_neighbors == 15
+ assert s.umap_min_dist == pytest.approx(0.1)
+ assert s.umap_metric == "cosine"
+
class TestSettingsEnvVars:
def test_running_mode_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -87,3 +102,22 @@ def test_local_csv_filename_from_env(self, monkeypatch: pytest.MonkeyPatch) -> N
monkeypatch.setenv("LOCAL_CSV_FILENAME", "/tmp/data.csv")
s = Settings()
assert s.local_csv_filename == "/tmp/data.csv"
+
+ def test_reduction_algorithm_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("REDUCTION_ALGORITHM", "pca")
+ s = Settings()
+ assert s.reduction_algorithm == "pca"
+
+ def test_tsne_perplexity_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("TSNE_PERPLEXITY", "50.0")
+ s = Settings()
+ assert s.tsne_perplexity == pytest.approx(50.0)
+
+ def test_umap_params_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("UMAP_N_NEIGHBORS", "30")
+ monkeypatch.setenv("UMAP_MIN_DIST", "0.5")
+ monkeypatch.setenv("UMAP_METRIC", "euclidean")
+ s = Settings()
+ assert s.umap_n_neighbors == 30
+ assert s.umap_min_dist == pytest.approx(0.5)
+ assert s.umap_metric == "euclidean"
diff --git a/uv.lock b/uv.lock
index 94c2987..db32f7a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -391,6 +391,9 @@ dev = [
{ name = "pytest-cov", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
{ name = "ruff", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
]
+umap = [
+ { name = "umap-learn", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+]
[package.metadata]
requires-dist = [
@@ -416,10 +419,11 @@ requires-dist = [
{ name = "sentence-transformers", specifier = ">=3.4,<4" },
{ name = "torch", specifier = ">=2.6,<3" },
{ name = "transformers", specifier = ">=4.48,<5" },
+ { name = "umap-learn", marker = "extra == 'umap'", specifier = ">=0.5" },
{ name = "uvicorn", specifier = ">=0.34,<1" },
{ name = "websockets", specifier = ">=14,<15" },
]
-provides-extras = ["dev"]
+provides-extras = ["dev", "umap"]
[[package]]
name = "fastapi"
@@ -769,6 +773,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" },
]
+[[package]]
+name = "llvmlite"
+version = "0.46.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/74/cd/08ae687ba099c7e3d21fe2ea536500563ef1943c5105bf6ab4ee3829f68e/llvmlite-0.46.0.tar.gz", hash = "sha256:227c9fd6d09dce2783c18b754b7cd9d9b3b3515210c46acc2d3c5badd9870ceb", size = 193456, upload-time = "2025-12-08T18:15:36.295Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/ff/3eba7eb0aed4b6fca37125387cd417e8c458e750621fce56d2c541f67fa8/llvmlite-0.46.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:30b60892d034bc560e0ec6654737aaa74e5ca327bd8114d82136aa071d611172", size = 37232767, upload-time = "2025-12-08T18:15:13.22Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/54/737755c0a91558364b9200702c3c9c15d70ed63f9b98a2c32f1c2aa1f3ba/llvmlite-0.46.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6cc19b051753368a9c9f31dc041299059ee91aceec81bd57b0e385e5d5bf1a54", size = 56275176, upload-time = "2025-12-08T18:15:16.339Z" },
+ { url = "https://files.pythonhosted.org/packages/95/ae/af0ffb724814cc2ea64445acad05f71cff5f799bb7efb22e47ee99340dbc/llvmlite-0.46.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:d252edfb9f4ac1fcf20652258e3f102b26b03eef738dc8a6ffdab7d7d341d547", size = 37232768, upload-time = "2025-12-08T18:15:25.055Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/19/5018e5352019be753b7b07f7759cdabb69ca5779fea2494be8839270df4c/llvmlite-0.46.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379fdd1c59badeff8982cb47e4694a6143bec3bb49aa10a466e095410522064d", size = 56275173, upload-time = "2025-12-08T18:15:28.109Z" },
+]
+
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@@ -949,6 +965,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
+[[package]]
+name = "numba"
+version = "0.64.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "llvmlite", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "numpy", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/23/c9/a0fb41787d01d621046138da30f6c2100d80857bf34b3390dd68040f27a3/numba-0.64.0.tar.gz", hash = "sha256:95e7300af648baa3308127b1955b52ce6d11889d16e8cfe637b4f85d2fca52b1", size = 2765679, upload-time = "2026-02-18T18:41:20.974Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/80/2734de90f9300a6e2503b35ee50d9599926b90cbb7ac54f9e40074cd07f1/numba-0.64.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3bab2c872194dcd985f1153b70782ec0fbbe348fffef340264eacd3a76d59fd6", size = 2683392, upload-time = "2026-02-18T18:41:06.563Z" },
+ { url = "https://files.pythonhosted.org/packages/42/e8/14b5853ebefd5b37723ef365c5318a30ce0702d39057eaa8d7d76392859d/numba-0.64.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:703a246c60832cad231d2e73c1182f25bf3cc8b699759ec8fe58a2dbc689a70c", size = 3812245, upload-time = "2026-02-18T18:41:07.963Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/8a/77d26afe0988c592dd97cb8d4e80bfb3dfc7dbdacfca7d74a7c5c81dd8c2/numba-0.64.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f565d55eaeff382cbc86c63c8c610347453af3d1e7afb2b6569aac1c9b5c93ce", size = 2683590, upload-time = "2026-02-18T18:41:12.897Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/4b/600b8b7cdbc7f9cebee9ea3d13bb70052a79baf28944024ffcb59f0712e3/numba-0.64.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9b55169b18892c783f85e9ad9e6f5297a6d12967e4414e6b71361086025ff0bb", size = 3781163, upload-time = "2026-02-18T18:41:15.377Z" },
+]
+
[[package]]
name = "numpy"
version = "2.4.2"
@@ -1538,6 +1570,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
+[[package]]
+name = "pynndescent"
+version = "0.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "joblib", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "llvmlite", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "numba", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "scikit-learn", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "scipy", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4a/fb/7f58c397fb31666756457ee2ac4c0289ef2daad57f4ae4be8dec12f80b03/pynndescent-0.6.0.tar.gz", hash = "sha256:7ffde0fb5b400741e055a9f7d377e3702e02250616834231f6c209e39aac24f5", size = 2992987, upload-time = "2026-01-08T21:29:58.943Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl", hash = "sha256:dc8c74844e4c7f5cbd1e0cd6909da86fdc789e6ff4997336e344779c3d5538ef", size = 73511, upload-time = "2026-01-08T21:29:57.306Z" },
+]
+
[[package]]
name = "pypika"
version = "0.51.1"
@@ -2040,6 +2088,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
+[[package]]
+name = "umap-learn"
+version = "0.5.11"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numba", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "numpy", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "pynndescent", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "scikit-learn", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "scipy", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+ { name = "tqdm", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/94/9a/a1e4a257a9aa979dac4f6d5781dac929cbb0949959e2003ed82657d10b0f/umap_learn-0.5.11.tar.gz", hash = "sha256:31566ffd495fbf05d7ab3efcba703861c0f5e6fc6998a838d0e2becdd00e54f5", size = 96409, upload-time = "2026-01-12T20:44:47.553Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/d2/fcf7192dd1cd8c090b6cfd53fa223c4fb2887a17c47e06bc356d44f40dfb/umap_learn-0.5.11-py3-none-any.whl", hash = "sha256:cb17adbde9d544ba79481b3ab4d81ac222e940f3d9219307bea6044f869af3cc", size = 90890, upload-time = "2026-01-12T20:44:46.511Z" },
+]
+
[[package]]
name = "urllib3"
version = "2.6.3"