diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..4376f72 --- /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 ".[dev]" + + - name: Run unit tests + run: pytest \ No newline at end of file diff --git a/.gitignore b/.gitignore index 92a24b6..256f010 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,13 @@ # 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/ + +# GUI output +playlistsmith_out/ \ No newline at end of file diff --git a/README.md b/README.md index 5de78a2..f3859ad 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,10 @@ CSV → io.csv_loader.TrackLibrary → features.extract(mode=...) → (features_ implementation details — always go through `extract`. - **`_http`** — a single shared, retry/backoff/User-Agent–configured HTTP client that every external API call routes through. - -Clustering and playlist export are the next pipeline stages and are not yet -implemented. +- **`cluster`** — preprocessing, model fitting (K-means / GMM / HDBSCAN with + BIC- and silhouette-based selection), and cluster interpretation. +- **`io.playlist_export`** — writes one CSV per cluster, preserving the + original Exportify columns so the output is round-trippable. ## Current module structure @@ -56,28 +57,98 @@ src/playlistsmith/ ├── _http.py # shared HTTP client (timeouts, retries, backoff) ├── io/ │ ├── __init__.py # exposes TrackLibrary -│ └── csv_loader.py # TrackLibrary: Exportify CSV → tidy DataFrame -└── features/ - ├── __init__.py # extract() entry point + CoverageReport - └── reccobeats.py # internal: ReccoBeats "precomputed" client +│ ├── csv_loader.py # TrackLibrary: Exportify CSV → tidy DataFrame +│ └── playlist_export.py # write one CSV per cluster +├── features/ +│ ├── __init__.py # extract() entry point + CoverageReport +│ └── reccobeats.py # internal: ReccoBeats "precomputed" client +├── cluster/ +│ ├── __init__.py # public cluster API +│ ├── preprocess.py # feature scaling / preparation +│ ├── algorithms.py # KMeans / GMM / HDBSCAN fitting + selection +│ ├── interpret.py # per-cluster summaries +│ └── public.py # high-level cluster_tracks(...) entry point +└── gui/ + ├── __init__.py # exposes the playlistsmith-gui entry point + ├── app.py # Streamlit single-page app + ├── cli.py # console-script launcher (--demo flag) + ├── state.py # session-state keys + reset helpers + ├── fixtures.py # offline ReccoBeats mock transport + └── widgets/ # upload / extract / cluster / viz / export panels ``` ## Usage +### Python API + +The example below runs the full pipeline offline against the bundled +synthetic Exportify CSV at [docs/example_synthetic.csv](docs/example_synthetic.csv). +Installing the GUI's mock transport reroutes every ReccoBeats call to a +deterministic in-process handler, so no network access is required: + ```python import playlistsmith as ps +from playlistsmith.gui import fixtures +from playlistsmith.io import playlist_export -# Load an Exportify-format CSV. -library = ps.TrackLibrary("./tests/example_tracklist.csv") -print(library) # TrackLibrary(source_path='...', tracks=3) +# 0. Install the offline ReccoBeats mock. Every call routed through +# playlistsmith._http now returns deterministic synthetic features +# for `syn` IDs (no network). +fixtures.install_mock_transport() + +# 1. Load the bundled synthetic Exportify-format CSV. +library = ps.TrackLibrary("./docs/example_synthetic.csv") +print(library) # TrackLibrary(source_path='...', tracks=...) library.display() # pretty-print the parsed tracks -# Resolve precomputed audio features via ReccoBeats. +# 2. Resolve precomputed audio features (served by the mock). features, coverage = library.extract_features(mode="precomputed") print(coverage) # "Feature coverage: N/M track(s) resolved ..." print(coverage.dropped_tracks) # tracks with no precomputed features + +# 3. Cluster. Defaults: GMM with BIC-based k selection, canonical +# cluster ordering, and small clusters collapsed into an +# Unclassified bucket (cluster id -1). +result = ps.cluster(features, method="gmm", random_state=0) +print(result.tracks.head()) # per-track: spotify_id, title, artist, cluster, cluster_summary +print(result.descriptions) # per-cluster: size, top_features, cluster_summary +for w in result.warnings: # e.g. dominant-cluster notices + print(w) + +# 4. Export one Exportify-shaped CSV per cluster (round-trippable +# back into Spotify via any Exportify-compatible import flow). +paths = playlist_export.write_cluster_csvs( + result, + output_dir="./playlists", + features_df=features, # optional: merge audio features into each CSV +) +print(paths) # [PosixPath('playlists/cluster_0.csv'), ...] +``` + +To run against real data instead, drop the `fixtures.install_mock_transport()` +call and point `TrackLibrary` at your own Exportify CSV — every other +step is identical. + +### GUI + +A Streamlit front-end walks through the same pipeline interactively +(Upload → Extract → Cluster → Visualize → Export). It is a thin shell — +every action maps to one public call into the package. + +Install the GUI extras and launch the console script: + +```bash +pip install -e ".[gui]" +playlistsmith-gui # live mode (real ReccoBeats lookups) +playlistsmith-gui --demo # offline mode (recorded ReccoBeats fixture, no network) ``` +Use `--demo` to try the app without an internet connection or while +iterating on the UI; it installs a mock HTTP transport so the entire +pipeline runs against a deterministic synthetic dataset. Any extra +arguments after the flag are forwarded to Streamlit (e.g. +`playlistsmith-gui -- --server.port 8502`). + ## Installation (local / development) Playlistsmith targets **Python 3.12**. From the repository root, install the @@ -91,6 +162,14 @@ pip install -e ".[dev]" The `[dev]` extra pulls in `pytest`, `pytest-httpx`, `pytest-cov`, `mypy`, and `pandas-stubs` on top of the runtime dependencies (`pandas`, `httpx`). +To also install the Streamlit GUI (see [Usage → GUI](#gui)): + +```bash +pip install -e ".[dev,gui]" +``` + +The `[gui]` extra adds `streamlit`, `plotly`, and `matplotlib`. + ## Running the tests The test suite is configured via `pyproject.toml` (`pythonpath = ["src"]`, 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 new file mode 100644 index 0000000..e5f37d8 --- /dev/null +++ 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..a2f0a21 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", ] @@ -33,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" @@ -40,4 +52,19 @@ 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] +exclude = ["src/playlistsmith/gui/"] + +[[tool.mypy.overrides]] +module = ["sklearn.*", "umap", "umap.*"] +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/__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/_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/__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..6eb309b --- /dev/null +++ b/src/playlistsmith/cluster/public.py @@ -0,0 +1,490 @@ +"""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). + 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 + 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 + projection_3d: pd.DataFrame | None = None + + +@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) + ) + + # 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, + ) + + +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/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, +