/
/
/
1"""Zero-shot CLAP prompt pairs and Platt-scale calibration for the soft scalars."""
2
3from __future__ import annotations
4
5import hashlib
6import json
7import math
8from pathlib import Path
9
10import numpy as np
11
12PRECOMPUTED_EMBEDDINGS_PATH: Path = (
13 Path(__file__).parent / "vendored_clap" / "precomputed_prompt_embeddings.npz"
14)
15
16SCALAR_PROMPT_PAIRS: dict[str, tuple[str, str]] = {
17 "danceability": (
18 "dance beat, 4/4 groove, danceable, body movement, club, steady tempo, pulsing bassline",
19 "slow tempo, ballad, free rhythm, ambient, meditative, sparse drums, no beat",
20 ),
21 "valence": (
22 "The sound of a joyful upbeat song with bright major-key chords and happy vocals.",
23 "The sound of a mournful sad song with minor-key chords and melancholy vocals.",
24 ),
25 "arousal": (
26 "loud, intense, fast tempo, distorted guitars, aggressive drums, high energy, shouting",
27 "soft, quiet, slow tempo, gentle, ambient, meditative, calm, peaceful, whispered",
28 ),
29 "instrumentalness": (
30 "instrumental, no vocals, no singing, piano solo, strings, orchestra, film score",
31 "lead vocals, singer, lyrics, verses, chorus, vocal melody, singing",
32 ),
33 "acousticness": (
34 "acoustic guitar, piano, and hand percussion recorded with natural room sound.",
35 "synthesizers, drum machines, and auto-tuned vocals with heavy studio production.",
36 ),
37}
38
39
40def compute_prompt_embeddings(model: object, prompts: dict[str, tuple[str, str]]) -> np.ndarray:
41 """
42 Run a CLAP model's text encoder over a SCALAR_PROMPT_PAIRS-shaped mapping.
43
44 :param model: An object exposing ``get_text_embeddings(list[str]) -> torch.Tensor``.
45 :param prompts: Mapping of scalar name -> (positive, negative) prompt pair.
46 """
47 flat: list[str] = []
48 for pos, neg in prompts.values():
49 flat.extend([pos, neg])
50 embeddings_tensor = model.get_text_embeddings(flat) # type: ignore[attr-defined]
51 arr: np.ndarray = embeddings_tensor.detach().cpu().numpy().astype(np.float32, copy=False)
52 return arr
53
54
55def save_precomputed_prompt_embeddings(
56 path: Path, embeddings: np.ndarray, prompts_hash: str
57) -> None:
58 """
59 Persist the (N, D) prompt-embedding matrix and its prompts-hash to .npz.
60
61 :param path: Destination .npz path; parent must exist.
62 :param embeddings: float32 array of shape (N_prompts, embedding_dim).
63 :param prompts_hash: SHA-256 hex digest of the SCALAR_PROMPT_PAIRS the
64 embeddings were computed from (drift detector at load time).
65 """
66 np.savez_compressed(
67 path,
68 embeddings=embeddings.astype(np.float32, copy=False),
69 prompts_hash=np.array(prompts_hash),
70 )
71
72
73def load_precomputed_prompt_embeddings(path: Path) -> tuple[np.ndarray, str]:
74 """
75 Load (embeddings, prompts_hash) from an .npz written by save_precomputed_prompt_embeddings.
76
77 :param path: Source .npz path.
78 """
79 if not path.exists():
80 raise FileNotFoundError(path)
81 with np.load(path) as data:
82 embeddings = np.asarray(data["embeddings"], dtype=np.float32)
83 prompts_hash = str(data["prompts_hash"].item())
84 return embeddings, prompts_hash
85
86
87def hash_scalar_prompt_pairs(prompts: dict[str, tuple[str, str]]) -> str:
88 """
89 Stable SHA-256 hex digest of a SCALAR_PROMPT_PAIRS-shaped mapping.
90
91 :param prompts: Mapping of scalar name -> (positive, negative) prompt pair.
92 """
93 canonical = json.dumps(
94 [[k, [p, n]] for k, (p, n) in prompts.items()],
95 ensure_ascii=False,
96 separators=(",", ":"),
97 )
98 return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
99
100
101# Calibration provenance
102# ----------------------
103# (a, b) Platt-scaling coefficients: score = sigmoid(a * (pos_logit - neg_logit) + b).
104# Fit via sklearn.LogisticRegression (5-fold CV) on a 50-track diverse ground-truth
105# set during initial development (commit c93183684, 2026-04-23).
106# 5-fold CV accuracy on the validation set:
107# acousticness: 0.843, danceability: 0.910, instrumentalness: 0.896,
108# arousal: 0.727, valence: 0.713
109# The b term corrects for CLAP's per-attribute bias (e.g., b<<0 on instrumentalness
110# corrects CLAP's tendency to interpret most music as instrumental).
111#
112# CALIBRATION_PROMPTS_HASH records the SCALAR_PROMPT_PAIRS hash at calibration time.
113# If you edit SCALAR_PROMPT_PAIRS, the hash will drift and
114# ``test_clap_calibration_hash_matches_prompts`` will fail in CI. When that happens
115# you must re-fit CALIBRATION and update CALIBRATION_PROMPTS_HASH, then bump
116# analysis_version in the provider so existing rows get re-analyzed.
117CALIBRATION_PROMPTS_HASH: str = "67495ab1df2dae36c6886975fdc56265151656f68bc7b3c9dbc941abd518b969"
118
119CALIBRATION: dict[str, tuple[float, float]] = {
120 "danceability": (0.940, -0.134),
121 "valence": (0.441, -1.870),
122 "arousal": (0.359, +0.358),
123 "instrumentalness": (0.761, -3.538),
124 "acousticness": (0.549, +0.453),
125}
126
127
128def score_scalars(
129 mean_similarities: np.ndarray,
130 calibration: dict[str, tuple[float, float]] = CALIBRATION,
131) -> dict[str, float]:
132 """
133 Map mean per-window CLAP similarity logits to calibrated 0-1 scalars.
134
135 :param mean_similarities: Per-prompt similarity logits averaged across
136 windows, ordered as SCALAR_PROMPT_PAIRS flattens its (pos, neg) pairs.
137 :param calibration: scalar name -> (a, b) Platt coefficients.
138 """
139 expected = 2 * len(SCALAR_PROMPT_PAIRS)
140 if mean_similarities.shape != (expected,):
141 raise ValueError(
142 f"mean_similarities must have shape ({expected},), got {mean_similarities.shape}"
143 )
144 scores: dict[str, float] = {}
145 for idx, scalar_name in enumerate(SCALAR_PROMPT_PAIRS):
146 pos_logit = float(mean_similarities[idx * 2])
147 neg_logit = float(mean_similarities[idx * 2 + 1])
148 a, b = calibration[scalar_name]
149 margin = pos_logit - neg_logit
150 scores[scalar_name] = 1.0 / (1.0 + math.exp(-(a * margin + b)))
151 return scores
152