From 23171ddadc68e18c7a80866bab67479b3d4a1671 Mon Sep 17 00:00:00 2001 From: julianwuth Date: Wed, 20 May 2026 16:30:10 +0200 Subject: [PATCH 1/6] Adding workflow for unit testing --- .github/workflows/python-tests.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/python-tests.yml diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..a223aa3 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,24 @@ +name: Python tests + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install package and test tools + run: | + python -m pip install --upgrade pip + python -m pip install -e . pytest + + - name: Run unit tests + run: pytest \ No newline at end of file From 46ded1f1550017b4e34e2391f47bc49c12f9749a Mon Sep 17 00:00:00 2001 From: julianwuth Date: Wed, 20 May 2026 16:34:15 +0200 Subject: [PATCH 2/6] Scaffold for vignette --- docs/vignette.ipynb | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/vignette.ipynb diff --git a/docs/vignette.ipynb b/docs/vignette.ipynb new file mode 100644 index 0000000..e69de29 From 90d9759074d6f9e4732c73a35750888409a734a1 Mon Sep 17 00:00:00 2001 From: julianwuth Date: Sun, 24 May 2026 20:35:38 +0200 Subject: [PATCH 3/6] Clustering tracks Implementing algorithms to cluster tracks based on their features: - Preprocessing of feature values - Clustering techniques (Gaussian Mixed Models, k-Means and HDBSCAN) - Visualisation friendly outputs - Associated unit tests --- .gitignore | 6 + docs/example_synthetic.csv | 36 + docs/vignette.ipynb | 1675 +++++++++++++++++++++++ pyproject.toml | 10 +- src/playlistsmith/__init__.py | 3 +- src/playlistsmith/cluster/__init__.py | 52 + src/playlistsmith/cluster/algorithms.py | 526 +++++++ src/playlistsmith/cluster/interpret.py | 149 ++ src/playlistsmith/cluster/preprocess.py | 181 +++ src/playlistsmith/cluster/public.py | 471 +++++++ tests/test_cluster_gmm.py | 273 ++++ tests/test_cluster_hdbscan.py | 212 +++ tests/test_cluster_interpret.py | 276 ++++ tests/test_cluster_kmeans.py | 226 +++ tests/test_cluster_preprocess.py | 278 ++++ tests/test_cluster_public.py | 313 +++++ tests/test_cluster_viz_hooks.py | 162 +++ 17 files changed, 4847 insertions(+), 2 deletions(-) create mode 100644 docs/example_synthetic.csv create mode 100644 src/playlistsmith/cluster/__init__.py create mode 100644 src/playlistsmith/cluster/algorithms.py create mode 100644 src/playlistsmith/cluster/interpret.py create mode 100644 src/playlistsmith/cluster/preprocess.py create mode 100644 src/playlistsmith/cluster/public.py create mode 100644 tests/test_cluster_gmm.py create mode 100644 tests/test_cluster_hdbscan.py create mode 100644 tests/test_cluster_interpret.py create mode 100644 tests/test_cluster_kmeans.py create mode 100644 tests/test_cluster_preprocess.py create mode 100644 tests/test_cluster_public.py create mode 100644 tests/test_cluster_viz_hooks.py diff --git a/.gitignore b/.gitignore index 92a24b6..a4f1650 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,10 @@ # only CSV that belongs in the repo, so it is explicitly re-included. *.csv !tests/example_tracklist.csv +!docs/example_synthetic.csv +# venv +.venv/ + +# implementation plans +plans/ \ No newline at end of file diff --git a/docs/example_synthetic.csv b/docs/example_synthetic.csv new file mode 100644 index 0000000..92d6a73 --- /dev/null +++ b/docs/example_synthetic.csv @@ -0,0 +1,36 @@ +Track URI;Track Name;Artist Name(s) +spotify:track:synA000000000000000001;Drift;The Placeholders +spotify:track:synA000000000000000002;Quiet Tide;Sample Collective +spotify:track:synA000000000000000003;Window Light;Lorem Beats +spotify:track:synA000000000000000004;Slow Rain;Stub Choir +spotify:track:synA000000000000000005;Almost Morning;Fixture Six +spotify:track:synA000000000000000006;Vellum;Yield Returns +spotify:track:synA000000000000000007;Hush Field;Mock Anthem +spotify:track:synA000000000000000008;Paper Lantern;Synthetic Sunrise +spotify:track:synA000000000000000009;First Frost;Test Signal, Dummy Vox +spotify:track:synA000000000000000010;Linen;The Placeholders +spotify:track:synA000000000000000011;Soft Pulse;Lorem Beats +spotify:track:synA000000000000000012;Late Sun;Fixture Six +spotify:track:synB000000000000000001;Neon Vector;Glitch Quartet +spotify:track:synB000000000000000002;Pulse Sprint;Carousel Dynamics +spotify:track:synB000000000000000003;City Loop;The Placeholders +spotify:track:synB000000000000000004;Glitter Hooks;Mock Anthem +spotify:track:synB000000000000000005;Floor Plan;Sample Collective +spotify:track:synB000000000000000006;Heart Drum;Stub Choir, Dummy Vox +spotify:track:synB000000000000000007;Bright Static;Yield Returns +spotify:track:synB000000000000000008;Carousel;Fixture Six +spotify:track:synB000000000000000009;Skyline Bounce;Lorem Beats +spotify:track:synB000000000000000010;Confetti;Synthetic Sunrise +spotify:track:synB000000000000000011;Open Top;Glitch Quartet +spotify:track:synB000000000000000012;Hold Steady;Test Signal +spotify:track:synC000000000000000001;Heavy Engine;Iron Lattice +spotify:track:synC000000000000000002;Black Glass;Tower Run +spotify:track:synC000000000000000003;Drone Stack;Iron Lattice +spotify:track:synC000000000000000004;Magnet;Volt Index +spotify:track:synC000000000000000005;Crushed Velvet;Tower Run +spotify:track:synC000000000000000006;Iron Sky;Iron Lattice +spotify:track:synC000000000000000007;Volt;Volt Index +spotify:track:synC000000000000000008;Sub Tunnel;Tower Run, Volt Index +spotify:track:synX000000000000000001;Test Phantom;Unknown Provider +spotify:track:synX000000000000000002;Missing Index;Off-Catalogue Trio +spotify:track:synX000000000000000003;Null Wave;Unknown Provider diff --git a/docs/vignette.ipynb b/docs/vignette.ipynb index e69de29..e5f37d8 100644 --- a/docs/vignette.ipynb +++ b/docs/vignette.ipynb @@ -0,0 +1,1675 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# `playlistsmith` — Vignette\n", + "\n", + "**Content-based playlist clustering from an Exportify CSV.**\n", + "\n", + "Spotify's `/audio-features` and `/recommendations` endpoints were deprecated for new\n", + "applications in November 2024. `playlistsmith` fills that gap by sourcing audio features\n", + "from [ReccoBeats](https://reccobeats.com), then clustering the resulting feature matrix\n", + "so a user can re-organise their library into content-similar playlists.\n", + "\n", + "This notebook walks the package top-to-bottom: *what problem, who for, how\n", + "it looks, how it flows, how it's built, then run it and see results.*\n", + "\n", + "Sections marked **(future work)** describe planned components that are not yet wired up;\n", + "everything else is exercised by the live cells below." + ] + }, + { + "cell_type": "markdown", + "id": "495ce4ab", + "metadata": {}, + "source": [ + "## 1. Scenario — who uses this and how\n", + "\n", + "**Purpose.** Describe how a typical user goes from an exported Spotify CSV to a folder\n", + "of clustered playlists.\n", + "\n", + "**Individual user.** A hobbyist with a 500–2000-track Spotify library, comfortable with\n", + "Python and a terminal but not a developer. They want playlists that are more\n", + "content-coherent than Spotify's auto-mixes.\n", + "\n", + "**Equipment / assumptions.**\n", + "\n", + "- Python 3.12 (pinned in [pyproject.toml](../pyproject.toml)).\n", + "- Dependencies: numpy, pandas, scikit-learn, scipy, umap-learn, hdbscan, httpx (via\n", + " [`_http.py`](../src/playlistsmith/_http.py)).\n", + "- Internet access for the ReccoBeats API (free, no key).\n", + "- A CSV produced by https://exportify.app. For this notebook we bundle\n", + " [`docs/example_synthetic.csv`](./example_synthetic.csv) — a synthetic Exportify-\n", + " shaped file with ~35 fake tracks — and mock the ReccoBeats API in the setup cell,\n", + " so the vignette runs fully offline and ships no Spotify content.\n", + "\n", + "**Scenario steps.**\n", + "\n", + "1. Export your library from Spotify via Exportify, download the CSV.\n", + "2. Install the package from source: `pip install -e .`.\n", + "3. Run the pipeline (this notebook, or `python testing_pipeline.py`) pointed at the CSV.\n", + "4. Read the coverage report: how many tracks resolved, how many were dropped because\n", + " ReccoBeats didn't know them.\n", + "5. Read per-cluster summaries (e.g. *\"high energy, fast tempo, low valence\"*) and a 2-D\n", + " UMAP scatter coloured by cluster.\n", + "6. Inspect pipeline warnings (e.g. *\"cluster 0 holds 60% of the library — try a larger\n", + " k\"*).\n", + "7. Re-run with a different `method` (`kmeans`, `hdbscan`) or a different `k_range` if\n", + " the result is unsatisfying.\n", + "8. **(future work)** Export one CSV per cluster to a folder of playlists\n", + " (`io.playlist_export.write_cluster_csvs`).\n", + "\n", + "**Open questions (deliberately unresolved).**\n", + "\n", + "- *What happens when the CSV has no Spotify IDs at all?* — Today: the loader raises\n", + " `ValueError(\"No tracks with a Spotify track ID found ...\")`.\n", + "- *Is `min_playlist_size=5` defensible for libraries of ~100 tracks?* — Probably not;\n", + " see §5 step 7 below where we deliberately trip the resulting warning." + ] + }, + { + "cell_type": "markdown", + "id": "d74455ff", + "metadata": {}, + "source": [ + "## 2. Screenshots / UI sketches — what the user sees\n", + "\n", + "Per the brief, rough sketches are acceptable at this stage. The real UI today is the\n", + "notebook / CLI itself; the Streamlit GUI is planned in [`plans/gui.md`](../plans/gui.md)\n", + "but not yet built.\n", + "\n", + "### 2a. Planned Streamlit GUI (mock-up, not implemented)\n", + "\n", + "```\n", + " +-----------------------------------------------------------+\n", + " | playlistsmith |\n", + " +-------------------+---------------------------------------+\n", + " | [sidebar] | [main panel] |\n", + " | | |\n", + " | 1. Upload CSV | step 1. drag-and-drop CSV here |\n", + " | 2. Extract feats. | step 2. Extract [run] |\n", + " | 3. Cluster | coverage: 412/500 tracks |\n", + " | 4. Visualise | step 3. method [gmm v] k 2..12 |\n", + " | 5. Export | [cluster] |\n", + " | | step 4. <3-D Plotly scatter> |\n", + " | | step 5. rename clusters, [export] |\n", + " +-------------------+---------------------------------------+\n", + "```\n", + "\n", + "### 2b. Actual current UI: notebook / CLI\n", + "\n", + "The code cells below are the *real* current UI. The captioned outputs in §5 (coverage\n", + "report, BIC curve, per-cluster summaries, z-profile heatmap, UMAP scatter) are what a\n", + "user actually sees when they run the pipeline today." + ] + }, + { + "cell_type": "markdown", + "id": "acf40e8c", + "metadata": {}, + "source": [ + "## 3. Flowchart — user actions vs software actions\n", + "\n", + "```mermaid\n", + "flowchart TB\n", + " subgraph User\n", + " U1[Export library via Exportify]\n", + " U2[Drop CSV into notebook / CLI]\n", + " U3[Choose method + k_range, run cluster]\n", + " U4[Inspect summaries, warnings, scatter]\n", + " U5[Rename clusters, export -- future]\n", + " end\n", + " subgraph Software\n", + " S1[TrackLibrary._load]\n", + " S2[features.extract -- ReccoBeats lookup]\n", + " S3{tracks resolved == 0?}\n", + " S4[cluster.preprocess.prepare_matrix]\n", + " S5[fit_gmm / fit_kmeans / fit_hdbscan]\n", + " S6[_canonical_order]\n", + " S7[_collapse_small_clusters]\n", + " S8{any cluster > max_playlist_share?}\n", + " S9[describe_clusters + _build_diagnostics]\n", + " S10[io.playlist_export -- future]\n", + " end\n", + " U1 --> U2 --> S1 --> S2 --> S3\n", + " S3 -- yes --> X[Abort with coverage message]\n", + " S3 -- no --> U3 --> S4 --> S5 --> S6 --> S7 --> S8\n", + " S8 -- yes --> W[Emit dominant-cluster warning]\n", + " S8 -- no --> S9 --> U4 --> U5 --> S10\n", + "```\n", + "\n", + "Decision points the flowchart highlights:\n", + "\n", + "- *Tracks resolved == 0?* → abort with the coverage message.\n", + "- *Cluster larger than `max_playlist_share`?* → emit a warning (no auto-split).\n", + "- *Cluster smaller than `min_playlist_size`?* → collapse into Unclassified (cluster `-1`)." + ] + }, + { + "cell_type": "markdown", + "id": "4544eef8", + "metadata": {}, + "source": [ + "## 4. Design — architecture of the project\n", + "\n", + "### 4a. Public interfaces — input / output\n", + "\n", + "| Entry point | Input | Output |\n", + "|---|---|---|\n", + "| `TrackLibrary(csv_path)` | path to an Exportify CSV | `tlib.dataframe` with `[title, artist, spotify_id]` |\n", + "| `tlib.extract_features(mode=\"precomputed\")` | the library | `(features_df, CoverageReport)` |\n", + "| `ps.cluster(features_df, method, k_range, ...)` | the features frame | `ClusterPipelineResult` (tracks, descriptions, clustering, transform_log, diagnostics, warnings) |\n", + "\n", + "### 4b. Internal vs external representations\n", + "\n", + "- **In memory:** dataclasses (`ClusteringResult`, `ClusterPipelineResult`,\n", + " `ClusterDiagnostics`, `TransformLog`, `CoverageReport`) and pandas DataFrames.\n", + "- **On disk:** CSV in, CSV out. No database; cache TTL bounded per\n", + " [`CLAUDE.md`](../CLAUDE.md).\n", + "\n", + "### 4c. Procedure chain (the order in which things run)\n", + "\n", + "```\n", + "csv_loader.TrackLibrary._load\n", + " -> features.reccobeats._resolve_spotify_ids\n", + " -> features.reccobeats._fetch_audio_features\n", + " -> cluster.preprocess.prepare_matrix\n", + " -> cluster.algorithms.fit_{gmm,kmeans,hdbscan}\n", + " -> cluster.public._canonical_order\n", + " -> cluster.public._collapse_small_clusters\n", + " -> cluster.interpret.describe_clusters\n", + " -> cluster.public._build_diagnostics\n", + " -> (future) io.playlist_export.write_cluster_csvs\n", + "```\n", + "\n", + "### 4d. Non-negotiables (from `CLAUDE.md`)\n", + "\n", + "- No Spotify-derived features in the ML pipeline. ReccoBeats only.\n", + "- Single `features.extract(mode=...)` entry point; no parallel pipelines.\n", + "- Shared HTTP client in [`_http.py`](../src/playlistsmith/_http.py); never\n", + " `httpx.get(...)` directly.\n", + "- GMM preferred over KMeans; BIC for selection; silhouette + cohesion reported alongside." + ] + }, + { + "cell_type": "markdown", + "id": "41433f52", + "metadata": {}, + "source": [ + "## 5. Example application — run it and look at the output\n", + "\n", + "Everything above is framing. This section is the meat of the vignette: end-to-end\n", + "the pipeline on a synthetic Exportify CSV, with ReccoBeats mocked so the notebook\n", + "runs offline and ships no third-party content. We show the figures, and deliberately\n", + "trip a warning to validate the error-message contract." + ] + }, + { + "cell_type": "markdown", + "id": "73bb569e", + "metadata": {}, + "source": [ + "### Setup\n", + "\n", + "To keep the vignette fully offline and free of any third-party content, we ship a\n", + "**synthetic Exportify-shaped CSV** ([`docs/example_synthetic.csv`](./example_synthetic.csv))\n", + "with ~35 fake tracks whose IDs encode three latent clusters, and we **mock the\n", + "ReccoBeats API** by installing an `httpx.MockTransport` on the shared HTTP client.\n", + "The mock returns deterministic synthetic features per ID drawn from three cluster\n", + "centres plus Gaussian noise, with a handful of IDs deliberately unknown so the\n", + "`CoverageReport.dropped_tracks` failure mode has something to show.\n", + "\n", + "Why this shape:\n", + "\n", + "- Exercises the *real* `playlistsmith.features.reccobeats` code path end-to-end\n", + " (batching, the `href` → Spotify-ID recovery, `CoverageReport`).\n", + "- Zero Spotify content anywhere — file, repo, or runtime.\n", + "- Deterministic and sub-second. All clustering cells pass `random_state=0`.\n", + "- Same pattern as the existing `pytest-httpx` tests, wrapped in notebook cells.\n", + "\n", + "The bootstrap cell below installs the package + plotting libraries; the mock cell\n", + "after it wires up the offline ReccoBeats stub before any feature call happens." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "47a187a8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Python: 3.12.7\n", + "Repo root: /Users/julian/Documents/playlistsmith\n" + ] + } + ], + "source": [ + "# Bootstrap: install playlistsmith (from the repo root, one level above this notebook)\n", + "# plus the plotting libraries this notebook uses but the package itself does not depend on.\n", + "import importlib\n", + "import subprocess\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "REPO_ROOT = Path().resolve().parent\n", + "\n", + "def _pip_install(*args: str) -> None:\n", + " subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"--quiet\", *args])\n", + "\n", + "if importlib.util.find_spec(\"playlistsmith\") is None:\n", + " _pip_install(\"-e\", str(REPO_ROOT))\n", + "\n", + "missing = [pkg for pkg in (\"matplotlib\", \"seaborn\") if importlib.util.find_spec(pkg) is None]\n", + "if missing:\n", + " _pip_install(*missing)\n", + "\n", + "print(\"Python: \", sys.version.split()[0])\n", + "print(\"Repo root:\", REPO_ROOT)" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "35c82507", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Mock ReccoBeats transport installed. Known cluster letters: ['A', 'B', 'C']\n" + ] + } + ], + "source": [ + "# Mock ReccoBeats: intercept both endpoints used by playlistsmith.features.reccobeats\n", + "# and return deterministic synthetic features. The Spotify IDs in example_synthetic.csv\n", + "# encode their target cluster in the 4th character ('A' / 'B' / 'C'); 'X' is reserved\n", + "# for IDs the mock pretends not to know, so dropped_tracks has something to show.\n", + "import hashlib\n", + "import json\n", + "\n", + "import httpx\n", + "import numpy as np\n", + "\n", + "from playlistsmith import _http\n", + "from playlistsmith.features.reccobeats import FEATURE_COLUMNS\n", + "\n", + "# Cluster centres in raw ReccoBeats feature space. Loudness is in dB; tempo in BPM;\n", + "# every other feature lives on [0, 1]. Chosen so the three clusters are clearly\n", + "# separable but not trivially so.\n", + "_CLUSTER_CENTRES = {\n", + " \"A\": { # chill / acoustic\n", + " \"acousticness\": 0.80, \"danceability\": 0.40, \"energy\": 0.20,\n", + " \"instrumentalness\": 0.10, \"liveness\": 0.10, \"loudness\": -18.0,\n", + " \"speechiness\": 0.05, \"tempo\": 85.0, \"valence\": 0.55,\n", + " },\n", + " \"B\": { # upbeat / dance\n", + " \"acousticness\": 0.15, \"danceability\": 0.80, \"energy\": 0.75,\n", + " \"instrumentalness\": 0.05, \"liveness\": 0.15, \"loudness\": -6.0,\n", + " \"speechiness\": 0.08, \"tempo\": 120.0, \"valence\": 0.75,\n", + " },\n", + " \"C\": { # intense / electronic\n", + " \"acousticness\": 0.05, \"danceability\": 0.55, \"energy\": 0.92,\n", + " \"instrumentalness\": 0.50, \"liveness\": 0.20, \"loudness\": -4.0,\n", + " \"speechiness\": 0.06, \"tempo\": 150.0, \"valence\": 0.30,\n", + " },\n", + "}\n", + "# Per-feature jitter scales (1 sigma). loudness/tempo on their natural scales,\n", + "# everything else small enough to keep features in [0, 1] after a clip.\n", + "_JITTER = {\n", + " \"acousticness\": 0.05, \"danceability\": 0.05, \"energy\": 0.05,\n", + " \"instrumentalness\": 0.05, \"liveness\": 0.04, \"loudness\": 1.5,\n", + " \"speechiness\": 0.02, \"tempo\": 4.0, \"valence\": 0.05,\n", + "}\n", + "\n", + "def _features_for(spotify_id: str) -> dict[str, float] | None:\n", + " \"\"\"Synthetic ReccoBeats features for one ID; None for unknown ('X') IDs.\"\"\"\n", + " # ID layout: 'syn<18 digits>'. Anything else: pretend unknown.\n", + " if len(spotify_id) < 4 or not spotify_id.startswith(\"syn\"):\n", + " return None\n", + " cluster_letter = spotify_id[3]\n", + " if cluster_letter not in _CLUSTER_CENTRES:\n", + " return None\n", + " centre = _CLUSTER_CENTRES[cluster_letter]\n", + " # Per-ID deterministic seed -> reproducible noise.\n", + " seed = int(hashlib.sha1(spotify_id.encode()).hexdigest()[:8], 16)\n", + " rng = np.random.default_rng(seed)\n", + " out: dict[str, float] = {}\n", + " for col in FEATURE_COLUMNS:\n", + " val = float(centre[col] + rng.normal(0.0, _JITTER[col]))\n", + " if col in {\"acousticness\", \"danceability\", \"energy\", \"instrumentalness\",\n", + " \"liveness\", \"speechiness\", \"valence\"}:\n", + " val = float(np.clip(val, 0.0, 1.0))\n", + " out[col] = val\n", + " return out\n", + "\n", + "def _handle(request: httpx.Request) -> httpx.Response:\n", + " \"\"\"MockTransport handler for ReccoBeats /v1/track and /v1/audio-features.\"\"\"\n", + " ids = [s for s in request.url.params.get(\"ids\", \"\").split(\",\") if s]\n", + " if request.url.path == \"/v1/track\":\n", + " # The real API maps Spotify IDs to ReccoBeats internal IDs; the client\n", + " # recovers the Spotify ID from the returned href. We mirror that\n", + " # contract: 'rb-' is our fake ReccoBeats ID, the href\n", + " # carries the original Spotify ID back.\n", + " content = [\n", + " {\"id\": f\"rb-{sid}\", \"href\": f\"https://open.spotify.com/track/{sid}\"}\n", + " for sid in ids\n", + " if _features_for(sid) is not None\n", + " ]\n", + " return httpx.Response(200, json={\"content\": content})\n", + " if request.url.path == \"/v1/audio-features\":\n", + " content = []\n", + " for rb_id in ids:\n", + " # Recover Spotify ID from our 'rb-' convention.\n", + " if not rb_id.startswith(\"rb-\"):\n", + " continue\n", + " sid = rb_id[3:]\n", + " feats = _features_for(sid)\n", + " if feats is None:\n", + " continue\n", + " content.append({\"id\": rb_id, **feats})\n", + " return httpx.Response(200, json={\"content\": content})\n", + " return httpx.Response(404, json={\"content\": []})\n", + "\n", + "# Install the mock on the shared client. Reset the singleton so any prior\n", + "# real-network client (e.g. from a previous notebook run in the same kernel)\n", + "# is dropped.\n", + "_http._client = httpx.Client(\n", + " transport=httpx.MockTransport(_handle),\n", + " headers={\"User-Agent\": \"playlistsmith-vignette/mock\"},\n", + ")\n", + "print(\"Mock ReccoBeats transport installed. Known cluster letters:\",\n", + " list(_CLUSTER_CENTRES))" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "6f37f5af", + "metadata": {}, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "import seaborn as sns\n", + "\n", + "import playlistsmith as ps\n", + "\n", + "# Path is relative to this notebook. The synthetic CSV sits next to the notebook\n", + "# in docs/, so a single ``Path().resolve()`` works regardless of where Jupyter\n", + "# was launched from.\n", + "CSV_PATH = Path().resolve() / \"example_synthetic.csv\"" + ] + }, + { + "cell_type": "markdown", + "id": "bf2eeed5", + "metadata": {}, + "source": [ + "### Step 1 — load the library\n", + "\n", + "`TrackLibrary` parses an Exportify (`;`-delimited) CSV down to the three identity\n", + "columns the rest of the pipeline cares about: `title`, `artist`, `spotify_id`. Rows\n", + "with no Spotify track ID are dropped at load time." + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "id": "96a21434", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "TrackLibrary(source_path='/Users/julian/Documents/playlistsmith/docs/example_synthetic.csv', tracks=35)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
titleartistspotify_id
0DriftThe PlaceholderssynA000000000000000001
1Quiet TideSample CollectivesynA000000000000000002
2Window LightLorem BeatssynA000000000000000003
3Slow RainStub ChoirsynA000000000000000004
4Almost MorningFixture SixsynA000000000000000005
\n", + "
" + ], + "text/plain": [ + " title artist spotify_id\n", + "0 Drift The Placeholders synA000000000000000001\n", + "1 Quiet Tide Sample Collective synA000000000000000002\n", + "2 Window Light Lorem Beats synA000000000000000003\n", + "3 Slow Rain Stub Choir synA000000000000000004\n", + "4 Almost Morning Fixture Six synA000000000000000005" + ] + }, + "execution_count": 33, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "tlib = ps.TrackLibrary(CSV_PATH)\n", + "print(tlib)\n", + "tlib.dataframe.head()" + ] + }, + { + "cell_type": "markdown", + "id": "2d5c47f6", + "metadata": {}, + "source": [ + "### Step 2 — extract features (ReccoBeats lookup)\n", + "\n", + "`extract_features(mode=\"precomputed\")` calls the single public\n", + "`playlistsmith.features.extract` entry point, which in turn batches Spotify IDs through\n", + "ReccoBeats' `/v1/track` + `/v1/audio-features` endpoints via the shared HTTP client.\n", + "Anything ReccoBeats does not know is dropped and reported on the\n", + "[`CoverageReport`](../src/playlistsmith/features/__init__.py) — the failure mode is\n", + "always visible." + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "85103b51", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[playlistsmith] Dropped 3 track(s) with no ReccoBeats precomputed features:\n", + " - Test Phantom — Unknown Provider\n", + " - Missing Index — Off-Catalogue Trio\n", + " - Null Wave — Unknown Provider\n", + "Feature coverage: 32/35 track(s) resolved via ReccoBeats; 3 dropped.\n", + "Feature frame: 32 resolved track(s), columns = ['spotify_id', 'title', 'artist', 'acousticness', 'danceability', 'energy', 'instrumentalness', 'liveness', 'loudness', 'speechiness', 'tempo', 'valence']\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
spotify_idtitleartistacousticnessdanceabilityenergyinstrumentalnesslivenessloudnessspeechinesstempovalence
0synA000000000000000001DriftThe Placeholders0.8461940.3787670.1769920.0440030.065825-18.2465030.02289082.3375490.533509
1synA000000000000000002Quiet TideSample Collective0.8339470.4205710.0803170.1242110.099663-16.8614400.03680281.7722300.536931
2synA000000000000000003Window LightLorem Beats0.7408800.3551910.2121170.1349790.104113-18.2929880.06970084.9438550.666672
3synA000000000000000004Slow RainStub Choir0.8244930.3121210.2685820.0003770.058100-18.8645130.05404977.8059100.628330
4synA000000000000000005Almost MorningFixture Six0.8676950.4171580.1708490.1638860.108118-17.7312400.04665985.0808170.460346
\n", + "
" + ], + "text/plain": [ + " spotify_id title artist acousticness \\\n", + "0 synA000000000000000001 Drift The Placeholders 0.846194 \n", + "1 synA000000000000000002 Quiet Tide Sample Collective 0.833947 \n", + "2 synA000000000000000003 Window Light Lorem Beats 0.740880 \n", + "3 synA000000000000000004 Slow Rain Stub Choir 0.824493 \n", + "4 synA000000000000000005 Almost Morning Fixture Six 0.867695 \n", + "\n", + " danceability energy instrumentalness liveness loudness speechiness \\\n", + "0 0.378767 0.176992 0.044003 0.065825 -18.246503 0.022890 \n", + "1 0.420571 0.080317 0.124211 0.099663 -16.861440 0.036802 \n", + "2 0.355191 0.212117 0.134979 0.104113 -18.292988 0.069700 \n", + "3 0.312121 0.268582 0.000377 0.058100 -18.864513 0.054049 \n", + "4 0.417158 0.170849 0.163886 0.108118 -17.731240 0.046659 \n", + "\n", + " tempo valence \n", + "0 82.337549 0.533509 \n", + "1 81.772230 0.536931 \n", + "2 84.943855 0.666672 \n", + "3 77.805910 0.628330 \n", + "4 85.080817 0.460346 " + ] + }, + "execution_count": 34, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "features, coverage = tlib.extract_features(mode=\"precomputed\")\n", + "print(coverage.render())\n", + "print(f\"Feature frame: {len(features)} resolved track(s), columns = {list(features.columns)}\")\n", + "features.head()" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "3d7e91ea", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
spotify_idtitleartist
0synX000000000000000001Test PhantomUnknown Provider
1synX000000000000000002Missing IndexOff-Catalogue Trio
2synX000000000000000003Null WaveUnknown Provider
\n", + "
" + ], + "text/plain": [ + " spotify_id title artist\n", + "0 synX000000000000000001 Test Phantom Unknown Provider\n", + "1 synX000000000000000002 Missing Index Off-Catalogue Trio\n", + "2 synX000000000000000003 Null Wave Unknown Provider" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Surface the dropped tracks so the user can see exactly which ones ReccoBeats missed.\n", + "coverage.dropped_tracks" + ] + }, + { + "cell_type": "markdown", + "id": "8bb704f5", + "metadata": {}, + "source": [ + "### Step 3 — cluster with the default (GMM)\n", + "\n", + "`ps.cluster(...)` wraps preprocessing (logit/log transforms, median imputation,\n", + "scaling), the GMM fit with BIC/ICL model selection, the canonical cluster ordering\n", + "(cluster 0 is the largest, ties broken by PC1) and the small-cluster collapse into\n", + "Unclassified (`-1`).\n", + "\n", + "`example_exportify.csv` is tiny (a handful of tracks) so we pass a small `k_range` and\n", + "loosen `min_playlist_size=1`; the step 7 cell below shows what happens when those\n", + "knobs are wrong for the library." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "c37ee770", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'method': 'GMM (diag covariance)',\n", + " 'k chosen': 4,\n", + " 'silhouette': 0.509,\n", + " 'cohesion': 1.748,\n", + " 'stability_ari': 1.0}" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result = ps.cluster(\n", + " features,\n", + " method=\"gmm\",\n", + " random_state=0,\n", + " k_range=range(2, min(5, max(3, len(features)))),\n", + " min_playlist_size=1,\n", + ")\n", + "\n", + "summary = {\n", + " \"method\": f\"GMM ({result.clustering.covariance_type} covariance)\",\n", + " \"k chosen\": result.clustering.k,\n", + " \"silhouette\": round(result.clustering.silhouette, 3),\n", + " \"cohesion\": round(result.clustering.cohesion, 3),\n", + " \"stability_ari\": round(result.clustering.stability_ari, 3),\n", + "}\n", + "summary" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "ff102cf2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "BIC curve: {2: 612.2, 3: 411.4, 4: 398.0}\n", + "ICL curve: {2: 612.2, 3: 411.4, 4: 398.0}\n" + ] + }, + { + "data": { + "image/png": "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P//8M99znnrqKa1Lly7ZjoWEhGhDhgxR+5mZmVqlSpW0zz77zPB4bGys5urqqv34448WL29O6enpmqenp7ZgwYJs/0G6deumFTdTyiv/ceXGz4+tX9+pU6eq62sccC11fYWPj482b948m793TSmvLd27ppTXlu5dU8prK/duQkKCVqdOHW39+vVa27ZtCwye1riH2Wxrgri4OPXV19c333O2b9+umgGMhYWFqePizJkziIqKynaO5FAMCQkxnGPJ8uYkC8nKOng5f2bLli2qqbJevXoYNmwYoqOji7Ss5pT31q1bCAwMVAn4u3XrhsOHDxses/Xr+80336BPnz7w8PCw6PXNyMjA4sWLcfv2bdVcZ+v3rinltaV719Ty2sq9W5jra617d/jw4aqpOOe9aSv3MBPDm7DiirS1t27dGo0bN873PPnD+Pv7Zzsm38tx/eP6Y/mdY8ny5jR27Fi1koDxzSV9GD179kSNGjVw6tQpvPnmm+jUqZO62RwdHS1aXvkP+u2336q+Gwlesujt/fffr96EJKm/LV9f6Y87dOiQehMyVpzX9+DBg+rNUdY4LFu2LJYvX46GDRva7L1rTnlt4d41p7y2cO8W9vpa494VEuD37t2LXbt2wRTWuIcZPE349CM3zz///IOSWl7pkJebVT5JGg9kkE+betIBL//5a9Wqpc5r3769Rcsr//GNPynLm0+DBg0we/ZsTJo0CbZ8feWNR67ffffdl+14cV5fecOOiIhQb9ZLly7FgAED8Oeff5ockCytsOW11r1rTnlt4d4t7PW1xr174cIFjBo1CuvXr881sMqWsNm2ACNGjMCqVauwefPmOy5ZVqlSJVy9ejXbMflejusf1x/L7xxLlldPPgXLG9C6devUf4CC1KxZExUqVMDJkyetVl49Z2dnBAUFGcpiq9dXmsfkzX3QoEF3PLcor6+Liwtq166NFi1aqNHC9957L7788kubvXfNKa8t3LuFKa81793ClNda9+6ePXtw7do1NG/eHE5OTmqTQP/VV1+pfWl6toV7mMEzDzKQSt4opWlj06ZNqmniTuST5caNG7Mdk09O+k+c8hzyRzI+Rxak3rlzp8l9D0VZXvHpp5+qT75r1qxBy5Yt73j+xYsXVb+GDPW2RnmNyX8gaYrSl8UWr69++HxKSgr69etnseubX3OzlMPW7t3ClNea925hy2ute/duymute7d9+/bq+khNWb/J37hv375qP69mYavcw4UaZlTCDRs2TI2O27JlS7ah2ImJiYZz+vfvr40bNy7bUGknJyft888/V8PnJ06cmOdQaW9vb23lypXagQMH1Gi1ohiOXpjySllcXFy0pUuXZvsZGeEm5OuYMWO07du3a2fOnNE2bNigNW/eXI1+S05Otnh533vvPW3t2rXaqVOntD179mh9+vTR3NzctMOHD9vk9dV74IEHtN69e+c6XpzXV8ohI4HleeU6yPc6nU5bt26dzd27hSmvNe/dwpTXmvduYcprzXs3PzlH29rCPczgmddFAfLcjOc5yR9ThmsbW7JkiVa3bl31H7tRo0ZqbpUxGS49YcIEzd/fXw2Rbt++vZp3ZY3yBgYG5vkzctMJCQyPPvqo5ufnp25COX/w4MG55qdZqryvvPKKmlsp11aun8y13Lt3r81eX3H06FF1nv5NylhxXt/nn39ePZ9cK3l+uQ7GZbCle7cw5bXmvVuY8lrz3i1Mea1575oaPG3hHuaSZERERGZinycREZGZGDyJiIjMxOBJRERkJgZPIiIiMzF4EhERmYnBk4iIyEwMnkRERGZi8CQiIjITgydRKffQQw+pZdaIyHQMnkRERGZi8CQiIjITgycRZfP777/Dy8sLCxcu5JUhyodTfg8QUemzaNEiDB06VH197LHHrF0cIpvFmicRKTNmzMBLL72E3377jYGT6A5Y8yQiLF26FNeuXcPWrVsRHBzMK0J0B6x5EhGCgoLg5+eHb7/9FlnrfxNRQRg8iQi1atXC5s2bsXLlSowcOZJXhOgO2GxLRErdunVVAJWkCU5OTpg2bRqvDFE+GDyJyKBevXrYtGmTCqCOjo6YMmUKrw5RHnQaOziIiIjMwj5PIiIiMzF4EhERmYnBk4iIyEwMnkRERGZi8CQiIjITgycREZGZGDyJiIjMxOBJRERkJgZPIiIiMzF4EhERmYnBk4iICOb5fy30c/Q+E7PWAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Model-selection curves: BIC (used for selection) + ICL (reported alongside).\n", + "bic = {k: round(v, 1) for k, v in result.clustering.bic_curve.items()}\n", + "icl = {k: round(v, 1) for k, v in result.clustering.icl_curve.items()}\n", + "print(\"BIC curve:\", bic)\n", + "print(\"ICL curve:\", icl)\n", + "\n", + "if len(bic) >= 2:\n", + " fig, ax = plt.subplots(figsize=(5, 3))\n", + " ax.plot(list(bic.keys()), list(bic.values()), marker=\"o\", label=\"BIC\")\n", + " ax.plot(list(icl.keys()), list(icl.values()), marker=\"s\", label=\"ICL\")\n", + " ax.axvline(result.clustering.k, color=\"grey\", linestyle=\":\", label=f\"chosen k={result.clustering.k}\")\n", + " ax.set_xlabel(\"k\")\n", + " ax.set_ylabel(\"score (lower is better)\")\n", + " ax.set_title(\"GMM model selection\")\n", + " ax.legend()\n", + " plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "7c0d5e1a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "logit columns: ['acousticness', 'danceability', 'energy', 'instrumentalness', 'liveness', 'speechiness', 'valence']\n", + "log columns: ['tempo']\n", + "imputed cells: {}\n", + "dropped tracks (>3 NaN of 9): 0\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
spotify_idtitleartist
\n", + "
" + ], + "text/plain": [ + "Empty DataFrame\n", + "Columns: [spotify_id, title, artist]\n", + "Index: []" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Preprocessing log: which columns were logit/log-transformed, how many cells\n", + "# imputed, which tracks dropped because too many features were missing.\n", + "log = result.transform_log\n", + "print(\"logit columns:\", list(log.logit_columns))\n", + "print(\"log columns: \", list(log.log_columns))\n", + "print(\"imputed cells:\", log.imputed_counts)\n", + "print(\"dropped tracks (>3 NaN of 9):\", len(log.dropped_tracks))\n", + "log.dropped_tracks" + ] + }, + { + "cell_type": "markdown", + "id": "59be2f9a", + "metadata": {}, + "source": [ + "### Step 4 — interpret\n", + "\n", + "`describe_clusters` produces one row per cluster with the top-discriminating features\n", + "and a short human-readable `cluster_summary` string (the thing a playlist gets named\n", + "after)." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "dd97a219", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
clustersizetop_featurescluster_summary
0012[danceability, speechiness]high danceability, high speechiness
1110[loudness, liveness, speechiness]quiet, low liveness, low speechiness
228[valence, acousticness]low valence, low acousticness
332[instrumentalness, loudness, liveness]low instrumentalness, quiet, low liveness
\n", + "
" + ], + "text/plain": [ + " cluster size top_features \\\n", + "0 0 12 [danceability, speechiness] \n", + "1 1 10 [loudness, liveness, speechiness] \n", + "2 2 8 [valence, acousticness] \n", + "3 3 2 [instrumentalness, loudness, liveness] \n", + "\n", + " cluster_summary \n", + "0 high danceability, high speechiness \n", + "1 quiet, low liveness, low speechiness \n", + "2 low valence, low acousticness \n", + "3 low instrumentalness, quiet, low liveness " + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result.descriptions[[\"cluster\", \"size\", \"top_features\", \"cluster_summary\"]]" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "807eccbd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
spotify_idtitleartistclustercluster_summary
0synA000000000000000001DriftThe Placeholders1quiet, low liveness, low speechiness
1synA000000000000000002Quiet TideSample Collective1quiet, low liveness, low speechiness
2synA000000000000000003Window LightLorem Beats1quiet, low liveness, low speechiness
3synA000000000000000004Slow RainStub Choir3low instrumentalness, quiet, low liveness
4synA000000000000000005Almost MorningFixture Six1quiet, low liveness, low speechiness
5synA000000000000000006VellumYield Returns1quiet, low liveness, low speechiness
6synA000000000000000007Hush FieldMock Anthem1quiet, low liveness, low speechiness
7synA000000000000000008Paper LanternSynthetic Sunrise1quiet, low liveness, low speechiness
8synA000000000000000009First FrostTest Signal, Dummy Vox1quiet, low liveness, low speechiness
9synA000000000000000010LinenThe Placeholders1quiet, low liveness, low speechiness
\n", + "
" + ], + "text/plain": [ + " spotify_id title artist cluster \\\n", + "0 synA000000000000000001 Drift The Placeholders 1 \n", + "1 synA000000000000000002 Quiet Tide Sample Collective 1 \n", + "2 synA000000000000000003 Window Light Lorem Beats 1 \n", + "3 synA000000000000000004 Slow Rain Stub Choir 3 \n", + "4 synA000000000000000005 Almost Morning Fixture Six 1 \n", + "5 synA000000000000000006 Vellum Yield Returns 1 \n", + "6 synA000000000000000007 Hush Field Mock Anthem 1 \n", + "7 synA000000000000000008 Paper Lantern Synthetic Sunrise 1 \n", + "8 synA000000000000000009 First Frost Test Signal, Dummy Vox 1 \n", + "9 synA000000000000000010 Linen The Placeholders 1 \n", + "\n", + " cluster_summary \n", + "0 quiet, low liveness, low speechiness \n", + "1 quiet, low liveness, low speechiness \n", + "2 quiet, low liveness, low speechiness \n", + "3 low instrumentalness, quiet, low liveness \n", + "4 quiet, low liveness, low speechiness \n", + "5 quiet, low liveness, low speechiness \n", + "6 quiet, low liveness, low speechiness \n", + "7 quiet, low liveness, low speechiness \n", + "8 quiet, low liveness, low speechiness \n", + "9 quiet, low liveness, low speechiness " + ] + }, + "execution_count": 25, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Per-track output: this is what io.playlist_export will eventually slice into\n", + "# one CSV per cluster.\n", + "result.tracks.head(10)" + ] + }, + { + "cell_type": "markdown", + "id": "f20590be", + "metadata": {}, + "source": [ + "### Step 5 — visualise\n", + "\n", + "`ClusterDiagnostics` carries everything the (future) viz subpackage needs without the\n", + "cluster step itself importing a plotting library. Here we plot three things directly:\n", + "\n", + "1. **z-profile heatmap** — rows = clusters, columns = the nine ReccoBeats features.\n", + " Tells you what each cluster is *about*.\n", + "2. **2-D UMAP scatter** — coloured by cluster, with Unclassified (`-1`) in grey.\n", + "3. **PCA explained-variance bar** — how much structure lives in the first few axes." + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "ec85c21c", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "diag = result.diagnostics\n", + "\n", + "fig, ax = plt.subplots(figsize=(8, max(2, 0.6 * len(diag.zprofile_heatmap) + 1)))\n", + "sns.heatmap(\n", + " diag.zprofile_heatmap,\n", + " annot=True,\n", + " fmt=\".2f\",\n", + " center=0,\n", + " cmap=\"vlag\",\n", + " cbar_kws={\"label\": \"z-score\"},\n", + " ax=ax,\n", + ")\n", + "ax.set_title(\"Cluster z-profile (per-feature mean, z-scored over the library)\")\n", + "ax.set_ylabel(\"cluster\")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "0b88dcd4", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAk4AAAHqCAYAAADyPMGQAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjksIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvJkbTWQAAAAlwSFlzAAAPYQAAD2EBqD+naQAAVsJJREFUeJzt3Qd8VGX2//GTTkJHOiiCKIgUFRQBFRQX7GVd7DSVtYCo2MBFUHRFfyig4F+sYAGxl7XAKkVFEASFXQsoCipNUAgtkIRk/q/vEyc76TfJTDIz+bz3dXe4d+7M3Jkgc3Ke85wnxufz+QwAAAAlii35FAAAABA4AQAAlAIZJwAAAI8InAAAADwicAIAAPCIwAkAAMAjAicAAACPCJwAAAA8InACAADwiMAJQMQZNGiQHXrooRX+uuvXr7eYmBibMWOGVZYzzzzThgwZYtEgMzPTDj74YPt//+//VfalAJ4ROAER7IsvvrBhw4bZUUcdZdWrV7dDDjnELrroIvv+++89P4cCEAUD2mJjY61OnTrWoUMH+/vf/25Lly61qmjWrFk2efJkCzefffaZ/fvf/7Y77rjDokFCQoKNGDHC/vnPf9r+/fsr+3IAT2JYqw6IXH/729/cl2m/fv2sY8eOtmXLFps6dart2bPHPv/8c2vfvr2nwKlu3bp2yy23uP3du3fbd999Z6+++qp7vptvvtkmTpxo4ZapyM7OtqSkpJA8/9lnn21ff/21yzAF0tKe6enp7gs/Li7OKtr5559v+/bts7lz51q0SE1NtUaNGtnjjz9uV155ZWVfDlAiAicggi1evNi6dOliiYmJucd++OEHlzFSUPXiiy96CpwUYL377rt5jusL+rLLLrO33nrLDaVcd911Fip79+51GbNwUVTgVJm2bt1qzZo1s2nTptlVV11l0eScc86xnTt32ieffFLZlwKUiKE6IIJ17949T9Akhx9+uBu6U9aoPJKTk+2FF16wevXquaEUZVtKCsAUcGgo6eijj7Zq1apZu3bt7I033shznuqDNCz48ccf2/XXX28NGza05s2b596vIE3Xr2xS06ZNbejQoS4rUVKNkzJQGl7TY/XaymJcc801tmPHjgLX+sEHH1jPnj2tZs2aVqtWLTvuuOPc8Jz06tXL3nvvPfv5559zhzD9r1VUjdP8+fPtpJNOcsGfhjrPO++8Ap//3Xff7R67du1ad/06r3bt2jZ48GBLS0sr4adh7poOHDhgp512WqHPm5//cw4M/vw/o4ULF7qAWz9jBdnaF/2stK/Pr3PnzvbVV1/lec7//Oc/7tpbtWrlzmncuLHLEv3xxx+FXtPq1avd0LE+44MOOshuvPHGQofk/vKXv9iiRYts+/btJX4OQGUjcAKijAKc3377zerXr1/u56pRo4ZdcMEFtnHjRvv2229LPF/ZrosvvtjOOOMMGz9+vMXHx7thxA8//LDAuQqa9JxjxoyxkSNH5n7hKlBSwPTwww/bhRdeaE888YT16dPHDc8VR0HSbbfdZj169LBHHnnEBSQzZ860vn375nmsAoqzzjrLfUmPGjXKHnjgARfozZkzx93/j3/8w+3r81PgqK24eqePPvrIvYYyQrp+1ewoE6jrKCxjpUBCw6H6fPRnXc8999xT4mer51Tw0aJFCysPBW7KJCrLo2tQYKk/67PSsOwVV1zhrufHH39016eA1E8/x59++sl9tlOmTLFLLrnEZs+e7QrWCwus9XgFSnodnfPoo4+62rn8FKTp8XqPQNhTjROA6PHCCy/oG8z3zDPPeDq/RYsWvrPOOqvI+ydNmuSe7+233y7xeXTe66+/nnts586dviZNmviOOeaY3GPTp09355144om+AwcO5B7funWrLzEx0denTx9fVlZW7vGpU6e685999tncYwMHDnSv5/fpp5+6c2bOnJnnmubMmZPneGpqqq9mzZq+rl27+vbt25fn3Ozs7Nw/6/MIfH6/devWuefTe/A7+uijfQ0bNvT98ccfucdWrVrli42N9Q0YMCD32NixY91jr7zyyjzPecEFF/gOOuggX0n0eXXu3LnAcf/z5uf/nHXN+X9Gixcvzj02d+5cdyw5Odn3888/5x5/4okn3PEFCxbkHktLSyvwOi+99JI775NPPilwTeeee26ec6+//np3XJ9PoE2bNrnjDz74YImfA1DZyDgBUURDI8rYdOvWzQYOHBiU51TWSZQlKYkyRcpQ+WmIZsCAAW7IR4XmgTSlPrDAWpmbjIwMu+mmm9zsvsDz9DwaqiqKCtk17KUhn99//z13UyZD179gwYLcjInehzJcGmoKVNhwV0k2b95sK1eudMNXGtL0U6G+ruX9998v8Jhrr702z76G+DTUtWvXrmJfS+eoiL+8NHyq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t2HDBhck/fHHH9agQQM78cQT7fPPP3d/FrX2D6yW7969u82aNcutk3TnnXe6BpiaUaeFSoMlKyvLrceE0CzXoMAYAICKElV9nCqzqKx27dquSDywxklDhQrm+IhDNxat2Q81atQI0SsAAKry93jUZ5zCiTJNCppSUlJcxov+TsGlYHTbtm3uM1amkMwTAKAiEDiFiIbn9OWuoCk5OTlUL1Ol6bNVPZs+awInAEBFIHAKsbJkmn7atsc+/eF325N+wGokxdtJh9e3Vg0YjgrGZwsAQHlE1ay6SLdhR5oNnfmlXfzk5zbxw+/t6U9/crfaHzbrS3d/qBx66KGe1/grzOTJk0vsuF4a9913nx122GFu+8c//hG05wUAoDwInMKEgqK/P7/CPv1hm8XFmNVLSbC6KYnuVvuffL/N3R/K4Kk8yho4HThwoMCxTz75xF566SX7z3/+Y99++63NnTvX3nvvvSBdKQAAZUfgFCbGv7/aBUUKllIS43OHoXSrfR3X/Q98sLpcr6OFjdWmoVOnTm5ZmrfffrvAOb169cqz0PHf/vY3twagPP3009auXTs7+uijrUOHDrZ06VIbN26cbdq0yS6++GJ3XJkr1R2NHDnSjj/+eHfsoosush07drjnGDRokF155ZV28sknF9r64eWXX7b+/ftb9erVXYd2natACgCAykaNUxhQTdOy9dstJTHO4mILr9vRcd2/dN12d35Zap62b99u559/vr322mtu8ePs7GxLTU0t1XPccsstrlmourErOEpPT3drAj777LMu4FGQJPfff78LfJYtW+b27733Xtcv67HHHnP7K1assEWLFhW6LpD6bSm4CxxGnD17dqnfLwAgBH5fa/bjfLP0XWZJtcwOO9Wsfusq81ETOIUBFYJnHMh2w3LFSU6Is+1pmbZo7e9lCpyUbWrTpo0LmkTNQOvVq1eq5+jdu7fLBp1zzjluUWWtBVgYZazUD+P11193+xkZGS4A8uvXr5+nxRQBAGEi9Rezf48x+2Wx2YF0s5hYM1+22acPmbXoYfaXe8zqHGLRjsApDGj2nBJNJc0S0/06b/f+gnVBwRQfH+/6UPnt378/988KhJQtWrhwoZ155pmuiPuSSy4p8BxqxTBlyhTr06dPoa9RXNPKQw45xH7++efcfbUc0DEAQCUGTbMvy7lNSDFLOUhfSvrH3iwz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TmFEQZGG4dSnSS0HNHtOheCqaUoMmMoPAAAqB4FTiJWlhEw9msrSp6mqoTwPAFDRCJxCRDPCNJtMDRw1+4wlQ4IfNOmz1eeqzxoAgIpA4BQicXFx1rx5c9uwYYOtX78+VC9TpSlo0meszxoAgIpA4BRC6o59+OGHuy7bCD5lmgiaAAAVicApxPTFzpc7AADRgXYEAAAAHhE4AQAAeETgBAAA4BGBEwAAgEcETgAAAAROAAAAwUXGCQAAwCMCJwAAAI8InAAAADwicAIAAPCIwAkAAMAjAicAAACPCJwAAAA8InACAADwiMAJAADAIwInAAAAjwicAAAAPCJwAgAA8IjACQAAwCMCJwAAAI8InAAAADwicAIAAPCIwAkAAMAjAicAAACPCJwAAAA8InACAADwiMAJAADAIwInAAAAjwicAAAAPCJwAgAAqMqB02OPPWaHHnqoVatWzbp27WrLli0r9vxXX33V2rZt687v0KGDvf/++xV2rQAAIHJEXeD08ssv24gRI2zs2LH25ZdfWqdOnaxv3762devWQs9fvHixXXrppXbVVVfZV199Zeeff77bvv766wq/dgAAEN5ifD6fz6KIMkzHHXecTZ061e1nZ2fbwQcfbDfccIONHDmywPkXX3yx7d271959993cYyeccIIdffTRNm3aNE+vuWvXLqtdu7bt3LnTatWqFcR3AwAAQq003+NRlXHKyMiwFStW2GmnnZZ7LDY21u0vWbKk0MfoeOD5ogxVUecDAICqK96iyO+//25ZWVnWqFGjPMe1v3r16kIfs2XLlkLP1/GipKenuy0wUgUAANEvqjJOFWX8+PEupeffNBQIAACiX1QFTvXr17e4uDj77bff8hzXfuPGjQt9jI6X5nwZNWqUGwf1b7/++muQ3gEAAAhnURU4JSYmWufOnW3evHm5x1Qcrv1u3boV+hgdDzxfPvzwwyLPl6SkJFc8FrgBAIDoF1U1TqJWBAMHDrQuXbrY8ccfb5MnT3az5gYPHuzuHzBggDVr1swNt8mNN95oPXv2tIcfftjOOussmz17ti1fvtyefPLJSn4nAAAg3ERd4KT2Atu2bbMxY8a4Am+1FZgzZ05uAfgvv/ziZtr5de/e3WbNmmWjR4+2O++80w4//HB76623rH379pX4LgAAQDiKuj5OlYE+TgAARK4q28cJAAAglAicAAAAPCJwAgAA8IjACQAAwCMCJwAAAI8InAAAADwicAIAAPCIwAkAAMAjAicAAACPCJwAAAA8InACAADwiMAJAADAIwInAAAAjwicAAAAPCJwAgAA8IjACQAAwCMCJwAAAI8InAAAADwicAIAAPCIwAkAAMAjAicAAACPCJwAAAA8InACAADwiMAJAADAIwInAAAAjwicAAAAPCJwAgAA8IjACQAAwCMCJwAAAI8InAAAADwicAIAAPCIwAkAAMAjAicAAACPCJwAAAA8InACAADwiMAJAADAIwInAAAAjwicAAAAPCJwAgAA8IjACQAAwCMCJwAAAI8InAAAADwicAIAAPCIwAkAAKCqBU7r16+3q666ylq2bGnJycl22GGH2dixYy0jI6PYx/Xq1ctiYmLybNdee22FXTcAAIgc8RYlVq9ebdnZ2fbEE09Y69at7euvv7YhQ4bY3r177aGHHir2sTpv3LhxufspKSkVcMUAACDSRE3gdPrpp7vNr1WrVrZmzRp7/PHHSwycFCg1bty4Aq4SAABUmaG6ffv22aJFi+zbb78tcN/+/fvt+eeft3Cyc+dOq1evXonnzZw50+rXr2/t27e3UaNGWVpaWrHnp6en265du/JsAAAg+nkOnL7//ns78sgj7eSTT7YOHTpYz549bfPmzXmClMGDB1u4WLt2rU2ZMsWuueaaYs+77LLL7MUXX7QFCxa4oOmFF16wK664otjHjB8/3mrXrp27HXzwwUG+egAAEI5ifD6fz8uJF1xwgWVmZtqMGTMsNTXVbrrpJpd5WrhwoR1yyCH222+/WdOmTS0rKyuoFzhy5Eh78MEHiz3nu+++s7Zt2+bub9y40QV2Kvx++umnS/V68+fPt969e7vASwXmRWWctPkp46TgScFjrVq1SvV6AACgcul7XIkQL9/jngOnRo0a2UcffeSyTaKHXX/99fb++++7bE316tVDEjht27bN/vjjj2LPUT1TYmKi+/OmTZtcwHTCCSe4IC82tnQTB1VMXqNGDZszZ4717ds36B84AAAIL6X5Ho8vTX1TfPz/Tte0fRVeDxs2zGV3Zs2aZaHQoEEDt3mhTNMpp5xinTt3tunTp5c6aJKVK1e62yZNmpT6sQAAILp5jiw0FLZ8+fICx6dOnWrnnXeenXvuuVaZFDQp06RhQ82iU6Zqy5Ytbgs8R+9j2bJlbv/HH3+0e++911asWOH6QL3zzjs2YMAAV8fVsWPHSnw3AAAgHHnOOKnG6aWXXrL+/fsXGjyph9K0adOssnz44YeuLklb8+bN89znH41UjZZaFPhnzWl4T8OPkydPdkN0qlO68MILbfTo0ZXyHgAAQHjzXOOEolHjBABA1fgej5olVwAAAEKNwAkAAMAjAicAAACPCJwAAAA8InACAAAIdjuCQOrOrcV+t27d6toQBBo+fHhZnhIAACD6AictY6KFc9UD6aCDDnIdxP30ZwInAAAQrUrdx0lNIq+99lobNWpUmZY0iUb0cQIAIHKFtI+Tum5fcsklBE0AAKDKKXXgdNVVV9mrr74amqsBAACIpqG6rKwsO/vss23fvn3WoUMHS0hIyHP/xIkTraphqA4AgKrxPV7q4vDx48fb3LlzrU2bNm4/f3E4AABAtCp14PTwww/bs88+a4MGDQrNFQEAAERLjVNSUpL16NEjNFcDAAAQTYHTjTfeaFOmTAnN1QAAAETTUN2yZcts/vz59u6779pRRx1VoDj8jTfeCOb1AQAARG7gVKdOHfvrX/8amqsBAACIpsBp+vTpobkSAACAMMeaKQAAAMHMOB177LE2b948q1u3rh1zzDHF9mv68ssvvb42AABARPEUOJ133nmuDYGcf/75ob4mAACA6FhyBQWx5AoAAFXje5waJwAAgGAO1am2yes6dNu3b/f62gAAANEXOE2ePDn3z3/88Yfdd9991rdvX+vWrZs7tmTJErfw71133RW6KwUAAIi0GqcLL7zQTjnlFBs2bFie41OnTrWPPvrI3nrrLatqqHECACByhbTGSZml008/vcBxHVPgBAAAEK1KHTgddNBB9vbbbxc4rmO6DwAAIFqVesmVe+65x66++mpbuHChde3a1R1bunSpzZkzx5566qlQXCMAAEBkBk6DBg2yI4880h599FF744033DHtL1q0KDeQAgAAiEY0wAwCisMBAIhcNMAEAAAIATqHAwAAeETgBAAA4BGBEwAAQChm1a1fv94+/PBDy8jIsJ49e1r79u1L83AAAICqETgtWLDAzj77bNu3b1/OA+Pj7dlnn7UrrrgilNcHAAAQeUN1WsD3L3/5i23cuNEt9DtkyBC7/fbbQ3t1AAAAkdjHqU6dOrZ48WJr166d209LS3ML4f32229VfqkV+jgBABC5QtLHSU9av3793P2UlBRLTk52LwIAAFAVlKo4fO7cuS4i88vOzrZ58+bZ119/nXvs3HPPDe4VAgAARNpQXWxsycmpmJgYy8rKsqqGoToAAKrG97jnjJOySwAAAFVZ0BpgKrB69913rTIdeuihLusVuD3wwAPFPmb//v02dOhQV+Beo0YNu/DCC13BOwAAQNADp7Vr19qdd95pzZs3twsuuMAq27hx42zz5s252w033FDs+TfffLP961//sldffdU+/vhj27Rpk/31r3+tsOsFAABRHjipCebzzz9vJ598srVp08a1KRgzZoxt2LDBKlvNmjWtcePGuVv16tWLPFdjmc8884xNnDjRTj31VOvcubNNnz7dvZ/PP/+8Qq8bAABEWeD0xRdf2DXXXOMCksmTJ9t5553nhsP+3//7f3bttddao0aNrLJpaE7Dbsccc4xNmDDBDhw4UOS5K1assMzMTDvttNNyj7Vt29YOOeQQW7JkSZGPS09Pd4VkgRsAAIh+novDO3bs6AKEyy67zGVkjjrqKHd85MiRFi6GDx9uxx57rNWrV89d46hRo9xwnTJKhdmyZYslJia65p6BFADqvqKMHz/e7rnnnqBfPwAAiJKM05o1a9zQ3CmnnJLbPbwiKDDLX/Cdf1u9erU7d8SIEdarVy8X5CkD9vDDD9uUKVNchiiYFJBpmM+//frrr0F9fgAAEOEZp59++slmzJhh1113natxuvTSS+3yyy93gUso3XLLLTZo0KBiz2nVqlWhx7t27eqG6tavX+9qsfLTkGNGRoalpqbmyTppVp3uK0pSUpLbAABA1eI549SsWTP7xz/+4WbRvfDCC24oq0ePHi4wUUD1/fffh+QCGzRo4OqOits03FaYlStXusadDRs2LPR+FYMnJCS47ueBmbVffvnFunXrFpL3AwAAqtisOs1Ae/HFF1390NSpU23+/PkugNEQWWVRMbcK1letWuWyYzNnznStBq644gqrW7euO2fjxo3uOpctW+b21SX0qquuckN8CxYscMXigwcPdkHTCSecUGnvBQAARMFadfkp8Lj++uvdpuzOs88+a5VFQ2ezZ8+2u+++29U0tWzZ0gVOCor8NINOGaW0tLTcY5MmTXJZKTW+1OP69u3rZgkCAACUea06FI216gAAiFwhWatOw3MlUaF4YL0QAABANPEcOC1cuNBatGhhZ511liuoBgAAqGo8B04PPvigW45Ea7qpDcGVV15p7du3D+3VAQAAROKsuttuu82+/fZbe+utt2z37t2uFcHxxx9v06ZNY8kRAABQJZS5OFwz05R9euyxx1xAtWnTphILqqIVxeEAAFSN7/Ey9XGSL7/80j7++GP77rvv3JAddU8AACDalSpwUlbp/vvvtyOOOML+9re/ucV0ly5dap9//rklJyeH7ioBAAAiqTj8zDPPdN21+/TpYxMmTHCz6+Ljy9U/EwAAIDprnNRdu0mTJm7dt+IW9tUQXlVDjRMAAJErJA0wx44dG4xrAwAAiFgsuRIEZJwAAIhcFTKrDgAAoKrxPFRXt27dQmubFKFplt2tt95qf/nLX4J9fQAAAJEXOE2ePLnQ46mpqbZixQo7++yz7bXXXrNzzjknmNcHAAAQeYHTwIEDi73/6KOPtvHjxxM4AQCAqBW0GidlnFavXh2spwMAAIjewCk9Pd0SExOD9XQAAADRGzg988wzbrgOAADAqnqN04gRIwo9rp4H6hb+/fff2yeffBLMawMAAIjMwOmrr74q9LgaRakNwRtvvGEtW7YM5rUBAABEZuCkBX4BAACqMjqHAwAAeETgBAAA4BGBEwAAgEcETgAAAB4ROAEAAHhE4AQAAOARgRMAAIBHBE4AAAAeETgBAAB4ROAEAADgEYETAACARwROAAAAHhE4AQAAeETgBAAA4BGBEwAAgEcETgAAAB4ROAEAAHhE4AQAAOARgRMAAIBHBE4AAAAeETgBAAB4ROAEAABQ1QKnhQsXWkxMTKHbF198UeTjevXqVeD8a6+9tkKvHQAARIZ4ixLdu3e3zZs35zl211132bx586xLly7FPnbIkCE2bty43P2UlJSQXScAAIhcURM4JSYmWuPGjXP3MzMz7e2337YbbrjBZZGKo0Ap8LEAAABRPVSX3zvvvGN//PGHDR48uMRzZ86cafXr17f27dvbqFGjLC0trdjz09PTbdeuXXk2AAAQ/aIm45TfM888Y3379rXmzZsXe95ll11mLVq0sKZNm9p//vMfu+OOO2zNmjX2xhtvFPmY8ePH2z333BOCqwYAAOEsxufz+SyMjRw50h588MFiz/nuu++sbdu2ufsbNmxwwdArr7xiF154Yaleb/78+da7d29bu3atHXbYYUVmnLT5KeN08MEH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EhJcUP/tt9/aww8/bHXr1rVo+XsZ+PP78MMP3fF+/fpZNHjwwQfdL2hTp0617777zu3/3//9n02ZMsWixdVXX+1+bi+88IL997//tT59+rhgQgF/JNq7d6916tTJHnvssULv18/v0UcftWnTptnSpUutevXq1rdvX9u/f79FJS25AhSnbt26vqeffjqqPqTdu3f7Dj/8cN+HH37o69mzp+/GG2/0RYOxY8f6OnXq5Itmd9xxh+/EE0/0VRX6u3nYYYf5srOzfdHgrLPO8l155ZV5jv31r3/1XX755b5okJaW5ouLi/O9++67eY4fe+yxvn/84x++SGdmvjfffDN3X38vGzdu7JswYULusdTUVF9SUpLvpZde8kUjMk4oNqU+e/Zs99uGhuyiiYY9zjrrLPdbYLRRilwp9VatWtnll19uv/zyi0WTd955x7p06eIyMA0bNrRjjjnGnnrqKYtGGRkZ9uKLL9qVV14ZNetgathq3rx59v3337v9VatWuazoGWecYdHgwIED7t/OatWq5TmuIaxoy/7KunXrbMuWLXn+LdXSJV27drUlS5ZYNGKtOhSg1LICJaVZa9SoYW+++aa1a9cuaj4pBYNffvllRNccFEX/WM2YMcPatGnjhnnuueceO+mkk+zrr792i1BHg59++skN9YwYMcLuvPNO93McPny4JSYm2sCBAy2aqJYkNTXVBg0aZNFi5MiRbnFY1d7FxcW5IOOf//ynC/Kjgf4707+fqts68sgjrVGjRvbSSy+5IKJ169YWbbZs2eJu9T4Dad9/X7QhcEIB+tJduXKl7dy501577TX3ZfTxxx9HRfCkVbxvvPFGV3+Q/zfCaBD4W7tqtxRIqUD1lVdesauuusqiQXZ2tss43X///W5fGScFhqqviLbA6ZlnnnE/U2UQo4X+Ls6cOdNmzZrl6vH0b40KqfUeo+Xnp9omZQmbNWvmgsNjjz3WLr30Ulc7isjHUB0K0G/u+s2oc+fONn78eFcU+Mgjj0TFJ6V/uLZu3er+IYuPj3ebgkIVNurP+u03mtSpU8eOOOIIW7t2rUULzdzJH8TrN/toG5L8+eef7aOPPnKFxtHktttuc1mnSy65xM2G7N+/v918883u35pooZmC+ndlz5497pe1ZcuWWWZmphs+jzaNGzd2t7/99lue49r33xdtCJzg6Tf89PT0qPik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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "proj = diag.projection_2d.copy()\n", + "proj[\"cluster\"] = result.tracks[\"cluster\"].to_numpy()\n", + "\n", + "fig, ax = plt.subplots(figsize=(6, 5))\n", + "for cid, sub in proj.groupby(\"cluster\"):\n", + " colour = \"#999999\" if cid == -1 else None\n", + " label = \"Unclassified\" if cid == -1 else f\"cluster {cid}\"\n", + " ax.scatter(sub[\"dim1\"], sub[\"dim2\"], label=label, color=colour, s=60, alpha=0.85)\n", + "ax.set_xlabel(\"UMAP dim 1\")\n", + "ax.set_ylabel(\"UMAP dim 2\")\n", + "ax.set_title(f\"2-D projection ({diag.projection_method})\")\n", + "ax.legend(loc=\"best\", fontsize=8)\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "10454aa3", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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iWqAE0sXL160+/hERESJpo4ao4+1a9dOAzVy1vfu3TMvaFBGRERETgzUGKqzR48eNvtSExERkZMDNcbhRtcsIiIicsE66nr16knfvn3l77//lmLFium435YaNGjgyPQRERElanYHakygASNGjIj0GhqTYUhQIiIiclKgjtgdi4iIiFyojpqIiIhcLEf91VdfSYcOHXTiCzyODlqEExER0RsM1FOmTNFBThCo8TgqqKNmoCYiInrDgfrChQs2HxMREVHcYh01ERFRQpuU4+rVq7Ju3TqdMAMTZFiaPHmyo9JGRESU6NkdqDFXNAY1yZs3r5w8eVLnp8ZEGYZhSKlSpRL9BSUiInJq0ffAgQOlT58+cvToUW1ctmrVKrly5YpUq1ZNmjZt6tDEERERJXZ2B+oTJ06Yp7JMnjy5/Pvvv5ImTRodqWz8+PFxkUYiIqJEy+5AnTp1anO9dLZs2eTcuXPm1+7cuePY1BERESVydtdRly9fXnbu3ClFihSRunXr6vzUKAZfvXq1vkZERERODNRo1R0aGqqPhw8fro+XL18uBQoUYItvIiIiZwdqtPa2LAafOXOmo9NERERE/x8HPCEiIorvOeoMGTLoON4xERIS8rppIiIiInsC9dSpU2OyGRERETkjULdp08bR5yUiIqK4Guv75cuXsmbNGh38BPz8/KRhw4Y6AAoRERE5sTHZ8ePHpWDBgprLRrDGgsfonnXs2DG7ExAUFCS+vr46HKm/v78EBwdHe+4mTZro9qgzZ5E8EREldHYH6k8++USKFi2qM2gdOHBAF4z1Xbx4cenQoYNdx0L/6969e8vQoUP1OCVKlJDatWvLrVu3bG7/5MkT7R42btw4yZo1q71JJyIiSviB+tChQzJ27FhtCW6Cx6NHj5aDBw/aPXhKYGCgtG3bVovP0Sfbw8ND5s2bZ3P7smXLyoQJE6R58+aSMmVKe5NORESU8AM1ir1v3rwZaT1ywfnz54/xcTBe+P79+6VmzZr/S0zSpPp8z5499iaLiIgoQbK79Rdy0z169JBhw4aZx/b+888/zbNnPXz40LxtunTpojwOJvBAozRvb2+r9XiOea4dJSwsTBcTy/QREREluEBdv359/dusWTPzICiGYejf9957z/wcryEQOxtuLDAmORERUaII1Fu3bnXIib28vCRZsmSRitHx3JENxQYOHKgN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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "ev = diag.pca_explained_variance_ratio\n", + "fig, ax = plt.subplots(figsize=(5, 3))\n", + "ax.bar(range(1, len(ev) + 1), ev)\n", + "ax.set_xlabel(\"PCA component\")\n", + "ax.set_ylabel(\"explained variance ratio\")\n", + "ax.set_title(f\"PCA scree (sum first 4 = {sum(ev[:4]):.3f})\")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Step 6 — alternative methods (K-Means, HDBSCAN)\n", + "\n", + "Re-running with the two other implemented methods makes the design decision *\"GMM\n", + "preferred; KMeans/HDBSCAN available for hard/density needs\"* observable rather than\n", + "just stated. Watch how the per-cluster summaries and quality metrics differ." + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "k chosen : 3\n", + "silhouette : 0.547\n", + "inertia : {2: 145.4, 3: 68.5, 4: 55.9}\n", + "CH : {2: 29.4, 3: 46.5, 4: 38.7}\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
clustersizecluster_summary
0012quiet, low liveness, low speechiness
1112high danceability, high speechiness
228low valence, low acousticness
\n", + "
" + ], + "text/plain": [ + " cluster size cluster_summary\n", + "0 0 12 quiet, low liveness, low speechiness\n", + "1 1 12 high danceability, high speechiness\n", + "2 2 8 low valence, low acousticness" + ] + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "kmeans_result = ps.cluster(\n", + " features,\n", + " method=\"kmeans\",\n", + " random_state=0,\n", + " k_range=range(2, min(5, max(3, len(features)))),\n", + " min_playlist_size=1,\n", + ")\n", + "print(f\"k chosen : {kmeans_result.clustering.k}\")\n", + "print(f\"silhouette : {kmeans_result.clustering.silhouette:.3f}\")\n", + "print(\"inertia :\", {k: round(v, 1) for k, v in kmeans_result.clustering.inertia_curve.items()})\n", + "print(\"CH :\", {k: round(v, 1) for k, v in kmeans_result.clustering.calinski_harabasz_curve.items()})\n", + "kmeans_result.descriptions[[\"cluster\", \"size\", \"cluster_summary\"]]" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "discovered clusters : 4\n", + "noise rate : 0.0%\n", + "silhouette (non-noise) : 0.509\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
clustersizecluster_summary
0012high danceability, high speechiness
1110quiet, low liveness, low speechiness
228low valence, low acousticness
332low instrumentalness, quiet, low liveness
\n", + "
" + ], + "text/plain": [ + " cluster size cluster_summary\n", + "0 0 12 high danceability, high speechiness\n", + "1 1 10 quiet, low liveness, low speechiness\n", + "2 2 8 low valence, low acousticness\n", + "3 3 2 low instrumentalness, quiet, low liveness" + ] + }, + "execution_count": 30, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "hdb_result = ps.cluster(\n", + " features,\n", + " method=\"hdbscan\",\n", + " random_state=0,\n", + " min_playlist_size=1,\n", + " hdbscan_min_cluster_size=2,\n", + ")\n", + "print(f\"discovered clusters : {hdb_result.clustering.k}\")\n", + "print(f\"noise rate : {hdb_result.clustering.noise_rate:.1%}\")\n", + "print(f\"silhouette (non-noise) : {hdb_result.clustering.silhouette:.3f}\")\n", + "hdb_result.descriptions[[\"cluster\", \"size\", \"cluster_summary\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "GMM gives soft posteriors and BIC-selected `k`; KMeans gives a hard partition with\n", + "silhouette + Calinski–Harabasz; HDBSCAN sidesteps `k` entirely and flushes outliers into\n", + "cluster `-1`. They are not interchangeable — the framework just makes them comparable\n", + "side-by-side on the same feature frame." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Step 7 — warnings & failure modes\n", + "\n", + "Deliberately set `min_playlist_size` larger than the library so the collapse step routes\n", + "every track to Unclassified. The error-message contract says the warning must *name the\n", + "public knob* and *suggest a fix* — we expect to see `min_playlist_size`, `k_range`, and\n", + "`method` all mentioned in the surfaced warning." + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "- All 32 tracks landed in the Unclassified bucket — no playlists were produced. Consider lowering min_playlist_size (currently 33), passing a smaller k_range, or trying method='hdbscan' with a smaller min_cluster_size.\n", + "\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
clustersizetop_featuresz_profilecluster_summary
0-132[]{}unclassified
\n", + "
" + ], + "text/plain": [ + " cluster size top_features z_profile cluster_summary\n", + "0 -1 32 [] {} unclassified" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "tripped = ps.cluster(\n", + " features,\n", + " method=\"gmm\",\n", + " random_state=0,\n", + " k_range=range(2, min(5, max(3, len(features)))),\n", + " min_playlist_size=len(features) + 1, # nothing can survive\n", + ")\n", + "for w in tripped.warnings:\n", + " print(\"-\", w)\n", + "print()\n", + "tripped.descriptions" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Step 8 — export (future work)\n", + "\n", + "`io.playlist_export.write_cluster_csvs(result.tracks, out_dir)` will eventually slice\n", + "`result.tracks` into one CSV per cluster id (with `-1` written as `Unclassified.csv`),\n", + "and emit a manifest. The cluster IDs and per-track summaries needed for that step are\n", + "already in `result.tracks` today — only the writer is missing.\n", + "\n", + "Sketch of the eventual call:\n", + "\n", + "```python\n", + "from playlistsmith.io import playlist_export # future\n", + "playlist_export.write_cluster_csvs(result.tracks, out_dir=\"./playlists/\")\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. Wrap-up\n", + "\n", + "This vignette walked the implemented path end-to-end: Exportify CSV → `TrackLibrary` →\n", + "ReccoBeats lookup → preprocessing → clustering (GMM / KMeans / HDBSCAN) → canonical\n", + "ordering and small-cluster collapse → per-cluster interpretation → diagnostics (PCA,\n", + "UMAP, z-profile heatmap). The two pieces still scaffolded are the Streamlit GUI\n", + "([`plans/gui.md`](../plans/gui.md)) and the playlist CSV writer\n", + "(`io.playlist_export`); both consume artefacts that already exist on\n", + "`ClusterPipelineResult`.\n", + "\n", + "### How to reproduce\n", + "\n", + "```bash\n", + "# from the repo root\n", + "pip install -e .\n", + "jupyter lab docs/vignette.ipynb\n", + "```\n", + "\n", + "Python 3.12, pinned in `pyproject.toml`. Internet access is required for ReccoBeats\n", + "unless HTTP is mocked (see the `pytest-httpx` fixtures under [`tests/`](../tests/) for\n", + "the pattern)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml index 6f6f77c..98c97f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,11 @@ classifiers = [ license = "MIT" license-files = ["LICEN[CS]E*"] dependencies = [ + "numpy", "pandas", + "scikit-learn", + "scipy", + "umap-learn", "httpx", ] @@ -40,4 +44,8 @@ Issues = "https://github.com/Programming-The-Next-Step-2026/playlistsmith/issues [tool.pytest.ini_options] pythonpath = ["src"] -testpaths = ["tests"] \ No newline at end of file +testpaths = ["tests"] + +[[tool.mypy.overrides]] +module = ["sklearn.*", "umap", "umap.*"] +ignore_missing_imports = true \ No newline at end of file diff --git a/src/playlistsmith/__init__.py b/src/playlistsmith/__init__.py index 115f60c..7b956fa 100644 --- a/src/playlistsmith/__init__.py +++ b/src/playlistsmith/__init__.py @@ -1,3 +1,4 @@ +from playlistsmith.cluster import ClusterPipelineResult, cluster from playlistsmith.io import TrackLibrary -__all__ = ["TrackLibrary"] +__all__ = ["ClusterPipelineResult", "TrackLibrary", "cluster"] diff --git a/src/playlistsmith/cluster/__init__.py b/src/playlistsmith/cluster/__init__.py new file mode 100644 index 0000000..9997050 --- /dev/null +++ b/src/playlistsmith/cluster/__init__.py @@ -0,0 +1,52 @@ +"""Clustering subpackage. + +This package turns a feature DataFrame (as produced by +:func:`playlistsmith.features.extract`) into cluster labels suitable for +playlist export. The pipeline is sequenced as: + +1. :mod:`.preprocess` — logit / log transforms, median imputation, + standard-scaling, and a record of what happened + (:class:`.preprocess.TransformLog`). +2. :mod:`.algorithms` — the actual clustering (GMM is the default per + ``CLAUDE.md``; K-Means and HDBSCAN are alternatives). +3. :mod:`.interpret` — short, deduplicated human-readable labels per + cluster (e.g. ``"high energy, fast tempo, low valence"``). + +The full design is in ``plans/clustering_analysis_revised.md``. +""" + +from __future__ import annotations + +from playlistsmith.cluster.algorithms import ( + ClusteringResult, + fit_gmm, + fit_hdbscan, + fit_kmeans, +) +from playlistsmith.cluster.interpret import describe_clusters +from playlistsmith.cluster.preprocess import ( + BOUNDED_FEATURES, + LOG_FEATURES, + TransformLog, + prepare_matrix, +) +from playlistsmith.cluster.public import ( + ClusterDiagnostics, + ClusterPipelineResult, + cluster, +) + +__all__ = [ + "BOUNDED_FEATURES", + "ClusterDiagnostics", + "ClusterPipelineResult", + "ClusteringResult", + "LOG_FEATURES", + "TransformLog", + "cluster", + "describe_clusters", + "fit_gmm", + "fit_hdbscan", + "fit_kmeans", + "prepare_matrix", +] diff --git a/src/playlistsmith/cluster/algorithms.py b/src/playlistsmith/cluster/algorithms.py new file mode 100644 index 0000000..fa0cd42 --- /dev/null +++ b/src/playlistsmith/cluster/algorithms.py @@ -0,0 +1,526 @@ +"""Clustering algorithms for the playlistsmith pipeline. + +The default clusterer is a Gaussian Mixture Model (``CLAUDE.md``: +"prefer ``GaussianMixture`` over ``KMeans`` for soft cluster assignment; +use BIC for model selection"). K-Means and HDBSCAN are documented as +alternatives in the plan and will be added as follow-ups. + +References +---------- +- Kass, R. E. & Raftery, A. E. (1995). *Bayes Factors*. Journal of + the American Statistical Association, 90(430), 773–795. We use the + Kass–Raftery "strong evidence" threshold of ``ΔBIC > 10`` when + deciding whether a larger ``k`` is justified. +- Biernacki, C., Celeux, G. & Govaert, G. (2000). *Assessing a mixture + model for clustering with the integrated completed likelihood*. IEEE + TPAMI, 22(7), 719–725. We use their ICL criterion as a cross-check on + BIC; it adds an entropy penalty so overlapping clusters score worse. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field + +import numpy as np +import pandas as pd +from sklearn.cluster import HDBSCAN, KMeans +from sklearn.metrics import ( + adjusted_rand_score, + calinski_harabasz_score, + silhouette_score, +) +from sklearn.mixture import GaussianMixture + +__all__ = ["ClusteringResult", "fit_gmm", "fit_hdbscan", "fit_kmeans"] + +#: Library size at and above which we trust ``covariance_type='full'``. +#: Below this, GMM full covariance is under-determined on nine features +#: (plan Section 1 step 5; statistical caveat in Section 7). +_FULL_COVARIANCE_MIN_TRACKS: int = 50 + +#: Kass–Raftery (1995) strong-evidence threshold on ΔBIC: a larger ``k`` +#: is only preferred if it improves BIC by more than this amount. +_DELTA_BIC_THRESHOLD: float = 10.0 + +#: Floor for posterior probabilities when computing ICL, to keep +#: ``log(p)`` finite at the boundary. +_POSTERIOR_LOG_FLOOR: float = 1e-12 + + +@dataclass +class ClusteringResult: + """Outcome of a clustering fit. + + Fields match the contract in + ``plans/clustering_analysis_revised.md`` Section 1. Two additions: + + - ``covariance_type`` is reused as a *model-variant* marker so + callers can tell GMM-full / GMM-diag / K-Means / HDBSCAN apart. + - ``inertia_curve``, ``silhouette_curve`` and + ``calinski_harabasz_curve`` carry the K-Means selection + diagnostics (plan §2). For GMM they stay empty; symmetrically, + ``bic_curve`` / ``icl_curve`` are empty for K-Means. + + Attributes: + labels: Hard cluster assignment per input row, shape ``(n,)``. + posteriors: Soft assignment matrix, shape ``(n, k)``; rows sum + to 1. ``None`` for non-probabilistic clusterers (K-Means, + HDBSCAN). + k: The number of clusters in the final fit. + silhouette: Silhouette score of the final labels (in ``[-1, 1]``). + cohesion: Mean within-cluster squared distance to centroid in + the transformed-scaled feature space. + feature_means_per_cluster: ``(k × n_features)`` frame of cluster + mean vectors in the *transformed-scaled* space; column names + are the canonical feature column names. + stability_ari: Adjusted Rand index between the chosen fit and a + second fit with a different seed. The plan warns when this + drops below 0.7. + random_state: The seed used for the primary fit. + covariance_type: Model-variant marker. ``"full"`` / ``"diag"`` + for GMM (``"diag"`` indicates the small-library fallback + fired); ``"kmeans"`` for K-Means; ``"hdbscan"`` for HDBSCAN. + bic_curve: ``{k: BIC}`` over the swept range, lower is better + (GMM only; empty otherwise). + icl_curve: ``{k: ICL}`` over the swept range, lower is better + (GMM only; empty otherwise). + inertia_curve: ``{k: inertia}`` over the swept range (K-Means + only). Inertia is non-increasing in ``k`` by construction; + useful for elbow plotting. + silhouette_curve: ``{k: silhouette}`` across the K-Means sweep, + used (with ``calinski_harabasz_curve``) for selection. + calinski_harabasz_curve: ``{k: CH-index}`` across the K-Means + sweep. + noise_rate: Share of input rows labelled ``-1`` (HDBSCAN noise). + Zero for clusterers that assign every point. + """ + + labels: np.ndarray + posteriors: np.ndarray | None + k: int + silhouette: float + cohesion: float + feature_means_per_cluster: pd.DataFrame = field(compare=False) + stability_ari: float = 0.0 + random_state: int = 0 + covariance_type: str = "" + bic_curve: dict[int, float] = field(default_factory=dict) + icl_curve: dict[int, float] = field(default_factory=dict) + inertia_curve: dict[int, float] = field(default_factory=dict) + silhouette_curve: dict[int, float] = field(default_factory=dict) + calinski_harabasz_curve: dict[int, float] = field(default_factory=dict) + noise_rate: float = 0.0 + + +def _icl(bic: float, posteriors: np.ndarray) -> float: + """Integrated completed likelihood (ICL) of a fitted mixture. + + Following Biernacki, Celeux & Govaert (2000), ICL is BIC plus an + entropy penalty on the posterior assignment matrix:: + + ICL = BIC - 2 * Σᵢⱼ pᵢⱼ · log(pᵢⱼ) + + Each ``pᵢⱼ · log(pᵢⱼ)`` term is non-positive, so the subtraction + contributes a non-negative entropy penalty: clusters that overlap + (mushy posteriors) score higher (worse) than clusters that + cleanly separate the data. Lower ICL is better, like lower BIC. + + Args: + bic: BIC of the fitted model (lower is better). + posteriors: Posterior matrix, shape ``(n, k)``, rows summing to 1. + + Returns: + The ICL value. + """ + clipped = np.clip(posteriors, _POSTERIOR_LOG_FLOOR, 1.0) + entropy_term = float(np.sum(posteriors * np.log(clipped))) + return bic - 2.0 * entropy_term + + +def _choose_k( + bic_curve: dict[int, float], icl_curve: dict[int, float] +) -> int: + """Pick the preferred ``k`` from the BIC and ICL curves. + + Plan Section 1 step 2: candidate ``k*`` is the BIC minimum, but is + only accepted over ``k* - 1`` if the improvement exceeds the + Kass–Raftery (1995) "strong evidence" threshold (``ΔBIC > 10``). + Otherwise we fall back to the smaller ``k``. If BIC and ICL + disagree on the final pick, prefer ICL (Biernacki et al. 2000). + + Args: + bic_curve: ``{k: BIC}`` over the swept range. + icl_curve: ``{k: ICL}`` over the swept range. + + Returns: + The chosen ``k``. + """ + ks_sorted = sorted(bic_curve) + bic_best = min(ks_sorted, key=lambda k: bic_curve[k]) + + # Walk down from the BIC argmin to the smallest k that still + # beats k-1 by more than the threshold. + bic_choice = bic_best + while bic_choice - 1 in bic_curve: + smaller = bic_choice - 1 + if bic_curve[smaller] - bic_curve[bic_choice] <= _DELTA_BIC_THRESHOLD: + bic_choice = smaller + else: + break + + icl_choice = min(ks_sorted, key=lambda k: icl_curve[k]) + return icl_choice if icl_choice != bic_choice else bic_choice + + +def _cohesion(X: np.ndarray, labels: np.ndarray) -> float: + """Mean within-cluster squared distance to the cluster centroid. + + Args: + X: The transformed-scaled feature matrix, shape ``(n, d)``. + labels: Hard cluster assignment per row, shape ``(n,)``. + + Returns: + The mean squared distance from each point to its cluster's + centroid, averaged over all points. Zero indicates perfect + cohesion; larger values mean more diffuse clusters. + """ + total = 0.0 + for label in np.unique(labels): + members = X[labels == label] + centroid = members.mean(axis=0) + total += float(np.sum((members - centroid) ** 2)) + return total / len(X) + + +def fit_gmm( + X: pd.DataFrame, + k_range: Iterable[int] = range(2, 13), + random_state: int = 0, +) -> ClusteringResult: + """Fit a Gaussian Mixture Model and select ``k`` by BIC/ICL. + + Sweeps ``GaussianMixture`` over ``k_range`` with ``n_init=5`` and a + fixed ``random_state``, records BIC and ICL at each ``k``, picks + ``k`` per :func:`_choose_k` (Kass–Raftery ΔBIC > 10 with an ICL + cross-check), refits the chosen model, and packages everything into + a :class:`ClusteringResult`. + + Small-library fallback: if ``X`` has fewer than + :data:`_FULL_COVARIANCE_MIN_TRACKS` rows, ``covariance_type`` drops + from ``"full"`` to ``"diag"`` because full covariance on nine + features is under-determined. + + Args: + X: Transformed-and-scaled modelling matrix from + :func:`~playlistsmith.cluster.preprocess.prepare_matrix`. + Rows are tracks, columns are the nine ReccoBeats features. + k_range: Candidate values of ``k`` to sweep. All values must be + ``>= 2``. + random_state: Seed threaded through ``GaussianMixture`` for + reproducibility (plan Section 5). + + Returns: + A :class:`ClusteringResult` with hard labels, posteriors, the + BIC/ICL curves, quality metrics, the per-cluster mean vectors + in the transformed-scaled space, a two-seed stability ARI, the + seed used, and the ``covariance_type`` actually used. + + Raises: + ValueError: If ``X`` is empty or ``k_range`` contains values + below 2. + """ + if X.empty: + raise ValueError("Cannot fit a GMM on an empty matrix.") + + k_values = sorted(set(int(k) for k in k_range)) + if not k_values or min(k_values) < 2: + raise ValueError( + f"k_range must contain integers >= 2 (got {k_values!r})." + ) + + X_arr = X.to_numpy() + n = len(X_arr) + if max(k_values) > n: + raise ValueError( + f"k_range max ({max(k_values)}) exceeds the {n} track(s) " + "available after preprocessing. Pass a smaller k_range " + "(e.g. range(2, n + 1)) or add more tracks to the library." + ) + covariance_type = "full" if n >= _FULL_COVARIANCE_MIN_TRACKS else "diag" + + bic_curve: dict[int, float] = {} + icl_curve: dict[int, float] = {} + fits: dict[int, GaussianMixture] = {} + for k in k_values: + gmm = GaussianMixture( + n_components=k, + covariance_type=covariance_type, + n_init=5, + random_state=random_state, + ) + gmm.fit(X_arr) + bic = float(gmm.bic(X_arr)) + bic_curve[k] = bic + icl_curve[k] = _icl(bic, gmm.predict_proba(X_arr)) + fits[k] = gmm + + chosen_k = _choose_k(bic_curve, icl_curve) + chosen = fits[chosen_k] + posteriors = chosen.predict_proba(X_arr) + labels = np.argmax(posteriors, axis=1) + + # Quality metrics. + sil = float(silhouette_score(X_arr, labels)) if chosen_k > 1 else 0.0 + cohesion = _cohesion(X_arr, labels) + + # Per-cluster mean vectors in the modelling space. + means = pd.DataFrame( + np.array([X_arr[labels == c].mean(axis=0) for c in range(chosen_k)]), + columns=list(X.columns), + ) + + # Stability: fit again with a different seed and compute ARI. + stability_seed = random_state + 1 + stability_gmm = GaussianMixture( + n_components=chosen_k, + covariance_type=covariance_type, + n_init=5, + random_state=stability_seed, + ) + stability_labels = stability_gmm.fit(X_arr).predict(X_arr) + stability_ari = float(adjusted_rand_score(labels, stability_labels)) + + return ClusteringResult( + labels=labels, + posteriors=posteriors, + k=chosen_k, + bic_curve=bic_curve, + icl_curve=icl_curve, + silhouette=sil, + cohesion=cohesion, + feature_means_per_cluster=means, + stability_ari=stability_ari, + random_state=random_state, + covariance_type=covariance_type, + ) + + +def _kmeans_choose_k( + silhouette_curve: dict[int, float], + ch_curve: dict[int, float], +) -> int: + """Pick ``k`` by silhouette-Calinski–Harabasz agreement. + + Plan §2 step 2: silhouette systematically prefers low ``k`` and + well-separated convex blobs, which under-segments real audio + libraries. The Calinski–Harabasz index pulls in the other direction. + We score each ``k`` by the sum of its ranks under both metrics + (rank 1 = best per metric), and pick the smallest sum. Ties break + toward larger ``k`` so the playlist count stays closer to user + expectations. + + Args: + silhouette_curve: ``{k: silhouette}`` across the sweep. + ch_curve: ``{k: CH-index}`` across the sweep. + + Returns: + The chosen ``k``. + """ + ks = sorted(silhouette_curve) + sil_rank = { + k: r + for r, k in enumerate(sorted(ks, key=lambda x: -silhouette_curve[x])) + } + ch_rank = { + k: r for r, k in enumerate(sorted(ks, key=lambda x: -ch_curve[x])) + } + return min(ks, key=lambda k: (sil_rank[k] + ch_rank[k], -k)) + + +def fit_kmeans( + X: pd.DataFrame, + k_range: Iterable[int] = range(2, 13), + random_state: int = 0, +) -> ClusteringResult: + """Fit K-Means and select ``k`` by silhouette-CH agreement. + + K-Means is the cheap, hard-assignment alternative to GMM + (plan Section 2). It is useful as a sanity check and as the + small-library fallback below ~25 tracks where GMM covariance is + too under-determined to be trusted. + + Selection follows plan §2 step 2: for each ``k`` we compute + inertia, silhouette and Calinski–Harabasz; ``k`` is picked by + sum-of-ranks across silhouette and CH (see :func:`_kmeans_choose_k`). + + Args: + X: Transformed-and-scaled modelling matrix from + :func:`~playlistsmith.cluster.preprocess.prepare_matrix`. + k_range: Candidate values of ``k`` to sweep. All values must be + ``>= 2``. + random_state: Seed threaded through ``KMeans`` for + reproducibility. + + Returns: + A :class:`ClusteringResult` with hard labels, ``posteriors=None``, + the inertia / silhouette / CH curves, the chosen-``k`` + silhouette and cohesion, a two-seed stability ARI, and + ``covariance_type="kmeans"``. + + Raises: + ValueError: If ``X`` is empty or ``k_range`` contains values + below 2. + """ + if X.empty: + raise ValueError("Cannot fit K-Means on an empty matrix.") + + k_values = sorted(set(int(k) for k in k_range)) + if not k_values or min(k_values) < 2: + raise ValueError( + f"k_range must contain integers >= 2 (got {k_values!r})." + ) + + X_arr = X.to_numpy() + n = len(X_arr) + if max(k_values) > n: + raise ValueError( + f"k_range max ({max(k_values)}) exceeds the {n} track(s) " + "available after preprocessing. Pass a smaller k_range " + "(e.g. range(2, n + 1)) or add more tracks to the library." + ) + + inertia_curve: dict[int, float] = {} + silhouette_curve: dict[int, float] = {} + ch_curve: dict[int, float] = {} + fits: dict[int, KMeans] = {} + for k in k_values: + km = KMeans(n_clusters=k, n_init=10, random_state=random_state) + km.fit(X_arr) + labels_k = km.labels_ + inertia_curve[k] = float(km.inertia_) + silhouette_curve[k] = float(silhouette_score(X_arr, labels_k)) + ch_curve[k] = float(calinski_harabasz_score(X_arr, labels_k)) + fits[k] = km + + chosen_k = _kmeans_choose_k(silhouette_curve, ch_curve) + chosen = fits[chosen_k] + labels = chosen.labels_ + sil = silhouette_curve[chosen_k] + cohesion = _cohesion(X_arr, labels) + + means = pd.DataFrame( + np.array([X_arr[labels == c].mean(axis=0) for c in range(chosen_k)]), + columns=list(X.columns), + ) + + stability_km = KMeans( + n_clusters=chosen_k, n_init=10, random_state=random_state + 1 + ) + stability_labels = stability_km.fit(X_arr).labels_ + stability_ari = float(adjusted_rand_score(labels, stability_labels)) + + return ClusteringResult( + labels=labels, + posteriors=None, + k=chosen_k, + silhouette=sil, + cohesion=cohesion, + feature_means_per_cluster=means, + stability_ari=stability_ari, + random_state=random_state, + covariance_type="kmeans", + inertia_curve=inertia_curve, + silhouette_curve=silhouette_curve, + calinski_harabasz_curve=ch_curve, + ) + + +def fit_hdbscan( + X: pd.DataFrame, + min_cluster_size: int, + min_samples: int | None = None, +) -> ClusteringResult: + """Fit HDBSCAN, surfacing low-density points as noise. + + Plan §3: HDBSCAN discovers the cluster count from the data and is + honest about songs that genuinely do not fit any playlist — + low-density points are labelled ``-1`` rather than being forced + into a cluster. The result's ``noise_rate`` reports the share of + such points so the coverage report can surface them. + + Args: + X: Transformed-and-scaled modelling matrix from + :func:`~playlistsmith.cluster.preprocess.prepare_matrix`. + min_cluster_size: The smallest cluster HDBSCAN is allowed to + return. Plan default is ``max(5, n_tracks // 50)``; the + public :func:`~playlistsmith.cluster.cluster` entry point + applies that default. + min_samples: Noise sensitivity (lower → fewer noise points). + Defaults to ``min_cluster_size`` per plan §3. + + Returns: + A :class:`ClusteringResult` with hard labels (``-1`` for noise), + ``posteriors=None``, ``covariance_type="hdbscan"``, the + ``noise_rate``, a chosen-fit silhouette computed over non-noise + points only, and per-cluster mean vectors for the discovered + non-noise clusters. + + Raises: + ValueError: If ``X`` is empty. + """ + if X.empty: + raise ValueError("Cannot fit HDBSCAN on an empty matrix.") + + effective_min_samples = ( + min_samples if min_samples is not None else min_cluster_size + ) + # ``copy=True`` is sklearn's announced default from 1.10; setting it + # explicitly silences the FutureWarning on 1.8/1.9. + model = HDBSCAN( + min_cluster_size=min_cluster_size, + min_samples=effective_min_samples, + copy=True, + ) + X_arr = X.to_numpy() + labels = model.fit_predict(X_arr) + non_noise_mask = labels >= 0 + n_clusters = int(labels.max() + 1) if non_noise_mask.any() else 0 + noise_rate = float((~non_noise_mask).mean()) + + # Silhouette is only meaningful over the non-noise subset, and + # requires at least two distinct labels. + if non_noise_mask.sum() > 1 and n_clusters >= 2: + sil = float( + silhouette_score(X_arr[non_noise_mask], labels[non_noise_mask]) + ) + else: + sil = 0.0 + + if non_noise_mask.any(): + cohesion = _cohesion(X_arr[non_noise_mask], labels[non_noise_mask]) + else: + cohesion = 0.0 + + means = pd.DataFrame( + np.array( + [X_arr[labels == c].mean(axis=0) for c in range(n_clusters)] + ).reshape(n_clusters, X_arr.shape[1]), + columns=list(X.columns), + ) + + # HDBSCAN is deterministic, so a same-params re-fit always agrees; + # stability_ari is not really informative here. We surface 1.0 with + # this caveat documented in the dataclass. + return ClusteringResult( + labels=labels, + posteriors=None, + k=n_clusters, + silhouette=sil, + cohesion=cohesion, + feature_means_per_cluster=means, + stability_ari=1.0, + random_state=0, + covariance_type="hdbscan", + noise_rate=noise_rate, + ) diff --git a/src/playlistsmith/cluster/interpret.py b/src/playlistsmith/cluster/interpret.py new file mode 100644 index 0000000..e33869f --- /dev/null +++ b/src/playlistsmith/cluster/interpret.py @@ -0,0 +1,149 @@ +"""Human-readable summaries of clusters. + +A bare cluster index (``0``, ``1``, ...) is unhelpful for a playlist +export: the user wants to know *why* a playlist hangs together. This +module turns a :class:`~playlistsmith.cluster.algorithms.ClusteringResult` +plus the modelling matrix into a short, comma-separated description for +each cluster — for example:: + + "high energy, fast tempo, low valence" + +The procedure is documented in +``plans/clustering_analysis_revised.md`` Section 4: + +1. Compute the per-cluster z-profile. Because the modelling matrix is + already z-scored, the z-profile is just the cluster's mean vector. +2. Rank features by ``|z|``. +3. Deduplicate correlated features (``|ρ| > 0.6``) so the summary does + not say the same thing three different ways. ``energy`` and + ``loudness`` typically correlate ~0.7–0.8 in the ReccoBeats space, + so this matters in practice. +4. Render a short qualified string. ``tempo`` and ``loudness`` get + genre-appropriate qualifiers; everything else uses ``high``/``low``. +""" + +from __future__ import annotations + +import pandas as pd + +from playlistsmith.cluster.algorithms import ClusteringResult + +__all__ = ["describe_clusters"] + +#: Features with ``|z|`` below this threshold are considered too close +#: to the global mean to be a defining characteristic of the cluster. +_NEAR_ZERO_Z_THRESHOLD: float = 0.5 + +#: Maximum number of features in a single summary string. Three keeps +#: the label legible; the plan picks this number explicitly. +_MAX_FEATURES_IN_SUMMARY: int = 3 + +#: Absolute Pearson correlation above which two features are treated as +#: redundant for label purposes (e.g. energy ↔ loudness). +_CORRELATION_DEDUP_THRESHOLD: float = 0.6 + +#: Per-feature qualifier overrides. Anything not in this mapping uses +#: the default ``high X`` / ``low X``. +_QUALIFIER_OVERRIDES: dict[str, tuple[str, str]] = { + "tempo": ("fast tempo", "slow tempo"), + "loudness": ("loud", "quiet"), +} + + +def _select_top_features( + z_profile: pd.Series, correlations: pd.DataFrame +) -> list[str]: + """Pick up to three defining features for a cluster. + + Features are walked in order of decreasing ``|z|`` and added to the + label only if (a) their ``|z|`` is above + :data:`_NEAR_ZERO_Z_THRESHOLD` and (b) they do not correlate with + any already-selected feature at + :data:`_CORRELATION_DEDUP_THRESHOLD` or stronger. + + Args: + z_profile: One cluster's z-profile across the nine features. + correlations: Pairwise feature correlations on the modelling + matrix (output of ``X.corr()``). + + Returns: + Feature names in summary order; ``[]`` if every feature is near + the global mean. + """ + ranked = z_profile.abs().sort_values(ascending=False).index.tolist() + chosen: list[str] = [] + for feature in ranked: + if len(chosen) >= _MAX_FEATURES_IN_SUMMARY: + break + if abs(z_profile[feature]) < _NEAR_ZERO_Z_THRESHOLD: + break # The rest are even smaller; nothing more to add. + if any( + abs(correlations.loc[feature, other]) > _CORRELATION_DEDUP_THRESHOLD + for other in chosen + ): + continue + chosen.append(feature) + return chosen + + +def _qualify(feature: str, z: float) -> str: + """Render a single ``feature, z`` pair as a human-readable phrase. + + Args: + feature: The feature column name. + z: The cluster's z-profile value for that feature (sign matters). + + Returns: + E.g. ``"high energy"``, ``"slow tempo"`` or ``"quiet"``. + """ + if feature in _QUALIFIER_OVERRIDES: + high_label, low_label = _QUALIFIER_OVERRIDES[feature] + return high_label if z >= 0 else low_label + return f"{'high' if z >= 0 else 'low'} {feature}" + + +def describe_clusters( + result: ClusteringResult, X: pd.DataFrame +) -> pd.DataFrame: + """Summarise each cluster as a short human-readable string. + + Builds the z-profile from ``result.feature_means_per_cluster`` (the + modelling matrix is already z-scored, so cluster means *are* the + z-profile), then picks up to three uncorrelated, non-trivial top + features per cluster and renders them as ``"high energy, fast + tempo, low valence"``-style summaries. + + Args: + result: The clustering outcome to interpret. + X: The transformed-and-scaled modelling matrix that was fed to + the clusterer. Used to compute pairwise feature correlations + for the dedup step. + + Returns: + A frame with one row per cluster and the columns ``cluster``, + ``size``, ``top_features`` (a ``list[str]``), ``z_profile`` (a + ``dict[str, float]``) and ``summary`` (a ``str``). Cluster rows + are ordered by ascending cluster id. + """ + correlations = X.corr() + means = result.feature_means_per_cluster + sizes = pd.Series(result.labels).value_counts() + + rows: list[dict[str, object]] = [] + for c in range(result.k): + z_profile = means.iloc[c] + top = _select_top_features(z_profile, correlations) + summary = ", ".join(_qualify(feat, z_profile[feat]) for feat in top) + rows.append( + { + "cluster": c, + "size": int(sizes.get(c, 0)), + "top_features": top, + "z_profile": z_profile.to_dict(), + "summary": summary, + } + ) + + return pd.DataFrame( + rows, columns=["cluster", "size", "top_features", "z_profile", "summary"] + ) diff --git a/src/playlistsmith/cluster/preprocess.py b/src/playlistsmith/cluster/preprocess.py new file mode 100644 index 0000000..64d427c --- /dev/null +++ b/src/playlistsmith/cluster/preprocess.py @@ -0,0 +1,181 @@ +"""Shared preprocessing for the clustering pipeline. + +This module is the single entry point that every clustering algorithm +goes through, so they all see identical input. The contract is described +in ``plans/clustering_analysis_revised.md`` Section 0; in short: + +- The seven bounded ``[0, 1]`` features (:data:`BOUNDED_FEATURES`) are + logit-transformed before scaling, with a small epsilon clip so that + raw ``0`` / ``1`` values do not blow up. +- ``tempo`` is log-transformed (BPM is heavy-tailed and perceived + multiplicatively). +- ``loudness`` is already in dB; we only z-score it. +- Per-feature missing cells are filled with that feature's median. +- Rows missing more than 3 of the 9 features are dropped and surfaced + on the returned :class:`TransformLog` so the coverage report can name + them. + +The output of :func:`prepare_matrix` is consumed by +:mod:`playlistsmith.cluster.algorithms`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +import pandas as pd +from sklearn.preprocessing import StandardScaler + +from playlistsmith.features.reccobeats import FEATURE_COLUMNS +from playlistsmith.io.csv_loader import ARTIST, SPOTIFY_ID, TITLE + +__all__ = [ + "BOUNDED_FEATURES", + "LOG_FEATURES", + "TransformLog", + "prepare_matrix", +] + +#: Features bounded to ``[0, 1]`` (logit-transformed before scaling). +BOUNDED_FEATURES: tuple[str, ...] = ( + "acousticness", + "danceability", + "energy", + "instrumentalness", + "liveness", + "speechiness", + "valence", +) + +#: Features that are log-transformed before scaling. ``loudness`` is +#: already in dB and is *not* in this list. +LOG_FEATURES: tuple[str, ...] = ("tempo",) + +#: Epsilon used to clip bounded features off the ``{0, 1}`` boundary +#: before logit so the transform stays finite. +_LOGIT_EPSILON: float = 1e-3 + +#: Identity columns carried alongside the modelling matrix. +_IDENTITY_COLUMNS: tuple[str, str, str] = (SPOTIFY_ID, TITLE, ARTIST) + +#: Maximum number of missing features a row can have and still be kept. +#: A row with strictly more than this many NaNs is dropped. +_MAX_MISSING_PER_ROW: int = 3 + + +@dataclass +class TransformLog: + """Record of what :func:`prepare_matrix` did to the input frame. + + The clustering subpackage hands this to the coverage report so users + can see how much imputation and dropping happened, and which columns + were logit/log transformed. + + Attributes: + logit_columns: Columns logit-transformed before scaling. + log_columns: Columns log-transformed before scaling. + imputed_counts: ``{column: n_cells_imputed}`` for any feature + that had missing values filled with the global median. + Columns with zero imputations are omitted. + dropped_tracks: Identity frame (``spotify_id``, ``title``, + ``artist``) for rows dropped because more than 3 of 9 + features were missing. Excluded from equality/repr. + """ + + logit_columns: tuple[str, ...] + log_columns: tuple[str, ...] + imputed_counts: dict[str, int] + dropped_tracks: pd.DataFrame = field(compare=False, repr=False) + + +def prepare_matrix( + features_df: pd.DataFrame, +) -> tuple[pd.DataFrame, pd.DataFrame, StandardScaler, TransformLog]: + """Turn a unified features frame into a model-ready matrix. + + Steps, in order: drop rows with >3 missing features, median-impute + the remaining missing cells, logit-transform :data:`BOUNDED_FEATURES` + (with boundary clipping), log-transform :data:`LOG_FEATURES`, then + z-score every column with a fitted ``StandardScaler``. + + Identity columns (``spotify_id``, ``title``, ``artist``) do not + enter the modelling matrix; they are returned in a side frame so + cluster labels can be re-attached by row position. + + Args: + features_df: A unified features frame as produced by + :func:`playlistsmith.features.extract`. Must contain the + three identity columns and all nine + :data:`~playlistsmith.features.reccobeats.FEATURE_COLUMNS`. + + Returns: + A ``(X, index, scaler, transform_log)`` tuple. ``X`` is the + transformed-and-scaled modelling matrix as a DataFrame whose + columns are :data:`~playlistsmith.features.reccobeats.FEATURE_COLUMNS` + in canonical order. ``index`` is the row-aligned identity frame. + ``scaler`` is the fitted :class:`~sklearn.preprocessing.StandardScaler`. + ``transform_log`` records what was imputed, dropped, and which + non-linear transforms were applied. + + Raises: + ValueError: If ``features_df`` is empty, or if any of the nine + feature columns are missing from the input. + """ + if features_df.empty: + raise ValueError("Cannot prepare an empty features frame for clustering.") + + missing_columns = [c for c in FEATURE_COLUMNS if c not in features_df.columns] + if missing_columns: + raise ValueError( + f"features_df is missing required columns: {missing_columns!r}" + ) + + # Coerce features to float so NaN-handling is well-defined. + feats = features_df[list(FEATURE_COLUMNS)].astype(float).reset_index(drop=True) + identity = ( + features_df[list(_IDENTITY_COLUMNS)].reset_index(drop=True).copy() + ) + + # Drop rows with too many missing features and remember which ones. + missing_per_row = feats.isna().sum(axis=1) + keep_mask = missing_per_row <= _MAX_MISSING_PER_ROW + dropped_tracks = identity.loc[~keep_mask].reset_index(drop=True) + feats = feats.loc[keep_mask].reset_index(drop=True) + index = identity.loc[keep_mask].reset_index(drop=True) + + if feats.empty: + raise ValueError( + "All tracks were dropped during preprocessing (>3 missing " + "features per row). Nothing left to cluster." + ) + + # Median-impute the remaining cells, recording counts per column. + imputed_counts: dict[str, int] = {} + for col in FEATURE_COLUMNS: + missing = feats[col].isna() + n_missing = int(missing.sum()) + if n_missing > 0: + imputed_counts[col] = n_missing + feats.loc[missing, col] = feats[col].median() + + # Apply non-linear transforms before scaling. + for col in BOUNDED_FEATURES: + clipped = feats[col].clip(lower=_LOGIT_EPSILON, upper=1.0 - _LOGIT_EPSILON) + feats[col] = np.log(clipped / (1.0 - clipped)) + for col in LOG_FEATURES: + feats[col] = np.log(feats[col]) + + # Z-score everything. StandardScaler fitted here is part of the + # public return so callers (e.g. diagnostics) can invert it. + scaler = StandardScaler() + scaled = scaler.fit_transform(feats.to_numpy()) + X = pd.DataFrame(scaled, columns=list(FEATURE_COLUMNS)) + + transform_log = TransformLog( + logit_columns=BOUNDED_FEATURES, + log_columns=LOG_FEATURES, + imputed_counts=imputed_counts, + dropped_tracks=dropped_tracks, + ) + return X, index, scaler, transform_log diff --git a/src/playlistsmith/cluster/public.py b/src/playlistsmith/cluster/public.py new file mode 100644 index 0000000..f0861d0 --- /dev/null +++ b/src/playlistsmith/cluster/public.py @@ -0,0 +1,471 @@ +"""Public entry point for the clustering pipeline. + +Downstream code (``viz``, ``io.playlist_export``) should import +:func:`~playlistsmith.cluster.cluster` from +:mod:`playlistsmith.cluster`. It wraps every step in the pipeline: + +1. :func:`~playlistsmith.cluster.preprocess.prepare_matrix` — logit/log + transforms, median imputation, scaling. +2. :func:`~playlistsmith.cluster.algorithms.fit_gmm` (or another method + when explicitly requested — see plan Section 5). +3. Canonical cluster ordering: relabel clusters by descending size, + ties broken by PC1 of the cluster mean, so cluster ``0`` means + roughly the same thing across re-runs. +4. Playlist-shape post-processing: collapse clusters smaller than + ``min_playlist_size`` into an "Unclassified" bucket (cluster id + ``-1``); warn (but do not auto-split) when a cluster holds more + than ``max_playlist_share`` of the library. +5. :func:`~playlistsmith.cluster.interpret.describe_clusters` — short + human-readable summary per cluster. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field, replace + +import numpy as np +import pandas as pd +import umap +from sklearn.decomposition import PCA + +from playlistsmith.cluster.algorithms import ( + ClusteringResult, + fit_gmm, + fit_hdbscan, + fit_kmeans, +) +from playlistsmith.cluster.interpret import describe_clusters +from playlistsmith.cluster.preprocess import TransformLog, prepare_matrix +from playlistsmith.features.reccobeats import FEATURE_COLUMNS +from playlistsmith.io.csv_loader import ARTIST, SPOTIFY_ID, TITLE + +__all__ = ["ClusterDiagnostics", "ClusterPipelineResult", "cluster"] + +#: Cluster id reserved for tracks in playlists that were too small to +#: be useful (and, in future, for HDBSCAN noise points). +UNCLASSIFIED_LABEL: int = -1 + +#: Human-readable summary string for the Unclassified bucket. +_UNCLASSIFIED_SUMMARY: str = "unclassified" + +#: Methods recognised by :func:`cluster`. K-Means and HDBSCAN are +#: scheduled as follow-ups in plan Section 8 step 6. +_SUPPORTED_METHODS: tuple[str, ...] = ("gmm", "kmeans", "hdbscan") +_IMPLEMENTED_METHODS: tuple[str, ...] = ("gmm", "kmeans", "hdbscan") + + +@dataclass +class ClusterDiagnostics: + """Visualisation-ready artefacts emitted by the cluster pipeline. + + The cluster step itself does not import any plotting library; this + bundle contains everything the viz subpackage needs to draw scatter + plots, heatmaps and selection diagnostics (plan §6). + + Attributes: + pca_components: ``(n_tracks × n_components)`` of PCA coordinates + in the transformed-scaled space. Row-aligned with the + ``tracks`` frame. Columns are ``pc1``, ``pc2``, .... PCA on + nine features is cheap and deterministic; it is also used + for the canonical cluster ordering (see + :func:`_canonical_order`). + pca_explained_variance_ratio: Variance share of each PCA + component, in component order — useful for axis labels. + projection_2d: ``(n_tracks × 2)`` 2-D embedding for scatter + plots. Row-aligned with ``tracks``; columns ``dim1``, + ``dim2``. UMAP is the MIR standard (plan §6). + projection_method: Which projection produced ``projection_2d`` + (``"umap"`` here; ``"tsne"`` if we ever fall back). + zprofile_heatmap: ``(n_clusters × n_features)`` z-profile + frame indexed by cluster id (including ``-1`` for the + Unclassified bucket if present). Columns are the canonical + feature columns. Heatmap-ready. + """ + + pca_components: pd.DataFrame + pca_explained_variance_ratio: list[float] + projection_2d: pd.DataFrame + projection_method: str + zprofile_heatmap: pd.DataFrame + + +@dataclass +class ClusterPipelineResult: + """Output of the full clustering pipeline. + + Attributes: + tracks: One row per surviving track with the identity columns + plus the final ``cluster`` id (``-1`` for the Unclassified + bucket) and ``cluster_summary`` string. This is the frame + that ``io.playlist_export`` consumes. + descriptions: One row per final cluster id (including + ``-1`` if present) with the columns produced by + :func:`~playlistsmith.cluster.interpret.describe_clusters`, + with ``summary`` renamed to ``cluster_summary``. + clustering: The canonical-ordered raw fit. Kept for diagnostics + and so callers can inspect posteriors / BIC curves / + stability ARI. + transform_log: Preprocessing record (logit/log columns, imputed + cells, dropped tracks). + warnings: Post-processing warnings (e.g. a dominant cluster + that exceeded ``max_playlist_share``). + diagnostics: Visualisation-ready artefacts (PCA coordinates, + 2-D projection, z-profile heatmap) — see + :class:`ClusterDiagnostics`. + """ + + tracks: pd.DataFrame + descriptions: pd.DataFrame + clustering: ClusteringResult + transform_log: TransformLog + diagnostics: ClusterDiagnostics + warnings: list[str] = field(default_factory=list) + + +def _canonical_order( + result: ClusteringResult, X: pd.DataFrame +) -> ClusteringResult: + """Relabel clusters by descending size, with PC1 tiebreak. + + Cluster ``0`` becomes the largest cluster after this transformation, + so re-runs that recover the same partition produce stable cluster + numbering (plan Section 5). + + Args: + result: The raw clustering result. + X: The modelling matrix the clusterer saw, used to compute PC1 + of each cluster mean for tiebreaking. + + Returns: + A new :class:`ClusteringResult` with labels, posteriors and + cluster-mean rows reordered into canonical order. + """ + # Native -1 (HDBSCAN noise) is left alone here; the canonical + # ordering only re-numbers real clusters. Noise flows into the + # Unclassified bucket together with anything the post-processing + # step collapses. + non_noise = result.labels[result.labels >= 0] + sizes = ( + np.bincount(non_noise, minlength=result.k) + if len(non_noise) > 0 + else np.zeros(result.k, dtype=int) + ) + if result.k > 0: + pca = PCA(n_components=1) + pc1 = pca.fit_transform( + result.feature_means_per_cluster.to_numpy() + ).ravel() + order = sorted( + range(result.k), key=lambda c: (-int(sizes[c]), float(pc1[c])) + ) + else: + order = [] + old_to_new = {old: new for new, old in enumerate(order)} + + new_labels = np.array( + [old_to_new[lbl] if lbl >= 0 else UNCLASSIFIED_LABEL for lbl in result.labels] + ) + new_posteriors = ( + result.posteriors[:, order] if result.posteriors is not None else None + ) + if order: + new_means = result.feature_means_per_cluster.iloc[order].reset_index(drop=True) + else: + new_means = result.feature_means_per_cluster.iloc[0:0].reset_index(drop=True) + + return replace( + result, + labels=new_labels, + posteriors=new_posteriors, + feature_means_per_cluster=new_means, + ) + + +def _collapse_small_clusters( + labels: np.ndarray, min_size: int +) -> np.ndarray: + """Move tracks in clusters smaller than ``min_size`` to ``-1``. + + Because :func:`_canonical_order` puts the smallest clusters at the + highest ids, collapsing them never leaves gaps in the surviving + label range — kept clusters remain ``0..k'-1`` with no relabel. + Already-``-1`` labels (HDBSCAN noise after canonical ordering) + pass through unchanged. + """ + non_noise = labels[labels >= 0] + if len(non_noise) == 0: + return labels + sizes = np.bincount(non_noise) + small = {c for c, n in enumerate(sizes) if n < min_size} + if not small: + return labels + return np.array( + [UNCLASSIFIED_LABEL if lbl in small or lbl < 0 else lbl for lbl in labels] + ) + + +def _post_processing_warnings( + final_labels: np.ndarray, max_share: float, min_size: int +) -> list[str]: + """Build post-processing warnings for the pipeline result. + + Two failure modes are surfaced: + + - **All tracks unclassified** — if the collapse step routed every + track to ``-1``, no real playlists were produced. The user + probably wants a lower ``min_playlist_size`` or a different + ``method``. + - **Dominant cluster** — any cluster holding more than ``max_share`` + of the library is flagged. The plan deliberately does not + auto-split (that would re-introduce algorithm choice); we surface + the problem and let the user re-run with a larger ``k`` or + ``method='hdbscan'``. + """ + total = len(final_labels) + if total == 0: + return [] + warnings: list[str] = [] + real_labels = final_labels[final_labels >= 0] + if len(real_labels) == 0: + warnings.append( + f"All {total} tracks landed in the Unclassified bucket — no " + "playlists were produced. Consider lowering min_playlist_size " + f"(currently {min_size}), passing a smaller k_range, or " + "trying method='hdbscan' with a smaller min_cluster_size." + ) + return warnings + for c in sorted(set(real_labels.tolist())): + n = int((real_labels == c).sum()) + share = n / total + if share > max_share: + warnings.append( + f"Cluster {c} is dominant: it holds {n}/{total} tracks " + f"({share:.0%}) of the library, more than the " + f"max_playlist_share={max_share:.0%} threshold. Consider " + f"a larger k or method='hdbscan'." + ) + return warnings + + +def _build_tracks_frame( + index: pd.DataFrame, + final_labels: np.ndarray, + summaries: dict[int, str], +) -> pd.DataFrame: + """Assemble the per-track output frame consumed by playlist export.""" + return pd.DataFrame( + { + SPOTIFY_ID: index[SPOTIFY_ID].to_numpy(), + TITLE: index[TITLE].to_numpy(), + ARTIST: index[ARTIST].to_numpy(), + "cluster": final_labels, + "cluster_summary": [summaries[int(c)] for c in final_labels], + } + ) + + +def _build_descriptions_frame( + raw_descriptions: pd.DataFrame, + final_labels: np.ndarray, +) -> pd.DataFrame: + """Restrict descriptions to surviving clusters, rename ``summary`` + to ``cluster_summary``, and append an Unclassified row if needed. + + Args: + raw_descriptions: Output of :func:`describe_clusters` on the + canonical-ordered fit (one row per *original* cluster). + final_labels: Post-collapse labels (with possible ``-1`` entries). + + Returns: + A frame with one row per cluster id appearing in + ``final_labels``. + """ + surviving = sorted(set(int(c) for c in final_labels if c >= 0)) + kept = raw_descriptions[raw_descriptions["cluster"].isin(surviving)].copy() + kept = kept.rename(columns={"summary": "cluster_summary"}) + + if UNCLASSIFIED_LABEL in final_labels: + n_unclassified = int((final_labels == UNCLASSIFIED_LABEL).sum()) + unclassified_row = pd.DataFrame( + [ + { + "cluster": UNCLASSIFIED_LABEL, + "size": n_unclassified, + "top_features": [], + "z_profile": {}, + "cluster_summary": _UNCLASSIFIED_SUMMARY, + } + ] + ) + kept = pd.concat([kept, unclassified_row], ignore_index=True) + + return kept.reset_index(drop=True) + + +def _build_diagnostics( + X: pd.DataFrame, + final_labels: np.ndarray, + raw_descriptions: pd.DataFrame, + random_state: int, +) -> ClusterDiagnostics: + """Compute the visualisation artefacts from the modelling matrix. + + PCA is deterministic; UMAP is seeded with ``random_state`` for + reproducibility. The heatmap is assembled from the cluster + z-profiles already computed by + :func:`~playlistsmith.cluster.interpret.describe_clusters`, with + an all-zero row appended for the Unclassified bucket if one is + present in ``final_labels``. + """ + n, d = X.shape + n_components = min(d, n) + pca = PCA(n_components=n_components) + pca_coords = pca.fit_transform(X.to_numpy()) + pca_df = pd.DataFrame( + pca_coords, + columns=[f"pc{i + 1}" for i in range(n_components)], + ) + + # UMAP needs n_neighbors < n_samples; clamp for small libraries. + umap_neighbors = min(15, max(2, n - 1)) + # ``n_jobs=1`` is implied when ``random_state`` is set; passing it + # explicitly silences a UserWarning from umap-learn. + reducer = umap.UMAP( + n_components=2, + n_neighbors=umap_neighbors, + random_state=random_state, + n_jobs=1, + ) + proj_arr = reducer.fit_transform(X.to_numpy()) + projection_2d = pd.DataFrame(proj_arr, columns=["dim1", "dim2"]) + + # Cluster × feature heatmap. Real clusters come from describe_clusters' + # z_profile column; the Unclassified bucket (if present) gets a row + # of zeros (no defining mean). + rows: dict[int, dict[str, float]] = {} + for _, row in raw_descriptions.iterrows(): + cluster_id = int(row["cluster"]) + rows[cluster_id] = dict(row["z_profile"]) + final_ids = sorted(set(int(c) for c in final_labels)) + surviving = {c for c in final_ids if c >= 0} + heatmap_rows = [] + for cid in final_ids: + if cid in surviving and cid in rows: + heatmap_rows.append([rows[cid].get(col, 0.0) for col in FEATURE_COLUMNS]) + else: + heatmap_rows.append([0.0] * len(FEATURE_COLUMNS)) + heatmap = pd.DataFrame( + heatmap_rows, index=final_ids, columns=list(FEATURE_COLUMNS) + ) + + return ClusterDiagnostics( + pca_components=pca_df, + pca_explained_variance_ratio=[float(x) for x in pca.explained_variance_ratio_], + projection_2d=projection_2d, + projection_method="umap", + zprofile_heatmap=heatmap, + ) + + +def cluster( + features_df: pd.DataFrame, + method: str = "gmm", + random_state: int = 0, + min_playlist_size: int = 5, + max_playlist_share: float = 0.5, + k_range: Iterable[int] = range(2, 13), + hdbscan_min_cluster_size: int | None = None, + hdbscan_min_samples: int | None = None, +) -> ClusterPipelineResult: + """Cluster ``features_df`` and produce playlist-ready output. + + Wraps preprocessing, GMM fitting, canonical cluster ordering, and + playlist-shape post-processing. Soft-assignment posteriors and + quality diagnostics travel along on the returned ``clustering`` + field; the ``tracks`` and ``descriptions`` frames are the + consumer-facing artefacts. + + Args: + features_df: A features frame as produced by + :func:`playlistsmith.features.extract`. + method: ``"gmm"`` (default; the only currently implemented + method), ``"kmeans"`` or ``"hdbscan"`` (both planned, see + plan Section 8 step 6 — raise ``NotImplementedError`` for + now). Other strings raise ``ValueError``. + random_state: Seed threaded through preprocessing-independent + stochastic steps (the GMM fit and its stability re-fit). + min_playlist_size: Clusters with fewer than this many tracks + are collapsed into the Unclassified bucket (cluster id + ``-1``). Defaults to ``5`` per plan Section 5. + max_playlist_share: If any cluster holds more than this share + of the library, a warning is emitted (no auto-split). + k_range: Candidate ``k`` values for GMM model selection. + + Returns: + A :class:`ClusterPipelineResult` with per-track labels, per- + cluster summaries, the raw clustering, the preprocessing log + and any post-processing warnings. + + Raises: + ValueError: If ``method`` is not one of ``{"gmm", "kmeans", + "hdbscan"}``. + NotImplementedError: If ``method`` is recognised but its + implementation is not yet wired up (currently ``"kmeans"`` + and ``"hdbscan"``). + """ + if method not in _SUPPORTED_METHODS: + raise ValueError( + f"Unknown method {method!r}; supported methods are " + f"{list(_SUPPORTED_METHODS)!r}." + ) + if method not in _IMPLEMENTED_METHODS: + raise NotImplementedError( + f"method={method!r} is planned but not yet implemented " + "(see plan Section 8 step 6)." + ) + + X, index, _scaler, transform_log = prepare_matrix(features_df) + if method == "gmm": + raw = fit_gmm(X, k_range=k_range, random_state=random_state) + elif method == "kmeans": + raw = fit_kmeans(X, k_range=k_range, random_state=random_state) + else: # method == "hdbscan" — guarded above. + mcs = ( + hdbscan_min_cluster_size + if hdbscan_min_cluster_size is not None + else max(5, len(X) // 50) # plan §3 default + ) + raw = fit_hdbscan( + X, min_cluster_size=mcs, min_samples=hdbscan_min_samples + ) + ordered = _canonical_order(raw, X) + + final_labels = _collapse_small_clusters(ordered.labels, min_playlist_size) + warnings = _post_processing_warnings( + final_labels, max_playlist_share, min_playlist_size + ) + + raw_descriptions = describe_clusters(ordered, X) + summaries: dict[int, str] = dict( + zip( + raw_descriptions["cluster"].astype(int), + raw_descriptions["summary"].astype(str), + ) + ) + summaries[UNCLASSIFIED_LABEL] = _UNCLASSIFIED_SUMMARY + + tracks_frame = _build_tracks_frame(index, final_labels, summaries) + descriptions_frame = _build_descriptions_frame(raw_descriptions, final_labels) + diagnostics = _build_diagnostics( + X, final_labels, raw_descriptions, random_state + ) + + return ClusterPipelineResult( + tracks=tracks_frame, + descriptions=descriptions_frame, + clustering=ordered, + transform_log=transform_log, + diagnostics=diagnostics, + warnings=warnings, + ) diff --git a/tests/test_cluster_gmm.py b/tests/test_cluster_gmm.py new file mode 100644 index 0000000..d75c2ce --- /dev/null +++ b/tests/test_cluster_gmm.py @@ -0,0 +1,273 @@ +"""Tests for :func:`playlistsmith.cluster.algorithms.fit_gmm`. + +The GMM fitter is the project-default clusterer (per ``CLAUDE.md``). It +sweeps ``k`` over a user-supplied range on a transformed-and-scaled +matrix, picks ``k`` by BIC with an ICL cross-check, refits, and packages +labels, posteriors and quality metrics into a +:class:`~playlistsmith.cluster.algorithms.ClusteringResult` dataclass. + +These tests exercise the public contract against synthetic data where +the right answer is known by construction (three well-separated +Gaussian blobs in 9-D), plus a few invariants the result must always +satisfy (posteriors sum to 1, labels match ``argmax`` of posteriors, +small libraries fall back to diag covariance, etc.). +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from playlistsmith.cluster.algorithms import ( + ClusteringResult, + fit_gmm, +) +from playlistsmith.features.reccobeats import FEATURE_COLUMNS + + +def _three_blob_matrix( + per_blob: int = 60, seed: int = 0 +) -> pd.DataFrame: + """Three well-separated Gaussian blobs in the nine-feature space. + + Centres are placed far enough apart (`±5σ`) that any reasonable + clusterer should recover ``k = 3``. Returned as a DataFrame with + the canonical :data:`FEATURE_COLUMNS` so it matches the modelling + matrix that :func:`prepare_matrix` would produce. + """ + rng = np.random.default_rng(seed) + n_features = len(FEATURE_COLUMNS) + centres = np.array( + [ + [+5.0] * n_features, + [-5.0] * n_features, + [+5.0 if i % 2 == 0 else -5.0 for i in range(n_features)], + ] + ) + rows = [] + for centre in centres: + rows.append(rng.normal(loc=centre, scale=1.0, size=(per_blob, n_features))) + arr = np.vstack(rows) + rng.shuffle(arr) + return pd.DataFrame(arr, columns=list(FEATURE_COLUMNS)) + + +# --------------------------------------------------------------------------- # +# Result shape # +# --------------------------------------------------------------------------- # + + +def test_fit_gmm_returns_clustering_result_dataclass() -> None: + """The return type is the documented :class:`ClusteringResult`.""" + X = _three_blob_matrix() + + result = fit_gmm(X, k_range=range(2, 6), random_state=0) + + assert isinstance(result, ClusteringResult) + + +def test_clustering_result_fields_match_plan_contract() -> None: + """Every field listed in plan Section 1 is present and populated.""" + X = _three_blob_matrix() + + result = fit_gmm(X, k_range=range(2, 6), random_state=0) + + # Required fields per plan/clustering_analysis_revised.md §1. + assert isinstance(result.labels, np.ndarray) + assert isinstance(result.posteriors, np.ndarray) + assert isinstance(result.k, int) + assert isinstance(result.bic_curve, dict) + assert isinstance(result.icl_curve, dict) + assert isinstance(result.silhouette, float) + assert isinstance(result.cohesion, float) + assert isinstance(result.feature_means_per_cluster, pd.DataFrame) + assert isinstance(result.stability_ari, float) + assert isinstance(result.random_state, int) + # Added for small-library-fallback transparency (Section 1 step 5). + assert isinstance(result.covariance_type, str) + + +def test_labels_shape_matches_n_rows_of_input() -> None: + """One label per input row.""" + X = _three_blob_matrix(per_blob=40) + + result = fit_gmm(X, k_range=range(2, 6), random_state=0) + + assert result.labels.shape == (len(X),) + + +def test_posteriors_shape_is_n_by_k_and_rows_sum_to_one() -> None: + """Soft assignments form a valid posterior matrix.""" + X = _three_blob_matrix(per_blob=40) + + result = fit_gmm(X, k_range=range(2, 6), random_state=0) + + assert result.posteriors.shape == (len(X), result.k) + np.testing.assert_allclose( + result.posteriors.sum(axis=1), np.ones(len(X)), atol=1e-6 + ) + + +def test_labels_equal_argmax_of_posteriors() -> None: + """Hard labels are derived from the posterior matrix.""" + X = _three_blob_matrix(per_blob=40) + + result = fit_gmm(X, k_range=range(2, 6), random_state=0) + + np.testing.assert_array_equal( + result.labels, np.argmax(result.posteriors, axis=1) + ) + + +def test_feature_means_have_one_row_per_cluster_with_feature_columns() -> None: + """Per-cluster mean vectors live in the transformed-scaled space and + are indexed by the canonical feature columns.""" + X = _three_blob_matrix(per_blob=40) + + result = fit_gmm(X, k_range=range(2, 6), random_state=0) + + assert result.feature_means_per_cluster.shape == (result.k, len(FEATURE_COLUMNS)) + assert list(result.feature_means_per_cluster.columns) == list(FEATURE_COLUMNS) + + +def test_bic_and_icl_curves_have_one_entry_per_k_in_range() -> None: + """Every ``k`` in the sweep contributes one BIC and one ICL point so + the diagnostic plot is complete.""" + X = _three_blob_matrix(per_blob=40) + k_range = range(2, 7) + + result = fit_gmm(X, k_range=k_range, random_state=0) + + assert set(result.bic_curve.keys()) == set(k_range) + assert set(result.icl_curve.keys()) == set(k_range) + + +# --------------------------------------------------------------------------- # +# Behaviour on synthetic data # +# --------------------------------------------------------------------------- # + + +def test_bic_picks_three_blobs_on_well_separated_data() -> None: + """On three well-separated Gaussian blobs, the chosen ``k`` is 3. + + The plan allows ``± 1`` for borderline cases; we keep the strict + expectation here because the blobs are deliberately far apart. + """ + X = _three_blob_matrix(per_blob=60, seed=0) + + result = fit_gmm(X, k_range=range(2, 8), random_state=0) + + assert result.k == 3 + + +def test_silhouette_is_high_on_well_separated_blobs() -> None: + """Silhouette score is bounded in [-1, 1] and should be clearly + positive on data with obvious structure.""" + X = _three_blob_matrix(per_blob=60, seed=0) + + result = fit_gmm(X, k_range=range(2, 8), random_state=0) + + assert -1.0 <= result.silhouette <= 1.0 + assert result.silhouette > 0.5 # Comfortably above noise. + + +def test_cohesion_is_non_negative() -> None: + """Intra-cluster cohesion (mean within-cluster squared distance to + centroid) cannot be negative.""" + X = _three_blob_matrix(per_blob=40) + + result = fit_gmm(X, k_range=range(2, 6), random_state=0) + + assert result.cohesion >= 0.0 + + +def test_stability_ari_is_high_on_well_separated_blobs() -> None: + """Re-fitting with a second seed and computing adjusted Rand index + should be near 1 on well-separated data.""" + X = _three_blob_matrix(per_blob=60, seed=0) + + result = fit_gmm(X, k_range=range(2, 8), random_state=0) + + assert -1.0 <= result.stability_ari <= 1.0 + assert result.stability_ari > 0.7 # Plan's stability threshold. + + +def test_fit_is_reproducible_for_fixed_random_state() -> None: + """Two fits with the same ``random_state`` produce identical labels.""" + X = _three_blob_matrix(per_blob=40, seed=0) + + r1 = fit_gmm(X, k_range=range(2, 6), random_state=42) + r2 = fit_gmm(X, k_range=range(2, 6), random_state=42) + + assert r1.k == r2.k + np.testing.assert_array_equal(r1.labels, r2.labels) + np.testing.assert_allclose(r1.posteriors, r2.posteriors, atol=1e-10) + + +def test_random_state_is_recorded_on_result() -> None: + """The seed used is part of the result so users can re-derive it.""" + X = _three_blob_matrix(per_blob=40) + + result = fit_gmm(X, k_range=range(2, 6), random_state=7) + + assert result.random_state == 7 + + +# --------------------------------------------------------------------------- # +# Small-library fallback # +# --------------------------------------------------------------------------- # + + +def test_small_library_falls_back_to_diag_covariance() -> None: + """Below ~50 tracks, full covariance is under-determined; the fitter + auto-falls back to ``covariance_type='diag'`` (plan Section 1 step 5).""" + X = _three_blob_matrix(per_blob=10, seed=0) # 30 rows total + assert len(X) < 50 + + result = fit_gmm(X, k_range=range(2, 5), random_state=0) + + assert result.covariance_type == "diag" + + +def test_large_library_uses_full_covariance() -> None: + """At or above the threshold, ``covariance_type='full'`` is used.""" + X = _three_blob_matrix(per_blob=30, seed=0) # 90 rows total + assert len(X) >= 50 + + result = fit_gmm(X, k_range=range(2, 5), random_state=0) + + assert result.covariance_type == "full" + + +# --------------------------------------------------------------------------- # +# Input validation # +# --------------------------------------------------------------------------- # + + +def test_k_range_with_only_k_equal_one_raises() -> None: + """GMM ``k`` must be at least 2 — otherwise there is nothing to + cluster.""" + X = _three_blob_matrix(per_blob=20) + + with pytest.raises(ValueError, match="k"): + fit_gmm(X, k_range=range(1, 2), random_state=0) + + +def test_k_range_max_above_n_samples_raises_clear_error() -> None: + """sklearn raises a cryptic ``n_components > n_samples`` error in + this case; we must catch it and name ``k_range`` and the track + count so the user knows what to fix.""" + # 7 tracks, default k_range tops out at 12. + X = _three_blob_matrix(per_blob=3, seed=0).head(7) + + with pytest.raises(ValueError, match=r"k_range.*7"): + fit_gmm(X, k_range=range(2, 13), random_state=0) + + +def test_empty_matrix_raises_value_error() -> None: + """Empty input is rejected with a user-facing error.""" + empty = pd.DataFrame(columns=list(FEATURE_COLUMNS)) + + with pytest.raises(ValueError): + fit_gmm(empty, k_range=range(2, 4), random_state=0) diff --git a/tests/test_cluster_hdbscan.py b/tests/test_cluster_hdbscan.py new file mode 100644 index 0000000..d4934ca --- /dev/null +++ b/tests/test_cluster_hdbscan.py @@ -0,0 +1,212 @@ +"""Tests for :func:`playlistsmith.cluster.algorithms.fit_hdbscan`. + +HDBSCAN is the density-based, outlier-aware alternative (plan §3). It +differs from GMM and K-Means in three ways the tests have to pin down: + +- there is *no* ``k`` to sweep — the number of clusters is discovered + from the data, so the relevant knobs are ``min_cluster_size`` and + ``min_samples``; +- low-density points are labelled ``-1`` (noise) rather than being + forced into a cluster — this naturally flows into the Unclassified + bucket maintained by the public pipeline; +- BIC / ICL / inertia curves are not defined; only a chosen-fit + silhouette (computed over non-noise points only) and a separately + reported ``noise_rate`` make sense. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from playlistsmith.cluster.algorithms import ( + ClusteringResult, + fit_hdbscan, +) +from playlistsmith.features.reccobeats import FEATURE_COLUMNS + + +def _two_blobs_plus_noise( + per_blob: int = 80, n_noise: int = 10, seed: int = 0 +) -> pd.DataFrame: + """Two tight blobs plus a scatter of noise points far from both.""" + rng = np.random.default_rng(seed) + n_features = len(FEATURE_COLUMNS) + blob_a = rng.normal(loc=+5.0, scale=0.5, size=(per_blob, n_features)) + blob_b = rng.normal(loc=-5.0, scale=0.5, size=(per_blob, n_features)) + noise = rng.uniform(low=-20.0, high=20.0, size=(n_noise, n_features)) + arr = np.vstack([blob_a, blob_b, noise]) + rng.shuffle(arr) + return pd.DataFrame(arr, columns=list(FEATURE_COLUMNS)) + + +# --------------------------------------------------------------------------- # +# Return shape # +# --------------------------------------------------------------------------- # + + +def test_fit_hdbscan_returns_clustering_result() -> None: + """HDBSCAN reuses the same dataclass as GMM and K-Means.""" + X = _two_blobs_plus_noise() + + result = fit_hdbscan(X, min_cluster_size=5) + + assert isinstance(result, ClusteringResult) + + +def test_posteriors_is_none_for_hdbscan() -> None: + """HDBSCAN is hard-assignment with a noise label.""" + X = _two_blobs_plus_noise() + + result = fit_hdbscan(X, min_cluster_size=5) + + assert result.posteriors is None + + +def test_covariance_type_records_hdbscan() -> None: + """The variant marker carries through the result.""" + X = _two_blobs_plus_noise() + + result = fit_hdbscan(X, min_cluster_size=5) + + assert result.covariance_type == "hdbscan" + + +def test_bic_icl_inertia_curves_are_empty_for_hdbscan() -> None: + """HDBSCAN has no ``k`` sweep; the GMM/K-Means curves are empty.""" + X = _two_blobs_plus_noise() + + result = fit_hdbscan(X, min_cluster_size=5) + + assert result.bic_curve == {} + assert result.icl_curve == {} + assert result.inertia_curve == {} + assert result.silhouette_curve == {} + assert result.calinski_harabasz_curve == {} + + +# --------------------------------------------------------------------------- # +# Clustering behaviour # +# --------------------------------------------------------------------------- # + + +def test_finds_two_clusters_on_two_well_separated_blobs() -> None: + """Two blobs + a sparse noise scatter → 2 clusters (plus noise).""" + X = _two_blobs_plus_noise(per_blob=80, n_noise=10, seed=0) + + result = fit_hdbscan(X, min_cluster_size=10) + + assert result.k == 2 + + +def test_noise_points_get_label_minus_one() -> None: + """Low-density points are not forced into a cluster; they get ``-1``.""" + X = _two_blobs_plus_noise(per_blob=80, n_noise=10, seed=0) + + result = fit_hdbscan(X, min_cluster_size=10) + + assert (result.labels == -1).any() + + +def test_noise_rate_is_recorded_and_between_zero_and_one() -> None: + """The noise-point share is reported on the result so the coverage + summary can surface it (plan §3 step 2).""" + X = _two_blobs_plus_noise(per_blob=80, n_noise=10, seed=0) + + result = fit_hdbscan(X, min_cluster_size=10) + + assert 0.0 <= result.noise_rate <= 1.0 + assert result.noise_rate > 0.0 # Some noise was injected. + + +def test_no_noise_when_data_has_no_outliers() -> None: + """If the data is all dense clusters, the noise rate is zero.""" + X = _two_blobs_plus_noise(per_blob=80, n_noise=0, seed=0) + + result = fit_hdbscan(X, min_cluster_size=10) + + assert result.noise_rate == 0.0 + + +def test_silhouette_excludes_noise_points() -> None: + """Silhouette is computed only over non-noise labels — including + noise as its own cluster would distort the score.""" + X = _two_blobs_plus_noise(per_blob=80, n_noise=10, seed=0) + + result = fit_hdbscan(X, min_cluster_size=10) + + # Silhouette is well-defined and high; the noise points were + # excluded rather than collapsed into a degenerate cluster. + assert -1.0 <= result.silhouette <= 1.0 + assert result.silhouette > 0.5 + + +def test_feature_means_have_one_row_per_non_noise_cluster() -> None: + """``feature_means_per_cluster`` has ``k`` rows (the discovered + non-noise clusters); the noise bucket is not a row.""" + X = _two_blobs_plus_noise() + + result = fit_hdbscan(X, min_cluster_size=10) + + assert result.feature_means_per_cluster.shape == (result.k, len(FEATURE_COLUMNS)) + assert list(result.feature_means_per_cluster.columns) == list(FEATURE_COLUMNS) + + +def test_cohesion_is_non_negative() -> None: + """Cohesion is a mean of non-negative squared distances.""" + X = _two_blobs_plus_noise() + + result = fit_hdbscan(X, min_cluster_size=10) + + assert result.cohesion >= 0.0 + + +# --------------------------------------------------------------------------- # +# Parameter handling # +# --------------------------------------------------------------------------- # + + +def test_min_samples_defaults_to_min_cluster_size() -> None: + """Plan §3: ``min_samples`` defaults to ``min_cluster_size`` but is + exposable as its own argument. Calling without it should not error + and should produce a sensible fit.""" + X = _two_blobs_plus_noise() + + result = fit_hdbscan(X, min_cluster_size=10) + + assert result.k >= 1 + + +def test_min_samples_is_independently_settable() -> None: + """Passing ``min_samples`` explicitly changes the noise sensitivity.""" + X = _two_blobs_plus_noise(per_blob=80, n_noise=10) + + strict = fit_hdbscan(X, min_cluster_size=10, min_samples=20) + lax = fit_hdbscan(X, min_cluster_size=10, min_samples=1) + + # Higher min_samples → more points labelled as noise. + assert strict.noise_rate >= lax.noise_rate + + +def test_fit_is_deterministic() -> None: + """HDBSCAN has no randomness; two fits agree exactly.""" + X = _two_blobs_plus_noise() + + r1 = fit_hdbscan(X, min_cluster_size=10) + r2 = fit_hdbscan(X, min_cluster_size=10) + + np.testing.assert_array_equal(r1.labels, r2.labels) + + +# --------------------------------------------------------------------------- # +# Input validation # +# --------------------------------------------------------------------------- # + + +def test_empty_matrix_raises_value_error() -> None: + """Empty input is rejected with a user-facing error.""" + empty = pd.DataFrame(columns=list(FEATURE_COLUMNS)) + + with pytest.raises(ValueError): + fit_hdbscan(empty, min_cluster_size=5) diff --git a/tests/test_cluster_interpret.py b/tests/test_cluster_interpret.py new file mode 100644 index 0000000..ccbab44 --- /dev/null +++ b/tests/test_cluster_interpret.py @@ -0,0 +1,276 @@ +"""Tests for :func:`playlistsmith.cluster.interpret.describe_clusters`. + +A bare cluster label is useless to a user — :func:`describe_clusters` +turns each cluster into a short, human-readable summary such as +``"high energy, fast tempo, low valence"`` (plan Section 4). The key +correctness criteria are: + +- one row per cluster, with ``cluster``, ``size``, ``top_features`` + and ``summary`` columns; +- top features are picked by absolute z-profile, capped at three; +- correlated features are deduplicated (``|ρ| > 0.6``) so the summary + does not say the same thing three ways; +- ``tempo`` and ``loudness`` get genre-appropriate qualifiers (fast/ + slow, loud/quiet) instead of bare ``high``/``low``. + +The tests construct a :class:`ClusteringResult` by hand so the z-profile +is known exactly, rather than going through :func:`fit_gmm`. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +from playlistsmith.cluster.algorithms import ClusteringResult +from playlistsmith.cluster.interpret import describe_clusters +from playlistsmith.features.reccobeats import FEATURE_COLUMNS + + +def _zscore_matrix(n: int = 60, seed: int = 0) -> pd.DataFrame: + """A z-scored modelling matrix (per-column mean 0, SD 1).""" + rng = np.random.default_rng(seed) + raw = pd.DataFrame( + rng.standard_normal((n, len(FEATURE_COLUMNS))), + columns=list(FEATURE_COLUMNS), + ) + return (raw - raw.mean()) / raw.std(ddof=0) + + +def _result_with_means( + cluster_means: list[dict[str, float]], labels: np.ndarray +) -> ClusteringResult: + """Build a minimal :class:`ClusteringResult` with controlled means.""" + means = pd.DataFrame( + [ + {col: row.get(col, 0.0) for col in FEATURE_COLUMNS} + for row in cluster_means + ], + columns=list(FEATURE_COLUMNS), + ) + k = len(cluster_means) + posteriors = np.eye(k)[labels] + return ClusteringResult( + labels=labels, + posteriors=posteriors, + k=k, + bic_curve={k: 0.0}, + icl_curve={k: 0.0}, + silhouette=0.5, + cohesion=1.0, + feature_means_per_cluster=means, + stability_ari=1.0, + random_state=0, + covariance_type="full", + ) + + +# --------------------------------------------------------------------------- # +# Shape & required columns # +# --------------------------------------------------------------------------- # + + +def test_returns_one_row_per_cluster() -> None: + """Output has exactly one row per cluster in the input.""" + X = _zscore_matrix(n=60) + labels = np.array([0] * 20 + [1] * 20 + [2] * 20) + result = _result_with_means( + [{"energy": 2.0}, {"energy": -1.5}, {}], labels + ) + + described = describe_clusters(result, X) + + assert len(described) == 3 + assert sorted(described["cluster"].tolist()) == [0, 1, 2] + + +def test_has_required_columns() -> None: + """Plan Section 4: ``label, size, top features, summary string``.""" + X = _zscore_matrix(n=60) + labels = np.array([0] * 30 + [1] * 30) + result = _result_with_means( + [{"energy": 2.0}, {"energy": -2.0}], labels + ) + + described = describe_clusters(result, X) + + for col in ("cluster", "size", "top_features", "summary"): + assert col in described.columns + + +def test_size_column_matches_label_counts() -> None: + """``size`` is the count of tracks in each cluster.""" + X = _zscore_matrix(n=60) + labels = np.array([0] * 10 + [1] * 20 + [2] * 30) + result = _result_with_means( + [{"energy": 2.0}, {"energy": -2.0}, {"valence": 2.0}], labels + ) + + described = describe_clusters(result, X) + + sizes = dict(zip(described["cluster"], described["size"])) + assert sizes == {0: 10, 1: 20, 2: 30} + + +# --------------------------------------------------------------------------- # +# Top-feature selection # +# --------------------------------------------------------------------------- # + + +def test_top_features_capped_at_three() -> None: + """Even when every feature has a strong z, the label is at most three + features long — the plan picks top-3 to keep summaries readable.""" + X = _zscore_matrix(n=40) + labels = np.array([0] * 20 + [1] * 20) + # All nine features extreme on cluster 0. + extreme = {col: (i + 1) * 0.5 for i, col in enumerate(FEATURE_COLUMNS)} + result = _result_with_means([extreme, {}], labels) + + described = describe_clusters(result, X) + + cluster_0 = described.set_index("cluster").loc[0] + assert len(cluster_0["top_features"]) <= 3 + + +def test_top_features_are_ranked_by_absolute_z() -> None: + """The feature with the largest ``|z|`` lands first, regardless of sign.""" + X = _zscore_matrix(n=40) + labels = np.array([0] * 20 + [1] * 20) + # Cluster 0: largest |z| is valence (-3), then danceability (+2), then + # liveness (+1). Everything else ~0. + means_0 = {"valence": -3.0, "danceability": 2.0, "liveness": 1.0} + result = _result_with_means([means_0, {}], labels) + + described = describe_clusters(result, X) + + cluster_0 = described.set_index("cluster").loc[0] + assert cluster_0["top_features"][0] == "valence" + assert cluster_0["top_features"][1] == "danceability" + + +def test_features_near_zero_are_excluded() -> None: + """A feature whose ``|z|`` is near zero should not appear in the + summary even if the cluster has fewer than 3 strong features.""" + X = _zscore_matrix(n=40) + labels = np.array([0] * 20 + [1] * 20) + # Only one notable feature; the other eight have means ~0. + result = _result_with_means( + [{"energy": 2.5}, {"energy": -2.5}], labels + ) + + described = describe_clusters(result, X) + + cluster_0 = described.set_index("cluster").loc[0] + assert "energy" in cluster_0["top_features"] + # No spurious near-average features have been padded in. + assert all( + abs(cluster_0["z_profile"][f]) >= 0.5 + for f in cluster_0["top_features"] + ) + + +# --------------------------------------------------------------------------- # +# Correlated-feature deduplication # +# --------------------------------------------------------------------------- # + + +def test_correlated_features_are_deduplicated() -> None: + """When two features correlate at ``|ρ| > 0.6``, only the one with + higher ``|z|`` appears in the summary.""" + rng = np.random.default_rng(0) + n = 80 + # Make energy and loudness strongly correlated (ρ ≈ 0.9). + energy = rng.standard_normal(n) + loudness = 0.9 * energy + 0.1 * rng.standard_normal(n) + data = {col: rng.standard_normal(n) for col in FEATURE_COLUMNS} + data["energy"] = energy + data["loudness"] = loudness + raw = pd.DataFrame(data, columns=list(FEATURE_COLUMNS)) + X = (raw - raw.mean()) / raw.std(ddof=0) + + labels = np.array([0] * (n // 2) + [1] * (n - n // 2)) + # Both energy and loudness extreme in cluster 0; energy slightly larger. + result = _result_with_means( + [{"energy": 2.5, "loudness": 2.3, "valence": 1.5}, {}], labels + ) + + described = describe_clusters(result, X) + + cluster_0 = described.set_index("cluster").loc[0] + top = cluster_0["top_features"] + assert "energy" in top + assert "loudness" not in top # Suppressed by correlation with energy. + # The deduplicated slot should be filled by the next-strongest + # uncorrelated feature. + assert "valence" in top + + +# --------------------------------------------------------------------------- # +# Summary string # +# --------------------------------------------------------------------------- # + + +def test_summary_uses_high_and_low_qualifiers_with_sign() -> None: + """``+z`` → "high X"; ``-z`` → "low X" for ordinary features.""" + X = _zscore_matrix(n=40) + labels = np.array([0] * 20 + [1] * 20) + result = _result_with_means( + [{"energy": 2.0, "valence": -2.0}, {"energy": -2.0, "valence": 2.0}], + labels, + ) + + described = describe_clusters(result, X) + + s0 = described.set_index("cluster").loc[0]["summary"] + s1 = described.set_index("cluster").loc[1]["summary"] + assert "high energy" in s0 + assert "low valence" in s0 + assert "low energy" in s1 + assert "high valence" in s1 + + +def test_tempo_summary_uses_fast_and_slow_qualifiers() -> None: + """``tempo`` reads more naturally as fast/slow than high/low.""" + X = _zscore_matrix(n=40) + labels = np.array([0] * 20 + [1] * 20) + result = _result_with_means( + [{"tempo": 2.0}, {"tempo": -2.0}], labels + ) + + described = describe_clusters(result, X) + + assert "fast tempo" in described.set_index("cluster").loc[0]["summary"] + assert "slow tempo" in described.set_index("cluster").loc[1]["summary"] + + +def test_loudness_summary_uses_loud_and_quiet_qualifiers() -> None: + """``loudness`` reads naturally as loud/quiet.""" + X = _zscore_matrix(n=40) + labels = np.array([0] * 20 + [1] * 20) + result = _result_with_means( + [{"loudness": 2.0}, {"loudness": -2.0}], labels + ) + + described = describe_clusters(result, X) + + s0 = described.set_index("cluster").loc[0]["summary"] + s1 = described.set_index("cluster").loc[1]["summary"] + assert "loud" in s0 and "quiet" not in s0 + assert "quiet" in s1 and "loud" not in s1 + + +def test_summary_is_comma_separated() -> None: + """The summary is a comma-separated, lowercase, no-trailing-comma + string built from the top features.""" + X = _zscore_matrix(n=40) + labels = np.array([0] * 20 + [1] * 20) + result = _result_with_means( + [{"energy": 2.0, "tempo": 1.8, "valence": -1.5}, {}], labels + ) + + described = describe_clusters(result, X) + + s = described.set_index("cluster").loc[0]["summary"] + assert ", " in s + assert not s.endswith(",") + assert s == s.lower() diff --git a/tests/test_cluster_kmeans.py b/tests/test_cluster_kmeans.py new file mode 100644 index 0000000..4287f37 --- /dev/null +++ b/tests/test_cluster_kmeans.py @@ -0,0 +1,226 @@ +"""Tests for :func:`playlistsmith.cluster.algorithms.fit_kmeans`. + +K-Means is the cheap, hard-assignment alternative to GMM +(plan Section 2). Selection differs from the GMM path: + +- there is no BIC / ICL, so :attr:`ClusteringResult.bic_curve` and + :attr:`icl_curve` are empty; +- per-``k`` we instead record inertia (for elbow plotting), silhouette + and Calinski–Harabasz on three new ``ClusteringResult`` fields; +- ``k`` is picked by *agreement* between silhouette and Calinski– + Harabasz rather than by either metric alone (silhouette systematically + prefers low ``k`` on real audio libraries); +- ``posteriors`` is ``None`` — callers that rank tracks within a + cluster must fall back to distance-to-centroid. + +These tests pin all of that against deterministic synthetic data. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from playlistsmith.cluster.algorithms import ( + ClusteringResult, + fit_kmeans, +) +from playlistsmith.features.reccobeats import FEATURE_COLUMNS + + +def _three_blob_matrix( + per_blob: int = 60, seed: int = 0 +) -> pd.DataFrame: + """Three well-separated Gaussian blobs in the nine-feature space.""" + rng = np.random.default_rng(seed) + n_features = len(FEATURE_COLUMNS) + centres = np.array( + [ + [+5.0] * n_features, + [-5.0] * n_features, + [+5.0 if i % 2 == 0 else -5.0 for i in range(n_features)], + ] + ) + rows = [] + for centre in centres: + rows.append(rng.normal(loc=centre, scale=1.0, size=(per_blob, n_features))) + arr = np.vstack(rows) + rng.shuffle(arr) + return pd.DataFrame(arr, columns=list(FEATURE_COLUMNS)) + + +# --------------------------------------------------------------------------- # +# Return shape & K-Means specifics # +# --------------------------------------------------------------------------- # + + +def test_fit_kmeans_returns_clustering_result() -> None: + """K-Means reuses the same result dataclass as GMM (plan §2).""" + X = _three_blob_matrix() + + result = fit_kmeans(X, k_range=range(2, 6), random_state=0) + + assert isinstance(result, ClusteringResult) + + +def test_posteriors_is_none_for_kmeans() -> None: + """K-Means is hard-assignment: there are no posteriors. Downstream + code is expected to fall back to distance-to-centroid.""" + X = _three_blob_matrix() + + result = fit_kmeans(X, k_range=range(2, 6), random_state=0) + + assert result.posteriors is None + + +def test_bic_and_icl_curves_are_empty_for_kmeans() -> None: + """BIC/ICL are not defined for K-Means — those fields are present + on the dataclass but empty here.""" + X = _three_blob_matrix() + + result = fit_kmeans(X, k_range=range(2, 6), random_state=0) + + assert result.bic_curve == {} + assert result.icl_curve == {} + + +def test_kmeans_curves_have_one_entry_per_k_in_range() -> None: + """``inertia_curve``, ``silhouette_curve`` and + ``calinski_harabasz_curve`` populate the per-``k`` diagnostics + used for selection (plan §2 step 1).""" + X = _three_blob_matrix() + k_range = range(2, 7) + + result = fit_kmeans(X, k_range=k_range, random_state=0) + + assert set(result.inertia_curve.keys()) == set(k_range) + assert set(result.silhouette_curve.keys()) == set(k_range) + assert set(result.calinski_harabasz_curve.keys()) == set(k_range) + + +def test_inertia_curve_is_non_increasing_in_k() -> None: + """K-Means inertia (sum of squared distances to centroid) is + monotonically non-increasing in ``k`` by construction.""" + X = _three_blob_matrix() + result = fit_kmeans(X, k_range=range(2, 7), random_state=0) + + ks_sorted = sorted(result.inertia_curve) + values = [result.inertia_curve[k] for k in ks_sorted] + for prev, curr in zip(values, values[1:]): + assert curr <= prev + 1e-9 + + +def test_covariance_type_records_kmeans() -> None: + """The ``covariance_type`` slot is reused as a model-variant marker + so callers can tell GMM-full / GMM-diag / K-Means apart.""" + X = _three_blob_matrix() + + result = fit_kmeans(X, k_range=range(2, 6), random_state=0) + + assert result.covariance_type == "kmeans" + + +# --------------------------------------------------------------------------- # +# Selection behaviour # +# --------------------------------------------------------------------------- # + + +def test_selection_picks_three_blobs() -> None: + """On three well-separated blobs, the silhouette-CH agreement rule + picks ``k = 3``.""" + X = _three_blob_matrix(per_blob=60, seed=0) + + result = fit_kmeans(X, k_range=range(2, 8), random_state=0) + + assert result.k == 3 + + +def test_silhouette_field_is_in_valid_range() -> None: + """The chosen-``k`` silhouette is in ``[-1, 1]`` and positive on + structured data.""" + X = _three_blob_matrix(per_blob=60, seed=0) + + result = fit_kmeans(X, k_range=range(2, 8), random_state=0) + + assert -1.0 <= result.silhouette <= 1.0 + assert result.silhouette > 0.5 + + +def test_cohesion_is_non_negative() -> None: + """Intra-cluster cohesion is a mean of non-negative squared + distances.""" + X = _three_blob_matrix() + + result = fit_kmeans(X, k_range=range(2, 6), random_state=0) + + assert result.cohesion >= 0.0 + + +def test_stability_ari_is_high_on_well_separated_blobs() -> None: + """Re-fit with a different seed and check agreement.""" + X = _three_blob_matrix(per_blob=60, seed=0) + + result = fit_kmeans(X, k_range=range(2, 8), random_state=0) + + assert -1.0 <= result.stability_ari <= 1.0 + assert result.stability_ari > 0.7 + + +def test_fit_is_reproducible_for_fixed_random_state() -> None: + """Two fits with the same seed produce identical labels.""" + X = _three_blob_matrix(per_blob=40) + + r1 = fit_kmeans(X, k_range=range(2, 6), random_state=42) + r2 = fit_kmeans(X, k_range=range(2, 6), random_state=42) + + assert r1.k == r2.k + np.testing.assert_array_equal(r1.labels, r2.labels) + + +def test_random_state_is_recorded() -> None: + """The seed used is part of the result.""" + X = _three_blob_matrix() + + result = fit_kmeans(X, k_range=range(2, 6), random_state=11) + + assert result.random_state == 11 + + +def test_feature_means_have_one_row_per_cluster() -> None: + """Per-cluster mean vectors live in the modelling space.""" + X = _three_blob_matrix(per_blob=40) + + result = fit_kmeans(X, k_range=range(2, 6), random_state=0) + + assert result.feature_means_per_cluster.shape == (result.k, len(FEATURE_COLUMNS)) + + +# --------------------------------------------------------------------------- # +# Input validation # +# --------------------------------------------------------------------------- # + + +def test_k_below_two_raises() -> None: + """K-Means with ``k < 2`` is rejected (nothing to cluster).""" + X = _three_blob_matrix(per_blob=20) + + with pytest.raises(ValueError, match="k"): + fit_kmeans(X, k_range=range(1, 2), random_state=0) + + +def test_k_range_max_above_n_samples_raises_clear_error() -> None: + """KMeans would otherwise fail with a misleading "n_samples >=" + error from sklearn. The wrapper must name ``k_range``.""" + X = _three_blob_matrix(per_blob=3, seed=0).head(7) + + with pytest.raises(ValueError, match=r"k_range.*7"): + fit_kmeans(X, k_range=range(2, 13), random_state=0) + + +def test_empty_matrix_raises_value_error() -> None: + """Empty input is rejected.""" + empty = pd.DataFrame(columns=list(FEATURE_COLUMNS)) + + with pytest.raises(ValueError): + fit_kmeans(empty, k_range=range(2, 4), random_state=0) diff --git a/tests/test_cluster_preprocess.py b/tests/test_cluster_preprocess.py new file mode 100644 index 0000000..f035d7a --- /dev/null +++ b/tests/test_cluster_preprocess.py @@ -0,0 +1,278 @@ +"""Tests for :mod:`playlistsmith.cluster.preprocess`. + +The preprocessing pipeline turns a unified feature DataFrame (identity +columns + nine ReccoBeats audio features) into a model-ready matrix. +The contract is documented in ``plans/clustering_analysis_revised.md`` +Section 0: + +- bounded ``[0, 1]`` features are logit-transformed before scaling, +- ``tempo`` is log-transformed, +- ``loudness`` is z-scored as-is (already in dB), +- per-feature missing cells are filled with the global median, +- rows missing more than 3 of 9 features are dropped, and +- the resulting columns have ~zero mean and unit variance. + +Every transformation decision is recorded on the returned +:class:`TransformLog` so the coverage report can surface it. +""" + +from __future__ import annotations + +import math + +import numpy as np +import pandas as pd +import pytest +from sklearn.preprocessing import StandardScaler + +from playlistsmith.cluster.preprocess import ( + BOUNDED_FEATURES, + LOG_FEATURES, + TransformLog, + prepare_matrix, +) +from playlistsmith.features.reccobeats import FEATURE_COLUMNS +from playlistsmith.io.csv_loader import ARTIST, SPOTIFY_ID, TITLE + + +def _synthetic_features(n: int = 30, seed: int = 0) -> pd.DataFrame: + """Build a deterministic features-shaped frame with ``n`` tracks. + + Bounded features are drawn from ``Beta(2, 5)`` so values stay strictly + inside ``(0, 1)`` (logit-safe), ``tempo`` from a log-normal centred + near 110 BPM, and ``loudness`` from a normal centred at -8 dB. + """ + rng = np.random.default_rng(seed) + rows: list[dict[str, object]] = [] + for i in range(n): + row: dict[str, object] = { + SPOTIFY_ID: f"sp{i:03d}", + TITLE: f"Track {i}", + ARTIST: f"Artist {i}", + } + for col in BOUNDED_FEATURES: + row[col] = float(rng.beta(2.0, 5.0)) + row["tempo"] = float(np.exp(rng.normal(np.log(110.0), 0.2))) + row["loudness"] = float(rng.normal(-8.0, 3.0)) + rows.append(row) + return pd.DataFrame(rows, columns=[SPOTIFY_ID, TITLE, ARTIST, *FEATURE_COLUMNS]) + + +# --------------------------------------------------------------------------- # +# Module-level constants # +# --------------------------------------------------------------------------- # + + +def test_bounded_features_constant_lists_the_seven_unit_interval_columns() -> None: + """The bounded-feature list is the seven ``[0, 1]``-ranged columns.""" + assert set(BOUNDED_FEATURES) == { + "acousticness", + "danceability", + "energy", + "instrumentalness", + "liveness", + "speechiness", + "valence", + } + + +def test_log_features_constant_lists_only_tempo() -> None: + """Only ``tempo`` is log-transformed; ``loudness`` is already in dB.""" + assert list(LOG_FEATURES) == ["tempo"] + + +# --------------------------------------------------------------------------- # +# Return shape # +# --------------------------------------------------------------------------- # + + +def test_prepare_matrix_returns_four_tuple_with_expected_types() -> None: + """Return shape is ``(X, index, scaler, transform_log)`` with the documented + types, per the plan.""" + features = _synthetic_features() + + X, index, scaler, log = prepare_matrix(features) + + assert isinstance(X, pd.DataFrame) + assert isinstance(index, pd.DataFrame) + assert isinstance(scaler, StandardScaler) + assert isinstance(log, TransformLog) + + +def test_x_has_all_nine_feature_columns_and_no_identity_columns() -> None: + """``X`` is the modelling matrix: nine numeric columns, no identity.""" + features = _synthetic_features() + + X, _, _, _ = prepare_matrix(features) + + assert list(X.columns) == FEATURE_COLUMNS + assert SPOTIFY_ID not in X.columns + assert TITLE not in X.columns + assert ARTIST not in X.columns + + +def test_identity_columns_survive_alongside_in_index_frame() -> None: + """The identity columns are carried in ``index`` so labels can be + re-attached later.""" + features = _synthetic_features(n=5) + + X, index, _, _ = prepare_matrix(features) + + assert list(index.columns) == [SPOTIFY_ID, TITLE, ARTIST] + assert len(index) == len(X) + # Row order is preserved so labels can be zipped back on by position. + pd.testing.assert_series_equal( + index[SPOTIFY_ID].reset_index(drop=True), + features[SPOTIFY_ID].reset_index(drop=True), + check_names=False, + ) + + +# --------------------------------------------------------------------------- # +# Transforms (logit, log, scale) # +# --------------------------------------------------------------------------- # + + +def test_bounded_columns_are_logit_transformed_before_scaling() -> None: + """Logit on each ``[0, 1]`` feature, then z-score. We can recover the + raw cell value by inverting the scaler and then the logit.""" + features = _synthetic_features(n=50) + + X, _, scaler, _ = prepare_matrix(features) + + # Inverse-z the scaled matrix back to the logit-space values. + bounded_idx = [FEATURE_COLUMNS.index(c) for c in BOUNDED_FEATURES] + unscaled = scaler.inverse_transform(X.to_numpy()) + for col in BOUNDED_FEATURES: + j = FEATURE_COLUMNS.index(col) + logit_vals = unscaled[:, j] + # Invert the logit and check it matches the original column values. + recovered = 1.0 / (1.0 + np.exp(-logit_vals)) + np.testing.assert_allclose( + recovered, features[col].to_numpy(), atol=1e-6 + ) + # Sanity: the bounded indices are exactly the columns we just covered. + assert len(bounded_idx) == len(BOUNDED_FEATURES) + + +def test_logit_clips_values_at_the_unit_boundaries() -> None: + """Raw 0 / 1 values would blow up under logit; preprocess must clip + them to a small epsilon first.""" + features = _synthetic_features(n=5) + # Force boundary values into one bounded column. + features.loc[0, "acousticness"] = 0.0 + features.loc[1, "acousticness"] = 1.0 + + X, _, _, _ = prepare_matrix(features) + + # Neither infinity nor NaN should appear after the transform. + assert np.isfinite(X["acousticness"].to_numpy()).all() + + +def test_tempo_is_log_transformed() -> None: + """``tempo`` goes through ``log`` before scaling.""" + features = _synthetic_features(n=50) + + X, _, scaler, _ = prepare_matrix(features) + + j = FEATURE_COLUMNS.index("tempo") + unscaled = scaler.inverse_transform(X.to_numpy()) + recovered = np.exp(unscaled[:, j]) + np.testing.assert_allclose(recovered, features["tempo"].to_numpy(), atol=1e-6) + + +def test_loudness_is_neither_logit_nor_log_transformed() -> None: + """``loudness`` is already in dB; we only z-score it. Recovering the + raw value should be a plain inverse-z, no exp / inverse-logit.""" + features = _synthetic_features(n=50) + + X, _, scaler, _ = prepare_matrix(features) + + j = FEATURE_COLUMNS.index("loudness") + unscaled = scaler.inverse_transform(X.to_numpy()) + np.testing.assert_allclose(unscaled[:, j], features["loudness"].to_numpy(), atol=1e-6) + + +def test_scaled_columns_have_zero_mean_and_unit_variance() -> None: + """After scaling, every modelling column has ~mean 0 and ~SD 1.""" + features = _synthetic_features(n=200) + + X, _, _, _ = prepare_matrix(features) + + means = X.mean(axis=0).to_numpy() + stds = X.std(axis=0, ddof=0).to_numpy() + np.testing.assert_allclose(means, np.zeros_like(means), atol=1e-9) + np.testing.assert_allclose(stds, np.ones_like(stds), atol=1e-9) + + +# --------------------------------------------------------------------------- # +# Missing-value handling # +# --------------------------------------------------------------------------- # + + +def test_median_imputation_fills_a_missing_cell_with_the_column_median() -> None: + """A single missing cell in a column is replaced with that column's + median (computed from the non-missing values).""" + features = _synthetic_features(n=20) + features.loc[3, "danceability"] = np.nan + # Median is computed from the non-missing values (pandas skips NaN). + expected_median = features["danceability"].median() + + X, _, scaler, log = prepare_matrix(features) + + j = FEATURE_COLUMNS.index("danceability") + unscaled = scaler.inverse_transform(X.to_numpy()) + recovered = 1.0 / (1.0 + np.exp(-unscaled[:, j])) + assert math.isclose(recovered[3], expected_median, abs_tol=1e-6) + assert log.imputed_counts.get("danceability", 0) == 1 + + +def test_row_with_more_than_three_missing_features_is_dropped() -> None: + """If >3 of the 9 features are missing on a row, it is dropped from + the modelling matrix and surfaced on the transform log.""" + features = _synthetic_features(n=10) + # Knock out four features on row 2 (>3 of 9). + for col in ["acousticness", "danceability", "energy", "instrumentalness"]: + features.loc[2, col] = np.nan + + X, index, _, log = prepare_matrix(features) + + assert len(X) == 9 + assert len(index) == 9 + assert "sp002" not in index[SPOTIFY_ID].tolist() + assert log.dropped_tracks[SPOTIFY_ID].tolist() == ["sp002"] + + +def test_row_with_exactly_three_missing_features_is_kept_and_imputed() -> None: + """The threshold is *strictly* greater than 3, so 3-of-9 still imputes.""" + features = _synthetic_features(n=10) + for col in ["acousticness", "danceability", "energy"]: + features.loc[4, col] = np.nan + + X, index, _, log = prepare_matrix(features) + + assert len(X) == 10 + assert "sp004" in index[SPOTIFY_ID].tolist() + assert log.dropped_tracks.empty + # Three imputations were recorded, one per knocked-out column. + assert sum(log.imputed_counts.values()) == 3 + + +def test_transform_log_records_logit_and_log_columns() -> None: + """The log lists which columns went through which transform so the + coverage report can explain it.""" + features = _synthetic_features(n=5) + + _, _, _, log = prepare_matrix(features) + + assert set(log.logit_columns) == set(BOUNDED_FEATURES) + assert list(log.log_columns) == ["tempo"] + + +def test_empty_features_frame_raises_value_error() -> None: + """An empty modelling matrix is a user-facing error, not a silent + success — there is nothing to cluster.""" + empty = pd.DataFrame(columns=[SPOTIFY_ID, TITLE, ARTIST, *FEATURE_COLUMNS]) + + with pytest.raises(ValueError, match="empty"): + prepare_matrix(empty) diff --git a/tests/test_cluster_public.py b/tests/test_cluster_public.py new file mode 100644 index 0000000..03468be --- /dev/null +++ b/tests/test_cluster_public.py @@ -0,0 +1,313 @@ +"""Tests for the public :func:`playlistsmith.cluster.cluster` entry point. + +This is the function downstream code (playlist export, viz) actually +calls. It wraps the whole pipeline: + +- :func:`prepare_matrix` (preprocessing + dropped/imputed accounting), +- :func:`fit_gmm` (or another method when the user asks), +- canonical cluster ordering (descending size, ties broken by PC1 + of the cluster mean) so cluster ``0`` means roughly the same thing + across re-runs, +- playlist-shape post-processing: small clusters collapse into an + ``Unclassified`` bucket (cluster id ``-1``); over-large clusters + emit a warning, +- :func:`describe_clusters` for human-readable labels. + +The tests construct deterministic synthetic features (no API calls) so +the right answer is known by construction. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from playlistsmith.cluster import cluster +from playlistsmith.cluster.algorithms import ClusteringResult +from playlistsmith.cluster.preprocess import TransformLog +from playlistsmith.cluster.public import ClusterPipelineResult +from playlistsmith.features.reccobeats import FEATURE_COLUMNS +from playlistsmith.io.csv_loader import ARTIST, SPOTIFY_ID, TITLE + + +def _synthetic_features( + cluster_sizes: tuple[int, ...] = (50, 50, 50), seed: int = 0 +) -> pd.DataFrame: + """A features frame with controllable blob sizes. + + Each blob has a distinctive centre across the bounded features so + GMM cleanly recovers the right ``k``. Identity columns are filled + with deterministic placeholder strings. + """ + rng = np.random.default_rng(seed) + rows: list[dict[str, object]] = [] + for blob_idx, n in enumerate(cluster_sizes): + # Bounded-feature centre and tempo/loudness shift unique to this blob. + bounded_mean = 0.1 + 0.2 * blob_idx + tempo_mean = 80.0 + 30.0 * blob_idx + loudness_mean = -12.0 + 4.0 * blob_idx + for _ in range(n): + row: dict[str, object] = { + SPOTIFY_ID: f"sp{len(rows):04d}", + TITLE: f"Track {len(rows)}", + ARTIST: f"Artist {len(rows) % 7}", + } + for col in ( + "acousticness", + "danceability", + "energy", + "instrumentalness", + "liveness", + "speechiness", + "valence", + ): + # Beta-ish centred near `bounded_mean`, clipped strictly inside (0, 1). + v = float(np.clip(rng.normal(bounded_mean, 0.05), 0.01, 0.99)) + row[col] = v + row["tempo"] = float(np.clip(rng.normal(tempo_mean, 3.0), 40.0, 200.0)) + row["loudness"] = float(rng.normal(loudness_mean, 1.0)) + rows.append(row) + return pd.DataFrame( + rows, columns=[SPOTIFY_ID, TITLE, ARTIST, *FEATURE_COLUMNS] + ) + + +# --------------------------------------------------------------------------- # +# Return shape # +# --------------------------------------------------------------------------- # + + +def test_returns_cluster_pipeline_result() -> None: + """The public entry point returns the documented dataclass.""" + features = _synthetic_features() + + out = cluster(features, random_state=0) + + assert isinstance(out, ClusterPipelineResult) + + +def test_pipeline_result_has_all_required_fields() -> None: + """Plan Section 5 contract.""" + features = _synthetic_features() + + out = cluster(features, random_state=0) + + assert isinstance(out.tracks, pd.DataFrame) + assert isinstance(out.descriptions, pd.DataFrame) + assert isinstance(out.clustering, ClusteringResult) + assert isinstance(out.transform_log, TransformLog) + assert isinstance(out.warnings, list) + + +def test_tracks_frame_has_identity_plus_cluster_plus_summary() -> None: + """One row per surviving track, with the identity columns and the + final cluster id + summary.""" + features = _synthetic_features() + + out = cluster(features, random_state=0) + + assert list(out.tracks.columns) == [ + SPOTIFY_ID, + TITLE, + ARTIST, + "cluster", + "cluster_summary", + ] + + +def test_track_count_equals_preprocessing_survivor_count() -> None: + """Tracks frame has one row per track that made it through + preprocessing (i.e. total minus rows dropped for >3 missing).""" + features = _synthetic_features(cluster_sizes=(50, 50, 50)) + + out = cluster(features, random_state=0) + + assert len(out.tracks) == len(features) - len(out.transform_log.dropped_tracks) + + +# --------------------------------------------------------------------------- # +# Method dispatch # +# --------------------------------------------------------------------------- # + + +def test_default_method_is_gmm() -> None: + """``CLAUDE.md``: prefer ``GaussianMixture``. Default ``method`` + must be GMM, and the raw clustering result reflects that.""" + features = _synthetic_features() + + out = cluster(features, random_state=0) + + # GMM is the only method that produces posteriors. + assert out.clustering.posteriors is not None + + +def test_unknown_method_raises_value_error() -> None: + """An unrecognised ``method`` is a user-facing error.""" + features = _synthetic_features() + + with pytest.raises(ValueError, match="method"): + cluster(features, method="not-a-real-method", random_state=0) + + +def test_kmeans_method_dispatches_to_fit_kmeans() -> None: + """``method='kmeans'`` runs through K-Means; ``posteriors`` is + ``None`` and ``covariance_type`` reflects the variant.""" + features = _synthetic_features() + + out = cluster(features, method="kmeans", random_state=0, k_range=range(2, 5)) + + assert out.clustering.posteriors is None + assert out.clustering.covariance_type == "kmeans" + + +def test_hdbscan_method_dispatches_to_fit_hdbscan() -> None: + """``method='hdbscan'`` runs through HDBSCAN; the noise-rate field + is populated and the variant marker reflects it.""" + features = _synthetic_features() + + out = cluster(features, method="hdbscan", random_state=0) + + assert out.clustering.covariance_type == "hdbscan" + assert 0.0 <= out.clustering.noise_rate <= 1.0 + + +# --------------------------------------------------------------------------- # +# Canonical cluster ordering # +# --------------------------------------------------------------------------- # + + +def test_clusters_are_relabelled_by_descending_size() -> None: + """Plan Section 5: cluster ``0`` is the biggest playlist.""" + features = _synthetic_features(cluster_sizes=(80, 40, 20)) + + # Constrain k to keep this test focused on the relabelling step, + # not on BIC over-segmenting the tight synthetic blobs. + out = cluster(features, random_state=0, k_range=range(2, 5)) + + sizes = out.tracks["cluster"].value_counts().sort_index() + # Drop the Unclassified bucket if present so we compare real clusters. + sizes = sizes[sizes.index >= 0] + # Non-increasing by cluster id. + sizes_list = sizes.tolist() + assert sizes_list == sorted(sizes_list, reverse=True) + + +def test_descriptions_index_aligns_with_relabelled_clusters() -> None: + """``descriptions`` references the same cluster ids that appear in + the ``tracks`` frame.""" + features = _synthetic_features() + + out = cluster(features, random_state=0) + + track_ids = set(out.tracks["cluster"].unique()) + desc_ids = set(out.descriptions["cluster"].unique()) + assert track_ids == desc_ids + + +# --------------------------------------------------------------------------- # +# Playlist-shape post-processing # +# --------------------------------------------------------------------------- # + + +def test_small_cluster_is_collapsed_into_unclassified_bucket() -> None: + """Clusters below ``min_playlist_size`` move to cluster id ``-1`` + rather than producing a useless 2-track playlist.""" + # Two large blobs and a tiny one. Place the tiny blob at extreme + # feature values so GMM gives it its own component, then test that + # the collapse step moves it into Unclassified. + features = _synthetic_features(cluster_sizes=(60, 60, 3)) + # Push the 3-track blob to an extreme corner of feature space. + tiny_idx = features.index[-3:] + for col in ( + "acousticness", + "danceability", + "energy", + "instrumentalness", + "liveness", + "speechiness", + "valence", + ): + features.loc[tiny_idx, col] = 0.95 + features.loc[tiny_idx, "tempo"] = 190.0 + features.loc[tiny_idx, "loudness"] = 0.0 + + out = cluster( + features, random_state=0, min_playlist_size=5, k_range=[3] + ) + + # The unclassified bucket exists in tracks and descriptions. + assert -1 in out.tracks["cluster"].tolist() + unclassified_size = int((out.tracks["cluster"] == -1).sum()) + assert unclassified_size >= 3 + assert -1 in out.descriptions["cluster"].tolist() + unclassified_summary = out.descriptions.set_index("cluster").loc[-1][ + "cluster_summary" if "cluster_summary" in out.descriptions.columns + else "summary" + ] + assert "unclassified" in str(unclassified_summary).lower() + + +def test_no_unclassified_bucket_when_all_clusters_meet_minimum() -> None: + """If every cluster meets ``min_playlist_size``, no ``-1`` rows are + introduced.""" + features = _synthetic_features(cluster_sizes=(40, 40, 40)) + + # Force k=3 so model selection cannot over-segment and create a + # tiny sub-cluster; this test is about the collapse step's no-op + # behaviour when every cluster is large enough. + out = cluster(features, random_state=0, min_playlist_size=5, k_range=[3]) + + assert -1 not in out.tracks["cluster"].tolist() + + +def test_all_tracks_unclassified_emits_warning() -> None: + """If post-processing collapses *every* track into the Unclassified + bucket, the result is useless — surface a warning so the user + knows to lower ``min_playlist_size`` or change method.""" + # 30 tracks; require playlists of at least 200. Everything collapses. + features = _synthetic_features(cluster_sizes=(10, 10, 10)) + + out = cluster( + features, random_state=0, min_playlist_size=200, k_range=range(2, 5) + ) + + assert all(c == -1 for c in out.tracks["cluster"].tolist()) + assert any( + "unclassified" in w.lower() or "no playlists" in w.lower() + for w in out.warnings + ) + + +def test_dominant_cluster_triggers_warning_but_is_not_split() -> None: + """A cluster holding more than ``max_playlist_share`` of the library + is flagged in ``warnings`` (plan: warn, don't auto-resplit).""" + # 200 tracks in one blob, 20 each in two others → biggest cluster ≈ 83%. + features = _synthetic_features(cluster_sizes=(200, 20, 20)) + + out = cluster(features, random_state=0, max_playlist_share=0.5) + + assert any("share" in w.lower() or "dominan" in w.lower() for w in out.warnings) + # The dominant cluster was not split — we still have at most as many + # clusters as we asked for. + assert out.clustering.k <= 12 + + +# --------------------------------------------------------------------------- # +# Reproducibility # +# --------------------------------------------------------------------------- # + + +def test_same_random_state_produces_same_pipeline_output() -> None: + """Threading ``random_state`` through the pipeline must be enough to + reproduce the labels.""" + features = _synthetic_features() + + out1 = cluster(features, random_state=42) + out2 = cluster(features, random_state=42) + + pd.testing.assert_series_equal( + out1.tracks["cluster"].reset_index(drop=True), + out2.tracks["cluster"].reset_index(drop=True), + check_names=False, + ) diff --git a/tests/test_cluster_viz_hooks.py b/tests/test_cluster_viz_hooks.py new file mode 100644 index 0000000..37e947b --- /dev/null +++ b/tests/test_cluster_viz_hooks.py @@ -0,0 +1,162 @@ +"""Tests for the visualisation hooks emitted by the cluster pipeline. + +Plan §6: the cluster step does not import any plotting library, but it +*does* hand the viz layer everything it needs to draw scatter plots, +heatmaps and selection diagnostics. The artefacts live on a +:class:`ClusterDiagnostics` bundle attached to +:class:`ClusterPipelineResult`: + +- ``pca_components`` — deterministic numeric coordinates for each + track in PCA space (cheap, used for the canonical cluster ordering + and as a fallback for plotting); +- ``projection_2d`` — a 2-D embedding for scatter plots (t-SNE; UMAP + can replace this later without changing the call sites); +- ``zprofile_heatmap`` — ``cluster × feature`` z-profile frame + consumed by heatmap viz; +- BIC / ICL / silhouette / inertia / CH curves are already on the + raw :class:`ClusteringResult` (Sections 1 and 2). + +These tests pin shapes and row-alignment with the tracks frame. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +from playlistsmith.cluster import cluster +from playlistsmith.cluster.public import ClusterDiagnostics +from playlistsmith.features.reccobeats import FEATURE_COLUMNS +from playlistsmith.io.csv_loader import ARTIST, SPOTIFY_ID, TITLE + + +def _synthetic_features( + cluster_sizes: tuple[int, ...] = (50, 50, 50), seed: int = 0 +) -> pd.DataFrame: + """Same as the synthetic generator used in the public-cluster tests.""" + rng = np.random.default_rng(seed) + rows: list[dict[str, object]] = [] + for blob_idx, n in enumerate(cluster_sizes): + bounded_mean = 0.1 + 0.2 * blob_idx + tempo_mean = 80.0 + 30.0 * blob_idx + loudness_mean = -12.0 + 4.0 * blob_idx + for _ in range(n): + row: dict[str, object] = { + SPOTIFY_ID: f"sp{len(rows):04d}", + TITLE: f"Track {len(rows)}", + ARTIST: f"Artist {len(rows) % 7}", + } + for col in ( + "acousticness", + "danceability", + "energy", + "instrumentalness", + "liveness", + "speechiness", + "valence", + ): + row[col] = float(np.clip(rng.normal(bounded_mean, 0.05), 0.01, 0.99)) + row["tempo"] = float(np.clip(rng.normal(tempo_mean, 3.0), 40.0, 200.0)) + row["loudness"] = float(rng.normal(loudness_mean, 1.0)) + rows.append(row) + return pd.DataFrame( + rows, columns=[SPOTIFY_ID, TITLE, ARTIST, *FEATURE_COLUMNS] + ) + + +# --------------------------------------------------------------------------- # +# Diagnostics bundle # +# --------------------------------------------------------------------------- # + + +def test_pipeline_result_carries_diagnostics_bundle() -> None: + """``ClusterPipelineResult.diagnostics`` exists and is the right type.""" + features = _synthetic_features() + + out = cluster(features, random_state=0, k_range=range(2, 5)) + + assert isinstance(out.diagnostics, ClusterDiagnostics) + + +# --------------------------------------------------------------------------- # +# PCA components # +# --------------------------------------------------------------------------- # + + +def test_pca_components_have_one_row_per_track() -> None: + """PCA coordinates are row-aligned with ``tracks`` so the viz layer + can colour by cluster id without re-indexing.""" + features = _synthetic_features() + + out = cluster(features, random_state=0, k_range=range(2, 5)) + + assert len(out.diagnostics.pca_components) == len(out.tracks) + + +def test_pca_components_have_principal_component_columns() -> None: + """Columns are ``pc1``, ``pc2``, ... so callers can pick the first + few without needing to know how many we computed.""" + features = _synthetic_features() + + out = cluster(features, random_state=0, k_range=range(2, 5)) + + cols = list(out.diagnostics.pca_components.columns) + assert cols[0] == "pc1" + assert cols[1] == "pc2" + assert all(c.startswith("pc") for c in cols) + + +def test_pca_explained_variance_ratios_are_recorded() -> None: + """The viz layer can label axes with explained-variance share.""" + features = _synthetic_features() + + out = cluster(features, random_state=0, k_range=range(2, 5)) + + evrs = out.diagnostics.pca_explained_variance_ratio + assert len(evrs) == out.diagnostics.pca_components.shape[1] + assert all(0.0 <= r <= 1.0 for r in evrs) + assert sum(evrs) <= 1.0 + 1e-9 + + +# --------------------------------------------------------------------------- # +# 2-D projection # +# --------------------------------------------------------------------------- # + + +def test_projection_2d_is_n_by_2() -> None: + """A scatter plot needs exactly two coordinates per track.""" + features = _synthetic_features() + + out = cluster(features, random_state=0, k_range=range(2, 5)) + + proj = out.diagnostics.projection_2d + assert proj.shape == (len(out.tracks), 2) + assert list(proj.columns) == ["dim1", "dim2"] + + +def test_projection_method_is_recorded() -> None: + """Callers (and docs) can tell whether they got t-SNE or UMAP.""" + features = _synthetic_features() + + out = cluster(features, random_state=0, k_range=range(2, 5)) + + assert out.diagnostics.projection_method in {"tsne", "umap"} + + +# --------------------------------------------------------------------------- # +# Heatmap frame # +# --------------------------------------------------------------------------- # + + +def test_zprofile_heatmap_is_k_by_nine_features() -> None: + """Heatmap-ready frame: one row per cluster id appearing in tracks + (including the Unclassified bucket if present), one column per + feature.""" + features = _synthetic_features() + + out = cluster(features, random_state=0, k_range=range(2, 5)) + + hm = out.diagnostics.zprofile_heatmap + cluster_ids = sorted(set(out.tracks["cluster"].tolist())) + assert list(hm.index) == cluster_ids + assert list(hm.columns) == list(FEATURE_COLUMNS) From 777d401a443fa0c5321a3e3003926e9f3eb015dd Mon Sep 17 00:00:00 2001 From: julianwuth Date: Sun, 24 May 2026 20:49:02 +0200 Subject: [PATCH 4/6] Properly install dependencies for Github workflow --- .github/workflows/python-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index a223aa3..4376f72 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -18,7 +18,7 @@ jobs: - name: Install package and test tools run: | python -m pip install --upgrade pip - python -m pip install -e . pytest + python -m pip install -e ".[dev]" - name: Run unit tests run: pytest \ No newline at end of file From 3416198f79ec9609b71fbf5d2e934936398065ac Mon Sep 17 00:00:00 2001 From: julianwuth Date: Mon, 25 May 2026 13:59:46 +0200 Subject: [PATCH 5/6] GUI & export functionality --- .gitignore | 5 +- pyproject.toml | 21 +- src/playlistsmith/_http.py | 15 ++ src/playlistsmith/cluster/public.py | 19 ++ src/playlistsmith/gui/__init__.py | 16 ++ src/playlistsmith/gui/app.py | 94 ++++++++ src/playlistsmith/gui/cli.py | 61 +++++ src/playlistsmith/gui/fixtures.py | 136 +++++++++++ src/playlistsmith/gui/state.py | 55 +++++ src/playlistsmith/gui/widgets/__init__.py | 6 + src/playlistsmith/gui/widgets/cluster.py | 223 ++++++++++++++++++ src/playlistsmith/gui/widgets/export.py | 147 ++++++++++++ src/playlistsmith/gui/widgets/extract.py | 72 ++++++ src/playlistsmith/gui/widgets/upload.py | 99 ++++++++ src/playlistsmith/gui/widgets/viz.py | 144 +++++++++++ src/playlistsmith/io/__init__.py | 7 +- src/playlistsmith/io/csv_loader.py | 33 ++- src/playlistsmith/io/playlist_export.py | 275 ++++++++++++++++++++++ tests/test_cluster_viz_hooks.py | 13 + tests/test_csv_loader.py | 29 +++ tests/test_gui_app_smoke.py | 104 ++++++++ tests/test_gui_fixtures.py | 67 ++++++ tests/test_gui_validate_name.py | 56 +++++ tests/test_playlist_export.py | 199 ++++++++++++++++ 24 files changed, 1889 insertions(+), 7 deletions(-) create mode 100644 src/playlistsmith/gui/__init__.py create mode 100644 src/playlistsmith/gui/app.py create mode 100644 src/playlistsmith/gui/cli.py create mode 100644 src/playlistsmith/gui/fixtures.py create mode 100644 src/playlistsmith/gui/state.py create mode 100644 src/playlistsmith/gui/widgets/__init__.py create mode 100644 src/playlistsmith/gui/widgets/cluster.py create mode 100644 src/playlistsmith/gui/widgets/export.py create mode 100644 src/playlistsmith/gui/widgets/extract.py create mode 100644 src/playlistsmith/gui/widgets/upload.py create mode 100644 src/playlistsmith/gui/widgets/viz.py create mode 100644 src/playlistsmith/io/playlist_export.py create mode 100644 tests/test_gui_app_smoke.py create mode 100644 tests/test_gui_fixtures.py create mode 100644 tests/test_gui_validate_name.py create mode 100644 tests/test_playlist_export.py diff --git a/.gitignore b/.gitignore index a4f1650..256f010 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,7 @@ .venv/ # implementation plans -plans/ \ No newline at end of file +plans/ + +# GUI output +playlistsmith_out/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 98c97f6..a2f0a21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,14 @@ dev = [ "pytest-httpx", "pytest-cov", ] +gui = [ + "streamlit>=1.33", + "plotly>=5.18", + "matplotlib", +] + +[project.scripts] +playlistsmith-gui = "playlistsmith.gui:main" [project.urls] Homepage = "https://github.com/Programming-The-Next-Step-2026/playlistsmith" @@ -46,6 +54,17 @@ Issues = "https://github.com/Programming-The-Next-Step-2026/playlistsmith/issues pythonpath = ["src"] testpaths = ["tests"] +[tool.mypy] +exclude = ["src/playlistsmith/gui/"] + [[tool.mypy.overrides]] module = ["sklearn.*", "umap", "umap.*"] -ignore_missing_imports = true \ No newline at end of file +ignore_missing_imports = true + +# The GUI subpackage is a thin Streamlit shell and pulls in untyped +# third-party libraries (plotly, parts of streamlit). It's excluded from +# the strict mypy run via `tool.mypy.exclude` above; this override also +# silences mypy if the exclusion is bypassed (e.g. `mypy `). +[[tool.mypy.overrides]] +module = ["playlistsmith.gui.*"] +ignore_errors = true \ No newline at end of file diff --git a/src/playlistsmith/_http.py b/src/playlistsmith/_http.py index 7332c83..a04a699 100644 --- a/src/playlistsmith/_http.py +++ b/src/playlistsmith/_http.py @@ -100,6 +100,21 @@ def __init__( _client: httpx.Client | None = None +def set_client(client: httpx.Client | None) -> None: + """Replace (or clear) the process-wide shared HTTP client. + + Used by demo mode and the test suite to swap the live client for one + backed by an :class:`httpx.MockTransport`. Passing ``None`` clears + the singleton; the next :func:`get_client` call will build a fresh + default client. + + Args: + client: The new client to install, or ``None`` to reset. + """ + global _client + _client = client + + def get_client() -> httpx.Client: """Return the process-wide shared HTTP client, creating it on first use. diff --git a/src/playlistsmith/cluster/public.py b/src/playlistsmith/cluster/public.py index f0861d0..6eb309b 100644 --- a/src/playlistsmith/cluster/public.py +++ b/src/playlistsmith/cluster/public.py @@ -77,6 +77,13 @@ class ClusterDiagnostics: ``dim2``. UMAP is the MIR standard (plan §6). projection_method: Which projection produced ``projection_2d`` (``"umap"`` here; ``"tsne"`` if we ever fall back). + projection_3d: ``(n_tracks × 3)`` PCA-3D embedding for the + optional 3-D scatter (plan §6.2). Row-aligned with + ``tracks``; columns ``pc1``, ``pc2``, ``pc3``. Fit on the + same z-scored modelling matrix used for clustering, so it + shares the axes the model actually saw. ``None`` when the + modelling matrix has fewer than three columns or rows (the + GUI greys the 3-D toggle out in that case). zprofile_heatmap: ``(n_clusters × n_features)`` z-profile frame indexed by cluster id (including ``-1`` for the Unclassified bucket if present). Columns are the canonical @@ -88,6 +95,7 @@ class ClusterDiagnostics: projection_2d: pd.DataFrame projection_method: str zprofile_heatmap: pd.DataFrame + projection_3d: pd.DataFrame | None = None @dataclass @@ -359,12 +367,23 @@ def _build_diagnostics( heatmap_rows, index=final_ids, columns=list(FEATURE_COLUMNS) ) + # 3-D PCA toggle (plan §6.2). Reuses the PCA fit above so the 3-D + # coordinates share the axes the canonical-ordering tiebreak uses. + projection_3d: pd.DataFrame | None + if n_components >= 3: + pca_3d = pca_df.iloc[:, :3].copy() + pca_3d.columns = ["pc1", "pc2", "pc3"] + projection_3d = pca_3d + else: + projection_3d = None + return ClusterDiagnostics( pca_components=pca_df, pca_explained_variance_ratio=[float(x) for x in pca.explained_variance_ratio_], projection_2d=projection_2d, projection_method="umap", zprofile_heatmap=heatmap, + projection_3d=projection_3d, ) diff --git a/src/playlistsmith/gui/__init__.py b/src/playlistsmith/gui/__init__.py new file mode 100644 index 0000000..4984d6e --- /dev/null +++ b/src/playlistsmith/gui/__init__.py @@ -0,0 +1,16 @@ +"""Streamlit GUI for playlistsmith. + +A thin shell over the package: every action it takes corresponds to one +public call into :mod:`playlistsmith`. Launch with the ``playlistsmith-gui`` +console script (installed by the ``gui`` optional dependency group): + +.. code-block:: bash + + pip install -e ".[gui]" + playlistsmith-gui # live mode (real ReccoBeats) + playlistsmith-gui --demo # offline mode (mock transport) +""" + +from playlistsmith.gui.cli import main + +__all__ = ["main"] diff --git a/src/playlistsmith/gui/app.py b/src/playlistsmith/gui/app.py new file mode 100644 index 0000000..67a060c --- /dev/null +++ b/src/playlistsmith/gui/app.py @@ -0,0 +1,94 @@ +"""Top-level Streamlit script for the playlistsmith GUI. + +Run via the ``playlistsmith-gui`` console script (or +``streamlit run path/to/app.py``). Streamlit launches its own subprocess +when invoked via the CLI, so this module re-installs the demo mock +transport at import time if :func:`playlistsmith.gui.cli.is_demo_mode` +indicates ``--demo`` was passed. +""" + +from __future__ import annotations + +import streamlit as st + +from playlistsmith.gui import cli, fixtures +from playlistsmith.gui.state import KEYS, reset_pipeline_state +from playlistsmith.gui.widgets import cluster, export, extract, upload, viz + +st.set_page_config( + page_title="playlistsmith", + page_icon=None, + layout="centered", + initial_sidebar_state="expanded", +) + + +def _bootstrap_demo_mode() -> None: + """Install the mock transport once per Streamlit session if requested.""" + if cli.is_demo_mode(): + st.session_state[KEYS.demo_mode] = True + if not st.session_state.get("__demo_mock_installed", False): + fixtures.install_mock_transport() + st.session_state["__demo_mock_installed"] = True + else: + st.session_state[KEYS.demo_mode] = False + + +def _sidebar() -> None: + """Global sidebar: demo badge, mode selector, reset button.""" + with st.sidebar: + st.title("playlistsmith") + if st.session_state.get(KEYS.demo_mode): + st.warning( + "**Demo mode** — running offline against the synthetic " + "ReccoBeats fixture." + ) + else: + st.caption("Live mode — feature lookups hit ReccoBeats over the network.") + + st.selectbox( + "Feature extraction mode", + options=["precomputed"], + index=0, + help="Only `precomputed` is supported today; the selector " + "stays so future modes drop in without UI churn.", + ) + + if st.button("Reset session", use_container_width=True): + reset_pipeline_state(st.session_state) + st.rerun() + + +def _step_indicator() -> None: + """Top-of-page breadcrumb showing the current pipeline stage.""" + stages = [ + ("Upload", KEYS.library), + ("Extract", KEYS.features_df), + ("Cluster", KEYS.cluster_result), + ("Export", KEYS.export_paths), + ] + current_idx = 0 + for i, (_, key) in enumerate(stages): + if st.session_state.get(key) is not None: + current_idx = i + 1 + crumb = " → ".join( + f"**{name}**" if i == min(current_idx, len(stages) - 1) + else (f"✓ {name}" if i < current_idx else name) + for i, (name, _) in enumerate(stages) + ) + st.caption(crumb) + + +def main() -> None: + """Render the whole single-page app top to bottom.""" + _bootstrap_demo_mode() + _sidebar() + _step_indicator() + upload.render() + extract.render() + cluster.render() + viz.render() + export.render() + + +main() diff --git a/src/playlistsmith/gui/cli.py b/src/playlistsmith/gui/cli.py new file mode 100644 index 0000000..6d8eb12 --- /dev/null +++ b/src/playlistsmith/gui/cli.py @@ -0,0 +1,61 @@ +"""Entry point for the ``playlistsmith-gui`` console script. + +Parses ``--demo`` (which installs the offline ReccoBeats mock transport +before any pipeline code runs) and then hands off to Streamlit's +in-process launcher pointed at :mod:`playlistsmith.gui.app`. +""" + +from __future__ import annotations + +import argparse +import importlib.resources +import os +import sys + + +_DEMO_ENV_VAR = "PLAYLISTSMITH_GUI_DEMO" + + +def main(argv: list[str] | None = None) -> None: + """Parse CLI args, install the demo mock if requested, run Streamlit. + + Args: + argv: Optional argument list (defaults to :data:`sys.argv` minus + the program name). The first ``--demo`` flag is consumed + here; every other argument is forwarded to Streamlit. + """ + parser = argparse.ArgumentParser( + prog="playlistsmith-gui", + description="Launch the playlistsmith Streamlit GUI.", + ) + parser.add_argument( + "--demo", + action="store_true", + help=( + "Run offline against the recorded ReccoBeats fixture. " + "No network calls are made." + ), + ) + args, streamlit_argv = parser.parse_known_args(argv) + + if args.demo: + # Set the env var so the Streamlit-spawned subprocess (which + # re-imports this package) can detect demo mode and re-install + # the mock transport in its own process. + os.environ[_DEMO_ENV_VAR] = "1" + from playlistsmith.gui import fixtures + + fixtures.install_mock_transport() + + app_path = importlib.resources.files("playlistsmith.gui") / "app.py" + + # Lazy import: streamlit is an optional dependency. + from streamlit.web import cli as stcli + + sys.argv = ["streamlit", "run", str(app_path), *streamlit_argv] + sys.exit(stcli.main()) + + +def is_demo_mode() -> bool: + """Return whether demo mode was requested on the CLI.""" + return os.environ.get(_DEMO_ENV_VAR) == "1" diff --git a/src/playlistsmith/gui/fixtures.py b/src/playlistsmith/gui/fixtures.py new file mode 100644 index 0000000..ebf77e9 --- /dev/null +++ b/src/playlistsmith/gui/fixtures.py @@ -0,0 +1,136 @@ +"""Offline ReccoBeats mock transport for the GUI's demo mode. + +Reuses the same synthetic-feature generator the vignette mocks +ReccoBeats with: every track's Spotify ID is a ``syn<18 digits>`` +string whose fourth character encodes a target cluster (``A``, ``B`` or +``C``); the mock returns deterministic synthetic audio features drawn +from per-cluster centres plus reproducible per-ID jitter. IDs that +don't match (e.g. ``synX...``) are reported as misses so the +``CoverageReport.dropped_tracks`` path is exercised end-to-end. + +The whole point of doing it this way (rather than checked-in JSON +fixtures) is that the same fixture also drives the vignette and the +GUI smoke test, so all three stay in sync with zero manual upkeep. +""" + +from __future__ import annotations + +import hashlib + +import httpx +import numpy as np + +from playlistsmith import _http +from playlistsmith.features.reccobeats import FEATURE_COLUMNS + +__all__ = [ + "CLUSTER_CENTRES", + "install_mock_transport", + "synthetic_features", +] + + +#: Cluster centres in raw ReccoBeats feature space. Loudness is in dB; +#: tempo in BPM; every other feature lives on [0, 1]. Picked so the +#: three clusters are clearly separable but not trivially so. +CLUSTER_CENTRES: dict[str, dict[str, float]] = { + "A": { # chill / acoustic + "acousticness": 0.80, "danceability": 0.40, "energy": 0.20, + "instrumentalness": 0.10, "liveness": 0.10, "loudness": -18.0, + "speechiness": 0.05, "tempo": 85.0, "valence": 0.55, + }, + "B": { # upbeat / dance + "acousticness": 0.15, "danceability": 0.80, "energy": 0.75, + "instrumentalness": 0.05, "liveness": 0.15, "loudness": -6.0, + "speechiness": 0.08, "tempo": 120.0, "valence": 0.75, + }, + "C": { # intense / electronic + "acousticness": 0.05, "danceability": 0.55, "energy": 0.92, + "instrumentalness": 0.50, "liveness": 0.20, "loudness": -4.0, + "speechiness": 0.06, "tempo": 150.0, "valence": 0.30, + }, +} + +_JITTER: dict[str, float] = { + "acousticness": 0.05, "danceability": 0.05, "energy": 0.05, + "instrumentalness": 0.05, "liveness": 0.04, "loudness": 1.5, + "speechiness": 0.02, "tempo": 4.0, "valence": 0.05, +} + +_UNIT_INTERVAL_FEATURES = frozenset( + { + "acousticness", "danceability", "energy", "instrumentalness", + "liveness", "speechiness", "valence", + } +) + + +def synthetic_features(spotify_id: str) -> dict[str, float] | None: + """Return deterministic synthetic features for one Spotify ID. + + Args: + spotify_id: The ``syn`` ID encoding a cluster. + + Returns: + A ``{feature: value}`` dict for known cluster letters, or + ``None`` for IDs the mock pretends not to know. + """ + if len(spotify_id) < 4 or not spotify_id.startswith("syn"): + return None + cluster_letter = spotify_id[3] + if cluster_letter not in CLUSTER_CENTRES: + return None + centre = CLUSTER_CENTRES[cluster_letter] + seed = int(hashlib.sha1(spotify_id.encode()).hexdigest()[:8], 16) + rng = np.random.default_rng(seed) + out: dict[str, float] = {} + for col in FEATURE_COLUMNS: + val = float(centre[col] + rng.normal(0.0, _JITTER[col])) + if col in _UNIT_INTERVAL_FEATURES: + val = float(np.clip(val, 0.0, 1.0)) + out[col] = val + return out + + +def _handle(request: httpx.Request) -> httpx.Response: + """``httpx.MockTransport`` handler for ReccoBeats endpoints.""" + ids = [s for s in request.url.params.get("ids", "").split(",") if s] + if request.url.path == "/v1/track": + content = [ + {"id": f"rb-{sid}", "href": f"https://open.spotify.com/track/{sid}"} + for sid in ids + if synthetic_features(sid) is not None + ] + return httpx.Response(200, json={"content": content}) + if request.url.path == "/v1/audio-features": + content = [] + for rb_id in ids: + if not rb_id.startswith("rb-"): + continue + sid = rb_id[3:] + feats = synthetic_features(sid) + if feats is None: + continue + content.append({"id": rb_id, **feats}) + return httpx.Response(200, json={"content": content}) + return httpx.Response(404, json={"content": []}) + + +def install_mock_transport() -> httpx.Client: + """Install a mock ``httpx.Client`` on the shared HTTP singleton. + + After this returns, every call routed through + :mod:`playlistsmith._http` is served by the in-process synthetic + ReccoBeats handler — no network access required. Idempotent: calling + it twice simply re-installs a fresh mock client. + + Returns: + The installed mock client (also stored on + ``playlistsmith._http._client``). + """ + client = httpx.Client( + transport=httpx.MockTransport(_handle), + headers={"User-Agent": "playlistsmith-gui/demo"}, + ) + _http.set_client(client) + return client diff --git a/src/playlistsmith/gui/state.py b/src/playlistsmith/gui/state.py new file mode 100644 index 0000000..ff5dad6 --- /dev/null +++ b/src/playlistsmith/gui/state.py @@ -0,0 +1,55 @@ +"""Session-state helpers for the Streamlit GUI. + +Centralises the keys used in :data:`streamlit.session_state` so widgets +can read/write without typo'd string keys, and so "Reset session" knows +exactly what to clear. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +#: Marker the upload widget uses to detect whether the currently-attached +#: file is one it has already loaded. Lives here (not in widgets/upload.py) +#: so :func:`reset_pipeline_state` can wipe it from a central place. +UPLOAD_IDENTITY_KEY = "_upload_identity" + + +@dataclass(frozen=True) +class Keys: + """The session-state slots used by the GUI.""" + + library: str = "library" + library_filename: str = "library_filename" + features_df: str = "features_df" + coverage: str = "coverage" + cluster_result: str = "cluster_result" + export_paths: str = "export_paths" + demo_mode: str = "demo_mode" + + +KEYS = Keys() + + +def reset_pipeline_state(session_state) -> None: # type: ignore[no-untyped-def] + """Clear every pipeline slot in ``session_state`` except demo mode. + + Used by the "Reset session" button and whenever an upstream stage + (e.g. a new CSV upload) invalidates the downstream ones. Also clears + the upload-identity marker so a still-attached file in the uploader + triggers a fresh load on the next rerun. + + Args: + session_state: The :data:`streamlit.session_state` object. + """ + for key in ( + KEYS.library, + KEYS.library_filename, + KEYS.features_df, + KEYS.coverage, + KEYS.cluster_result, + KEYS.export_paths, + UPLOAD_IDENTITY_KEY, + ): + if key in session_state: + del session_state[key] diff --git a/src/playlistsmith/gui/widgets/__init__.py b/src/playlistsmith/gui/widgets/__init__.py new file mode 100644 index 0000000..d47fb6c --- /dev/null +++ b/src/playlistsmith/gui/widgets/__init__.py @@ -0,0 +1,6 @@ +"""Streamlit widget modules for the playlistsmith GUI. + +Each module owns one stage of the vertical-flow page (upload, extract, +cluster, viz, export) and exposes a single ``render(...)`` function the +top-level :mod:`playlistsmith.gui.app` script calls in order. +""" diff --git a/src/playlistsmith/gui/widgets/cluster.py b/src/playlistsmith/gui/widgets/cluster.py new file mode 100644 index 0000000..8efd56d --- /dev/null +++ b/src/playlistsmith/gui/widgets/cluster.py @@ -0,0 +1,223 @@ +"""Clustering stage of the GUI. + +Renders the method selector and method-specific hyperparameters, runs +:func:`playlistsmith.cluster` on click, and surfaces the quality panel +(BIC curve where applicable, silhouette/cohesion, cluster sizes, +z-profile heatmap, post-processing warnings). +""" + +from __future__ import annotations + +import pandas as pd +import streamlit as st + +import playlistsmith as ps +from playlistsmith.gui.state import KEYS + +#: Maps the package-level method id to the display label the user sees. +_METHOD_LABELS: dict[str, str] = { + "gmm": "GMM", + "kmeans": "K-means", + "hdbscan": "HDBSCAN", +} +_METHODS: tuple[str, ...] = tuple(_METHOD_LABELS.keys()) + +#: Tooltip text for the method selector itself. +_METHOD_HELP = ( + "**GMM** (default): soft, probabilistic assignments; BIC picks k. Best " + "for libraries with overlapping content.\n\n" + "**K-means**: hard partition; tight, equal-sized blobs. Faster, no " + "posteriors. Good when clusters are well separated.\n\n" + "**HDBSCAN**: density-based, no `k`. Discovers the number of clusters " + "and routes outliers to Unclassified. Best when some tracks really " + "shouldn't belong to any playlist." +) + +#: Tooltip text for every hyperparameter widget. +_HELP_MIN_PLAYLIST_SIZE = ( + "Smallest size a final cluster is allowed to keep. Clusters smaller " + "than this are collapsed into the **Unclassified** bucket (cluster " + "-1) so you don't end up with two-track playlists. Raise it to force " + "larger, fewer playlists; lower it to keep small niche groups." +) +_HELP_MAX_PLAYLIST_SHARE = ( + "Maximum share of the library any one cluster is allowed to hold " + "before the pipeline warns you. The pipeline does **not** auto-split " + "— it surfaces a warning so you can re-run with a larger k or switch " + "to HDBSCAN. Lower it to be stricter about dominant clusters." +) +_HELP_K_RANGE = ( + "Range of candidate cluster counts to evaluate. GMM picks the best k " + "by BIC inside this range; K-means picks by silhouette. Wider range " + "= more candidates evaluated (slower); narrower = faster but you " + "might miss the best k." +) +_HELP_HDBSCAN_MIN_CLUSTER_SIZE = ( + "Smallest group of tracks HDBSCAN is allowed to call a cluster. " + "Raise it to get fewer, larger clusters and more Unclassified " + "outliers; lower it to discover smaller niches at the cost of more " + "fragmentation." +) +_HELP_HDBSCAN_MIN_SAMPLES = ( + "How conservative HDBSCAN is about declaring core points. Higher " + "values produce more outliers (Unclassified) and more conservative " + "clusters. 0 means \"use the library default\" (= min_cluster_size)." +) + + +def _format_method(method_id: str) -> str: + """Return the human-facing label for a method id (used by selectbox).""" + return _METHOD_LABELS.get(method_id, method_id) + + +def _reset_cluster_state() -> None: + """Clear the cluster + export slots — e.g. after changing method.""" + for key in (KEYS.cluster_result, KEYS.export_paths): + if key in st.session_state: + del st.session_state[key] + + +def _on_method_change() -> None: + """Selectbox ``on_change`` callback: invalidate stale fit + export.""" + _reset_cluster_state() + + +def _render_method_params(method: str, n_tracks: int) -> dict[str, object]: + """Render method-specific widgets and collect their values.""" + params: dict[str, object] = {} + k_upper = max(3, min(12, n_tracks - 1)) + + if method in ("gmm", "kmeans"): + k_min, k_max = st.slider( + "k range (candidate cluster counts)", + min_value=2, + max_value=max(3, k_upper), + value=(2, min(8, k_upper)), + step=1, + help=_HELP_K_RANGE, + ) + params["k_range"] = range(k_min, k_max + 1) + elif method == "hdbscan": + params["hdbscan_min_cluster_size"] = st.number_input( + "hdbscan_min_cluster_size", + min_value=2, + max_value=max(2, n_tracks), + value=max(2, min(5, n_tracks // 4)), + step=1, + help=_HELP_HDBSCAN_MIN_CLUSTER_SIZE, + ) + params["hdbscan_min_samples"] = st.number_input( + "hdbscan_min_samples (0 = use default)", + min_value=0, + max_value=max(1, n_tracks), + value=0, + step=1, + help=_HELP_HDBSCAN_MIN_SAMPLES, + ) or None + return params + + +def _render_quality_panel(result: "ps.ClusterPipelineResult") -> None: + """Render the BIC / silhouette / cluster sizes / heatmap block.""" + clustering = result.clustering + cols = st.columns(3) + cols[0].metric("k chosen", clustering.k) + cols[1].metric("silhouette", f"{clustering.silhouette:.3f}") + cols[2].metric("cohesion", f"{clustering.cohesion:.3f}") + + bic_curve = getattr(clustering, "bic_curve", None) or {} + if bic_curve: + bic_df = pd.DataFrame( + {"k": list(bic_curve.keys()), "BIC": list(bic_curve.values())} + ).set_index("k") + st.line_chart(bic_df, height=200) + + sizes = ( + result.tracks.groupby("cluster").size().rename("size").to_frame() + ) + st.bar_chart(sizes, height=200) + + heatmap = result.diagnostics.zprofile_heatmap + # Streamlit's default ~5-row dataframe viewport hides clusters past the + # third one; size the widget to fit every row of the heatmap plus the + # header so the whole thing reads at a glance. + st.markdown("**Per-cluster z-profile heatmap**") + st.dataframe( + heatmap.style.background_gradient( + cmap="coolwarm", axis=None, vmin=-2, vmax=2 + ), + use_container_width=True, + height=int(38 * (len(heatmap) + 1) + 8), + ) + + for w in result.warnings: + st.warning(w) + + +def render() -> None: + """Render the cluster stage if features are present.""" + features_df = st.session_state.get(KEYS.features_df) + if features_df is None or len(features_df) == 0: + return + + st.header("3. Cluster") + st.caption( + "GMM is preferred (soft assignments, BIC for k); K-means and " + "HDBSCAN are available for hard / density-based needs." + ) + + method = st.selectbox( + "Method", + _METHODS, + index=0, + format_func=_format_method, + key="cluster_method", + on_change=_on_method_change, + help=_METHOD_HELP, + ) + + common_cols = st.columns(2) + with common_cols[0]: + min_playlist_size = st.slider( + "min_playlist_size", + min_value=1, + max_value=50, + value=5, + step=1, + help=_HELP_MIN_PLAYLIST_SIZE, + ) + with common_cols[1]: + max_playlist_share = st.slider( + "max_playlist_share", + min_value=0.1, + max_value=1.0, + value=0.5, + step=0.05, + help=_HELP_MAX_PLAYLIST_SHARE, + ) + + params = _render_method_params(method, n_tracks=len(features_df)) + + if st.button("Cluster", type="primary"): + with st.spinner(f"Clustering with {_format_method(method)}…"): + try: + result = ps.cluster( + features_df, + method=method, + random_state=0, + min_playlist_size=min_playlist_size, + max_playlist_share=max_playlist_share, + **params, + ) + except (ValueError, NotImplementedError) as exc: + st.error(f"Clustering failed: {exc}") + return + st.session_state[KEYS.cluster_result] = result + if KEYS.export_paths in st.session_state: + del st.session_state[KEYS.export_paths] + + result = st.session_state.get(KEYS.cluster_result) + if result is None: + return + + _render_quality_panel(result) diff --git a/src/playlistsmith/gui/widgets/export.py b/src/playlistsmith/gui/widgets/export.py new file mode 100644 index 0000000..e87df34 --- /dev/null +++ b/src/playlistsmith/gui/widgets/export.py @@ -0,0 +1,147 @@ +"""Export stage of the GUI. + +Lets the user rename clusters (one row per real cluster, Unclassified +shown read-only), validates names inline, and on click writes one CSV +per cluster via :func:`playlistsmith.io.playlist_export.write_cluster_csvs`. +Each written CSV is also offered as an ``st.download_button`` so users +on a remote Streamlit deployment can grab the files without shell +access. +""" + +from __future__ import annotations + +from pathlib import Path + +import pandas as pd +import streamlit as st + +from playlistsmith.io import playlist_export +from playlistsmith.io.playlist_export import validate_name +from playlistsmith.gui.state import KEYS + +_UNCLASSIFIED_LABEL = -1 + + +def _build_editor_frame(result) -> pd.DataFrame: # type: ignore[no-untyped-def] + """Build the cluster / size / summary / name table for the editor.""" + sizes = result.tracks.groupby("cluster").size().rename("size") + df = result.descriptions[["cluster", "cluster_summary"]].copy() + df["size"] = df["cluster"].map(sizes).fillna(0).astype(int) + df["name"] = df["cluster"].apply( + lambda c: "Unclassified" if int(c) == _UNCLASSIFIED_LABEL + else f"Cluster {int(c)}" + ) + return df[["cluster", "size", "cluster_summary", "name"]].reset_index(drop=True) + + +def _validate_naming(edited: pd.DataFrame) -> tuple[dict[int, str], list[str]]: + """Pull the user-edited names out and check them. + + Returns: + A ``(naming, errors)`` tuple: ``naming`` is the validated + ``{cluster_id: name}`` mapping for real clusters only; ``errors`` + is a list of human-readable error strings (empty when valid). + """ + errors: list[str] = [] + naming: dict[int, str] = {} + real_rows = edited[edited["cluster"] != _UNCLASSIFIED_LABEL] + for _, row in real_rows.iterrows(): + cid = int(row["cluster"]) + name = str(row["name"]) + if not validate_name(name): + errors.append( + f"Cluster {cid}: {name!r} is not a valid filename — " + "avoid /, \\, :, *, ?, \", <, >, |, and leading/trailing dots." + ) + naming[cid] = name.strip() + counts: dict[str, int] = {} + for n in naming.values(): + counts[n] = counts.get(n, 0) + 1 + duplicates = [n for n, c in counts.items() if c > 1] + for n in duplicates: + errors.append(f"Playlist name {n!r} is used by more than one cluster.") + return naming, errors + + +def render() -> None: + """Render the export stage if a clustering result is present.""" + result = st.session_state.get(KEYS.cluster_result) + if result is None: + return + + st.header("5. Export playlists") + st.caption( + "Each cluster becomes one CSV with `Track URI`, `Track Name`, " + "`Artist Name(s)` and the audio features. Rename the playlists " + "below — the names become filenames." + ) + + base_dir_str = st.text_input( + "Output directory (server-side path)", + value=str(Path.cwd() / "playlistsmith_out"), + ) + include_unclassified = st.checkbox( + "Also write Unclassified.csv (cluster -1)", value=False + ) + write_combined = st.checkbox( + "Also write a combined playlists.csv (one file, all clusters)", + value=False, + ) + + editor_frame = _build_editor_frame(result) + edited = st.data_editor( + editor_frame, + key="export_name_editor", + hide_index=True, + disabled=("cluster", "size", "cluster_summary"), + column_config={ + "cluster": st.column_config.NumberColumn("Cluster", width="small"), + "size": st.column_config.NumberColumn("Size", width="small"), + "cluster_summary": st.column_config.TextColumn("Auto description"), + "name": st.column_config.TextColumn( + "Playlist name (filename)", required=True + ), + }, + use_container_width=True, + ) + + naming, errors = _validate_naming(edited) + for err in errors: + st.error(err) + + disabled = bool(errors) + if st.button("Write CSVs", type="primary", disabled=disabled): + out_dir = Path(base_dir_str).expanduser() + try: + paths = playlist_export.write_cluster_csvs( + result, + output_dir=out_dir, + naming=naming, + features_df=st.session_state.get(KEYS.features_df), + include_unclassified=include_unclassified, + write_combined=write_combined, + ) + except (ValueError, OSError) as exc: + st.error(f"Could not write CSVs: {exc}") + return + st.session_state[KEYS.export_paths] = paths + st.success(f"Wrote {len(paths)} file(s) to {out_dir}.") + + paths: list[Path] | None = st.session_state.get(KEYS.export_paths) + if not paths: + return + + st.subheader("Download") + for path in paths: + try: + data = path.read_bytes() + except OSError as exc: + st.error(f"Could not read {path}: {exc}") + continue + st.download_button( + label=f"Download {path.name}", + data=data, + file_name=path.name, + mime="text/csv", + key=f"dl_{path.name}", + ) diff --git a/src/playlistsmith/gui/widgets/extract.py b/src/playlistsmith/gui/widgets/extract.py new file mode 100644 index 0000000..269a7df --- /dev/null +++ b/src/playlistsmith/gui/widgets/extract.py @@ -0,0 +1,72 @@ +"""Feature-extraction stage of the GUI. + +One "Extract features" button. While running, an ``st.spinner`` covers +the wait; when done, the one-line :meth:`CoverageReport.render` summary +and an expandable dropped-tracks table are shown. The empty-result +edge case (zero resolved tracks) is surfaced as an ``st.info`` rather +than silently letting the clustering stage stay invisible. +""" + +from __future__ import annotations + +import streamlit as st + +from playlistsmith import TrackLibrary +from playlistsmith.gui.state import KEYS + + +def render() -> None: + """Render the extract stage if a library is loaded.""" + library: TrackLibrary | None = st.session_state.get(KEYS.library) + if library is None: + return + + st.header("2. Extract features") + st.caption( + "Look up audio features for each track via ReccoBeats " + "(`precomputed` mode). Tracks ReccoBeats does not know are " + "dropped and listed below." + ) + + already_extracted = st.session_state.get(KEYS.features_df) is not None + button_label = "Re-extract features" if already_extracted else "Extract features" + + if st.button(button_label): + with st.spinner("Extracting features…"): + try: + features_df, coverage = library.extract_features( + mode="precomputed" + ) + except Exception as exc: # noqa: BLE001 — surface every error. + st.error(f"Feature extraction failed: {exc}") + return + st.session_state[KEYS.features_df] = features_df + st.session_state[KEYS.coverage] = coverage + # Invalidate downstream stages. + for key in (KEYS.cluster_result, KEYS.export_paths): + if key in st.session_state: + del st.session_state[key] + + coverage = st.session_state.get(KEYS.coverage) + features_df = st.session_state.get(KEYS.features_df) + if coverage is None or features_df is None: + return + + st.info(coverage.render()) + if bool(st.session_state.get(KEYS.demo_mode)): + st.caption( + "Running against the recorded ReccoBeats fixture; results " + "reflect only the example library." + ) + + if not coverage.dropped_tracks.empty: + with st.expander( + f"Dropped tracks ({len(coverage.dropped_tracks)})", expanded=False + ): + st.dataframe(coverage.dropped_tracks, use_container_width=True) + + if len(features_df) == 0: + st.info( + f"0 / {coverage.total} tracks resolved — try a different CSV, " + "or enable demo mode to explore with the example tracklist." + ) diff --git a/src/playlistsmith/gui/widgets/upload.py b/src/playlistsmith/gui/widgets/upload.py new file mode 100644 index 0000000..91a356a --- /dev/null +++ b/src/playlistsmith/gui/widgets/upload.py @@ -0,0 +1,99 @@ +"""Upload widget: CSV file_uploader + TrackLibrary preview. + +In demo mode the widget surfaces a one-click "Load example tracklist" +button that points at ``docs/example_synthetic.csv`` (the synthetic file +the vignette uses), so a first-time user can see the full pipeline run +without leaving the GUI. +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import streamlit as st + +from playlistsmith import TrackLibrary +from playlistsmith.gui.state import KEYS, UPLOAD_IDENTITY_KEY, reset_pipeline_state + +#: Path to the synthetic example CSV (lives at ``docs/example_synthetic.csv``). +#: Resolved relative to the repo root, located by walking up from this file. +_REPO_ROOT = Path(__file__).resolve().parents[4] +EXAMPLE_CSV_PATH = _REPO_ROOT / "docs" / "example_synthetic.csv" + + +def _load_library_from_path(path: Path, *, filename: str, identity: str) -> None: + """Construct a :class:`TrackLibrary` and stash it in session state.""" + try: + library = TrackLibrary(path) + except (FileNotFoundError, ValueError) as exc: + st.error(f"Could not load CSV: {exc}") + return + reset_pipeline_state(st.session_state) + st.session_state[KEYS.library] = library + st.session_state[KEYS.library_filename] = filename + st.session_state[UPLOAD_IDENTITY_KEY] = identity + + +def _upload_identity(uploaded) -> str: # type: ignore[no-untyped-def] + """A stable identity for an UploadedFile across reruns. + + Streamlit assigns each upload a ``file_id`` that persists for the + lifetime of the upload widget's value. We fall back to ``name`` + + ``size`` if a future Streamlit version drops ``file_id``. + """ + file_id = getattr(uploaded, "file_id", None) + if file_id is not None: + return f"upload:{file_id}" + return f"upload:{uploaded.name}:{uploaded.size}" + + +def render() -> None: + """Render the upload stage; populates ``st.session_state[library]``.""" + st.header("1. Upload your tracklist") + st.caption( + "Export your Spotify playlists as CSV using " + "[Exportify](https://exportify.app), then upload the file here." + ) + + uploaded = st.file_uploader("Exportify CSV", type=["csv"]) + demo_mode = bool(st.session_state.get(KEYS.demo_mode)) + + if demo_mode and EXAMPLE_CSV_PATH.exists(): + st.caption( + "**Demo mode:** click below to load the bundled synthetic " + "tracklist — the same one the vignette uses." + ) + if st.button("Load example tracklist", type="primary"): + _load_library_from_path( + EXAMPLE_CSV_PATH, + filename=EXAMPLE_CSV_PATH.name, + identity=f"example:{EXAMPLE_CSV_PATH}", + ) + + if uploaded is not None: + identity = _upload_identity(uploaded) + # Only reload when the upload actually changed. Without this, a + # rerun triggered by clicking *any* downstream button (Extract, + # Cluster, Write CSVs, …) would re-load the library and + # reset_pipeline_state would silently wipe the features. + if st.session_state.get(UPLOAD_IDENTITY_KEY) != identity: + with tempfile.NamedTemporaryFile( + suffix=".csv", delete=False + ) as tmp: + tmp.write(uploaded.getvalue()) + tmp_path = Path(tmp.name) + _load_library_from_path( + tmp_path, filename=uploaded.name, identity=identity + ) + + library: TrackLibrary | None = st.session_state.get(KEYS.library) + if library is None: + return + + st.success( + f"Loaded **{st.session_state.get(KEYS.library_filename, '?')}** — " + f"{len(library)} track(s) with a Spotify ID." + ) + with st.expander("Preview tracklist (first 20 rows)", expanded=False): + st.dataframe(library.dataframe.head(20), use_container_width=True) diff --git a/src/playlistsmith/gui/widgets/viz.py b/src/playlistsmith/gui/widgets/viz.py new file mode 100644 index 0000000..8214cf9 --- /dev/null +++ b/src/playlistsmith/gui/widgets/viz.py @@ -0,0 +1,144 @@ +"""Visualisation stage of the GUI. + +Primary view: UMAP-2D scatter (always available, from +``diagnostics.projection_2d``). Optional toggle: PCA-3D scatter when +the cluster module emits ``diagnostics.projection_3d`` (plan §6.2). +For very small libraries (fewer than three features or rows after +preprocessing) the 3-D projection is not produced and the toggle is +greyed out with an explanatory tooltip. +""" + +from __future__ import annotations + +import pandas as pd +import plotly.express as px +import streamlit as st + +from playlistsmith.gui.state import KEYS + +_UNCLASSIFIED_LABEL = -1 +_UNCLASSIFIED_COLOR = "#999999" +_UNCLASSIFIED_LEGEND = "Unclassified" + + +def _cluster_label(cluster_id: int) -> str: + """Pretty cluster label used both in legend keys and color mapping.""" + if int(cluster_id) == _UNCLASSIFIED_LABEL: + return _UNCLASSIFIED_LEGEND + return f"Cluster {int(cluster_id)}" + + +def _hover_columns(result) -> pd.DataFrame: # type: ignore[no-untyped-def] + """Row-aligned per-track columns that drive the hovertemplate.""" + tracks = result.tracks.reset_index(drop=True) + return tracks[["title", "artist", "cluster", "cluster_summary"]] + + +def _add_cluster_label_column(df: pd.DataFrame) -> pd.DataFrame: + df["cluster_label"] = df["cluster"].apply(_cluster_label) + return df + + +def _render_2d(result) -> None: # type: ignore[no-untyped-def] + proj = result.diagnostics.projection_2d.reset_index(drop=True) + df = _add_cluster_label_column( + pd.concat([proj, _hover_columns(result)], axis=1) + ) + fig = px.scatter( + df, + x="dim1", + y="dim2", + color="cluster_label", + color_discrete_map={_UNCLASSIFIED_LEGEND: _UNCLASSIFIED_COLOR}, + custom_data=["title", "artist", "cluster", "cluster_summary"], + height=520, + ) + fig.update_traces( + marker=dict(size=9, opacity=0.85, line=dict(width=0)), + hovertemplate=( + "%{customdata[0]}
" + "%{customdata[1]}
" + "Cluster %{customdata[2]}: %{customdata[3]}" + ), + ) + fig.update_layout( + xaxis_title="UMAP dim 1", + yaxis_title="UMAP dim 2", + legend_title="", + ) + st.plotly_chart(fig, use_container_width=True) + + +def _render_3d(result) -> None: # type: ignore[no-untyped-def] + proj = result.diagnostics.projection_3d.reset_index(drop=True) + df = _add_cluster_label_column( + pd.concat([proj, _hover_columns(result)], axis=1) + ) + fig = px.scatter_3d( + df, + x="pc1", + y="pc2", + z="pc3", + color="cluster_label", + color_discrete_map={_UNCLASSIFIED_LEGEND: _UNCLASSIFIED_COLOR}, + custom_data=["title", "artist", "cluster", "cluster_summary"], + height=620, + ) + fig.update_traces( + marker=dict(size=5, opacity=0.85, line=dict(width=0)), + hovertemplate=( + "%{customdata[0]}
" + "%{customdata[1]}
" + "Cluster %{customdata[2]}: %{customdata[3]}" + ), + ) + fig.update_layout( + scene=dict( + xaxis_title="PC 1", + yaxis_title="PC 2", + zaxis_title="PC 3", + ), + legend_title="", + ) + st.plotly_chart(fig, use_container_width=True) + + +def render() -> None: + """Render the visualisation toggle if a clustering result is available.""" + result = st.session_state.get(KEYS.cluster_result) + if result is None: + return + + st.header("4. Visualise") + + has_3d = result.diagnostics.projection_3d is not None + view_options = ["UMAP (2-D)"] + if has_3d: + view_options.append("PCA (3-D)") + + view = st.radio( + "Projection", + view_options, + index=0, + horizontal=True, + help=( + None + if has_3d + else "PCA-3D needs at least three feature columns; this library is too small." + ), + ) + + if view == "UMAP (2-D)": + st.caption( + f"UMAP projection ({result.diagnostics.projection_method.upper()}). " + "Hover for track title, artist, and cluster summary." + ) + _render_2d(result) + else: + ev = result.diagnostics.pca_explained_variance_ratio[:3] + st.caption( + "PCA projection on the z-scored modelling matrix " + f"(first three components explain {sum(ev):.1%} of variance). " + "Drag to rotate; hover for track title, artist, and cluster summary." + ) + _render_3d(result) diff --git a/src/playlistsmith/io/__init__.py b/src/playlistsmith/io/__init__.py index a7be273..13042b9 100644 --- a/src/playlistsmith/io/__init__.py +++ b/src/playlistsmith/io/__init__.py @@ -1,9 +1,12 @@ """I/O subpackage: CSV loading and playlist export. Exposes :class:`~playlistsmith.io.csv_loader.TrackLibrary` for reading a -track CSV (Exportify-compatible layout) into a tidy DataFrame. +track CSV (Exportify-compatible layout) and +:mod:`~playlistsmith.io.playlist_export` for writing one CSV per +cluster after the pipeline runs. """ +from playlistsmith.io import playlist_export from playlistsmith.io.csv_loader import TrackLibrary -__all__ = ["TrackLibrary"] +__all__ = ["TrackLibrary", "playlist_export"] diff --git a/src/playlistsmith/io/csv_loader.py b/src/playlistsmith/io/csv_loader.py index 330e91a..5a0ca05 100644 --- a/src/playlistsmith/io/csv_loader.py +++ b/src/playlistsmith/io/csv_loader.py @@ -92,14 +92,41 @@ def _load(path: Path) -> pd.DataFrame: if not path.is_file(): raise FileNotFoundError(f"Exportify CSV not found: {path}") - raw = pd.read_csv(path, sep=";", dtype=str) - + # Exportify has shipped both comma- and semicolon-delimited exports + # over its lifetime (locale-dependent). Try each in turn rather + # than guessing from the file extension or sniffing — sniff has + # bitten us on files with semicolons inside quoted artist names. + raw: pd.DataFrame | None = None + last_error: Exception | None = None required = [_SRC_TRACK_NAME, _SRC_ARTIST_NAME, _SRC_TRACK_URI] + for sep in (",", ";"): + try: + candidate = pd.read_csv(path, sep=sep, dtype=str) + except pd.errors.ParserError as exc: + last_error = exc + continue + if all(col in candidate.columns for col in required): + raw = candidate + break + # All required columns missing means we likely picked the wrong + # delimiter and ended up with a single fat column; keep trying. + if raw is None: + if last_error is not None: + raise ValueError( + f"Could not parse {path.name} as CSV with comma or " + f"semicolon delimiters: {last_error}" + ) + raise ValueError( + "CSV is missing required column(s): " + f"{', '.join(required)}. " + "Is this an Exportify export?" + ) + missing = [col for col in required if col not in raw.columns] if missing: raise ValueError( f"CSV is missing required column(s): {', '.join(missing)}. " - "Is this an Exportify export? Expected a ';'-delimited file." + "Is this an Exportify export?" ) tracks = pd.DataFrame( diff --git a/src/playlistsmith/io/playlist_export.py b/src/playlistsmith/io/playlist_export.py new file mode 100644 index 0000000..f8de684 --- /dev/null +++ b/src/playlistsmith/io/playlist_export.py @@ -0,0 +1,275 @@ +"""Write per-cluster CSVs from a clustering result. + +Takes the ``tracks`` frame produced by +:func:`playlistsmith.cluster.cluster` and slices it into one CSV per +cluster, named by the caller (the GUI lets the user rename clusters +before export). The output is Exportify-compatible so the user can +re-import each CSV into Spotify via a Spotify-import flow that accepts +Exportify-shaped CSVs: ``Track URI`` is the leading column, followed by +``Track Name`` and ``Artist Name(s)``. + +Cluster ``-1`` ("Unclassified") is excluded from the per-cluster output +by default; it can be requested explicitly (``include_unclassified=True``) +or rolled into the optional combined CSV via ``write_combined=True``. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +import pandas as pd + +from playlistsmith.io.csv_loader import ARTIST, SPOTIFY_ID, TITLE + +__all__ = [ + "DEFAULT_COMBINED_FILENAME", + "EXPORTIFY_ARTIST", + "EXPORTIFY_TITLE", + "EXPORTIFY_URI", + "UNCLASSIFIED_FILENAME", + "validate_name", + "write_cluster_csvs", +] + +#: Exportify column names. Re-exported so callers (e.g. the GUI export +#: widget) can reference the schema without hard-coding strings. +EXPORTIFY_URI = "Track URI" +EXPORTIFY_TITLE = "Track Name" +EXPORTIFY_ARTIST = "Artist Name(s)" + +_SPOTIFY_TRACK_URI_PREFIX = "spotify:track:" + +#: Filename for the Unclassified bucket when ``include_unclassified=True``. +UNCLASSIFIED_FILENAME = "Unclassified.csv" + +#: Filename for the combined CSV when ``write_combined=True``. +DEFAULT_COMBINED_FILENAME = "playlists.csv" + +#: Cluster id reserved for the Unclassified bucket. Kept local rather than +#: imported from cluster/public.py to avoid pulling sklearn/umap into a +#: pure-io module. +_UNCLASSIFIED_LABEL: int = -1 + +#: Characters that are unsafe in playlist filenames across the common +#: operating systems we care about (Windows, macOS, Linux). Path +#: separators are explicitly included so a user-supplied "name" cannot +#: navigate the filesystem. +_UNSAFE_NAME_CHARS = frozenset('/\\:*?"<>|') + + +def validate_name(name: str) -> bool: + """Return whether ``name`` is safe to use as a playlist filename. + + Rules: + + - non-empty after stripping whitespace, + - no path separators or filesystem-reserved characters, + - no leading or trailing ``.``. + + Args: + name: Candidate playlist filename (without extension). + + Returns: + ``True`` if the name is safe to use, ``False`` otherwise. + """ + if not isinstance(name, str): + return False + stripped = name.strip() + if not stripped: + return False + if stripped.startswith(".") or stripped.endswith("."): + return False + return not any(ch in _UNSAFE_NAME_CHARS for ch in stripped) + + +def _tracks_frame(source: Any) -> pd.DataFrame: + """Coerce ``source`` to a tracks DataFrame. + + Accepts either a :class:`~playlistsmith.cluster.ClusterPipelineResult` + or a DataFrame directly. The pipeline result carries its tracks frame + on the ``tracks`` attribute; we read it lazily so importing this + module does not pull in the clustering machinery. + """ + if isinstance(source, pd.DataFrame): + return source + tracks = getattr(source, "tracks", None) + if isinstance(tracks, pd.DataFrame): + return tracks + raise TypeError( + "write_cluster_csvs expects a DataFrame or an object with a " + f"`.tracks` DataFrame attribute; got {type(source).__name__}." + ) + + +def _resolve_name( + cluster_id: int, + naming: Mapping[int, str] | Callable[[int], str] | None, +) -> str: + """Look up the filename stem for ``cluster_id``. + + Falls back to ``cluster_`` when ``naming`` is ``None`` or does + not have an entry for this cluster. + """ + default = f"cluster_{cluster_id}" + if naming is None: + return default + if callable(naming): + value = naming(cluster_id) + return value if value else default + if isinstance(naming, Mapping): + return naming.get(cluster_id, default) + raise TypeError( + "`naming` must be None, a Mapping[int, str], or Callable[[int], " + f"str]; got {type(naming).__name__}." + ) + + +def _build_export_frame( + cluster_tracks: pd.DataFrame, features_df: pd.DataFrame | None +) -> pd.DataFrame: + """Build the Exportify-shaped frame written to a single cluster CSV. + + Columns: ``Track URI``, ``Track Name``, ``Artist Name(s)``, ``Cluster``, + ``Cluster Summary``, plus any audio-feature columns merged from + ``features_df`` (keyed on ``spotify_id``). + """ + bare_ids = cluster_tracks[SPOTIFY_ID].astype(str) + out = pd.DataFrame( + { + EXPORTIFY_URI: _SPOTIFY_TRACK_URI_PREFIX + bare_ids, + EXPORTIFY_TITLE: cluster_tracks[TITLE].astype(str), + EXPORTIFY_ARTIST: cluster_tracks[ARTIST].astype(str), + "Cluster": cluster_tracks["cluster"].astype(int), + "Cluster Summary": cluster_tracks["cluster_summary"].astype(str), + } + ) + if features_df is not None: + identity = {SPOTIFY_ID, TITLE, ARTIST} + feature_cols = [c for c in features_df.columns if c not in identity] + aligned = ( + features_df.set_index(SPOTIFY_ID)[feature_cols] + .reindex(bare_ids.to_numpy()) + .reset_index(drop=True) + ) + out = pd.concat([out.reset_index(drop=True), aligned], axis=1) + return out + + +def _validate_names(names: dict[int, str]) -> None: + """Raise ``ValueError`` if any value is unsafe or a duplicate.""" + for cluster_id, name in names.items(): + if not validate_name(name): + raise ValueError( + f"Playlist name for cluster {cluster_id} is unsafe or empty: " + f"{name!r}. Avoid path separators, the characters /\\:*?\"<>|, " + "and leading/trailing dots." + ) + seen: dict[str, int] = {} + for cluster_id, name in names.items(): + if name in seen: + raise ValueError( + f"Playlist names must be unique: clusters {seen[name]} and " + f"{cluster_id} both map to {name!r}." + ) + seen[name] = cluster_id + + +def write_cluster_csvs( + result: Any, + output_dir: Path | str, + naming: Mapping[int, str] | Callable[[int], str] | None = None, + *, + features_df: pd.DataFrame | None = None, + include_unclassified: bool = False, + write_combined: bool = False, + combined_filename: str = DEFAULT_COMBINED_FILENAME, +) -> list[Path]: + """Write one Exportify-shaped CSV per cluster to ``output_dir``. + + The output schema is:: + + Track URI, Track Name, Artist Name(s), Cluster, Cluster Summary, +