Commit 0dcd2f10 by PLN (Algolia)

feat(tide-table): GT corpus wave 2 (→60, quadrant-balanced) + V/A calibrator

Corpus 22→60 tracks, all 4 V/A quadrants now ≥13 (was +-:4, --:2 thin), 50 genres,
all 12 emotions — chosen for confident, balanced priors (quality of pseudo-GT > raw
count for calibration). Added the sad/dark-calm + calm/pleasant fillers (Portishead,
Mazzy Star, Satie, Debussy, Coltrane, Bon Iver…) the gaps report flagged.

emotion_calibrate.py: fits per-axis affine gt≈a·pred+b over analyzed∩GT to DE-COMPRESS
the center-biased CLAP read (learns the scale-up from data, not a guessed tau).
Reports leave-one-out CV error (honest generalization, not in-sample) + re-derives the
label from calibrated V/A (nearest anchor) so we see if labels improve too. Pragmatic
call: scale corpus = whole-mix analysis (demucs doesn't scale to 100s/1k; stems didn't
beat mix on accuracy) — stems reserved for the hexa lane.
parent ce3da569
#!/usr/bin/env python3
"""emotion_calibrate — fit a V/A calibration from the GT corpus to de-compress CLAP.
The grader's baseline finding: CLAP's emotion read is center-compressed — predicted
valence/arousal span ~±0.4 while the training-knowledge priors span ±0.9. A
distribution-weighted mean of emotion anchors regresses to the centre, so extremes
(euphoric, aggressive, sad) get pulled toward neutral. We fix it from DATA, not by
guessing: fit a per-axis affine map gt ≈ a·pred + b (least squares) over every
analyzed corpus track, which learns the scale-up (a>1) that de-compresses + any offset.
Honesty: we report LEAVE-ONE-OUT cross-validated error (fit on N-1, test the held-out
one), so "calibration helps" is a generalization claim, not an in-sample illusion. We
also re-derive the emotion LABEL from the calibrated V/A (nearest circumplex anchor) and
re-grade — if de-compression is right, labels improve too.
python3 emotion_calibrate.py fit [--stem] # fit + LOO report → calibration.json
python3 emotion_calibrate.py apply V A # map one (valence, arousal) point
Feeds emotion_ontology/emotion_timeline once trusted. See [[feedback_build_katana_first]]."""
import json
import sys
from pathlib import Path
import numpy as np
import emotion_ontology as EMO
HERE = Path(__file__).resolve().parent
GT = HERE / "emotion_corpus_gt.json"
ANALYSIS = HERE / "_corpus_cache" / "analysis"
OUT = HERE / "calibration.json"
def _pairs(stem=False):
"""(pred_v, pred_a, gt_v, gt_a, slug) for every track that's both GT and analyzed."""
gt = {t["slug"]: t for t in json.loads(GT.read_text())["tracks"]}
rows = []
for slug, t in gt.items():
f = ANALYSIS / f"{slug}{'' if stem else '_mix'}.json"
if not f.exists():
continue
s = json.loads(f.read_text())["summary"]
rows.append((s["valence"], s["arousal"], t["valence"], t["arousal"], slug))
return rows
def _fit_affine(x, y):
"""Least-squares a,b for y ≈ a·x + b."""
A = np.vstack([x, np.ones_like(x)]).T
a, b = np.linalg.lstsq(A, y, rcond=None)[0]
return float(a), float(b)
def _nearest_emotion(v, a):
va = EMO._matrix()["va"]
return EMO._matrix()["names"][int(np.argmin((va[:, 0] - v) ** 2 + (va[:, 1] - a) ** 2))]
def _quad(v, a):
return ("+" if v >= 0 else "-") + ("+" if a >= 0 else "-")
def fit(stem=False):
rows = _pairs(stem)
if len(rows) < 6:
sys.exit(f"only {len(rows)} analyzed tracks — run `emotion_corpus.py analyze"
f"{'' if stem else ' --mix'}` first (need ≥6).")
pv = np.array([r[0] for r in rows]); pa = np.array([r[1] for r in rows])
gv = np.array([r[2] for r in rows]); ga = np.array([r[3] for r in rows])
gt = json.loads(GT.read_text())["tracks"]
emos = {t["slug"]: set(t["emotions"]) for t in gt}
# in-sample fit (the shipped calibration)
va_a, va_b = _fit_affine(pv, gv)
ar_a, ar_b = _fit_affine(pa, ga)
def metrics(vv, aa):
err = np.mean(np.hypot(vv - gv, aa - ga))
quad = np.mean([_quad(vv[i], aa[i]) == _quad(gv[i], ga[i]) for i in range(len(vv))])
hit = np.mean([_nearest_emotion(vv[i], aa[i]) in emos[rows[i][4]] for i in range(len(vv))])
return err, quad, hit
raw = metrics(pv, pa)
cal_in = metrics(va_a * pv + va_b, ar_a * pa + ar_b)
# leave-one-out CV (honest generalization)
loo_v, loo_a = np.zeros_like(pv), np.zeros_like(pa)
for i in range(len(rows)):
m = np.ones(len(rows), bool); m[i] = False
a1, b1 = _fit_affine(pv[m], gv[m]); a2, b2 = _fit_affine(pa[m], ga[m])
loo_v[i] = a1 * pv[i] + b1; loo_a[i] = a2 * pa[i] + b2
cal_loo = metrics(loo_v, loo_a)
print(f"⛵ V/A calibration — {len(rows)} tracks ({'stem' if stem else 'whole-mix'})\n")
print(f" valence: gt ≈ {va_a:.2f}·pred {va_b:+.2f} (×{va_a:.1f} de-compression)")
print(f" arousal: gt ≈ {ar_a:.2f}·pred {ar_b:+.2f}\n")
print(f" {'':<16}{'V/A err':>9}{'quad hit':>10}{'emo hit':>9}")
print(f" {'raw':<16}{raw[0]:>9.3f}{raw[1]*100:>9.0f}%{raw[2]*100:>8.0f}%")
print(f" {'calibrated':<16}{cal_in[0]:>9.3f}{cal_in[1]*100:>9.0f}%{cal_in[2]*100:>8.0f}%")
print(f" {'calibrated (LOO)':<16}{cal_loo[0]:>9.3f}{cal_loo[1]*100:>9.0f}%{cal_loo[2]*100:>8.0f}%")
verdict = "✓ helps" if cal_loo[0] < raw[0] else "✗ no gain — need more/cleaner data"
print(f"\n {verdict} (LOO V/A err {raw[0]:.3f} → {cal_loo[0]:.3f})")
OUT.write_text(json.dumps({
"source": "stem" if stem else "whole-mix", "n": len(rows),
"valence": {"a": va_a, "b": va_b}, "arousal": {"a": ar_a, "b": ar_b},
"raw": {"va_err": raw[0], "quad": raw[1], "emo_hit": raw[2]},
"loo": {"va_err": cal_loo[0], "quad": cal_loo[1], "emo_hit": cal_loo[2]},
}, indent=1))
print(f" ✓ {OUT.name}")
def apply(v, a):
c = json.loads(OUT.read_text())
return (round(c["valence"]["a"] * v + c["valence"]["b"], 3),
round(c["arousal"]["a"] * a + c["arousal"]["b"], 3))
def main():
args = sys.argv[1:]
if args and args[0] == "fit":
fit(stem="--stem" in args)
elif args and args[0] == "apply" and len(args) >= 3:
print(apply(float(args[1]), float(args[2])))
else:
sys.exit("usage: emotion_calibrate.py [fit [--stem] | apply <V> <A>]")
if __name__ == "__main__":
main()
...@@ -67,6 +67,45 @@ ...@@ -67,6 +67,45 @@
"structure": [[0.0, "intro", false], [0.1, "head", false], [0.3, "trumpet solo", false], [0.6, "sax solo", false], [0.85, "head", false]]}, "structure": [[0.0, "intro", false], [0.1, "head", false], [0.3, "trumpet solo", false], [0.6, "sax solo", false], [0.85, "head", false]]},
{"slug": "mj-billie-jean", "artist": "Michael Jackson", "title": "Billie Jean", "query": "Michael Jackson Billie Jean official audio", {"slug": "mj-billie-jean", "artist": "Michael Jackson", "title": "Billie Jean", "query": "Michael Jackson Billie Jean official audio",
"emotions": ["tense", "hypnotic"], "valence": 0.0, "arousal": 0.55, "genre": ["pop", "funk", "r&b"], "emotions": ["tense", "hypnotic"], "valence": 0.0, "arousal": 0.55, "genre": ["pop", "funk", "r&b"],
"structure": [[0.0, "intro", false], [0.12, "verse", true], [0.35, "chorus", true], [0.55, "verse", true], [0.75, "chorus", true]]} "structure": [[0.0, "intro", false], [0.12, "verse", true], [0.35, "chorus", true], [0.55, "verse", true], [0.75, "chorus", true]]},
{"slug": "radiohead-how-to-disappear", "artist": "Radiohead", "title": "How to Disappear Completely", "query": "Radiohead How to Disappear Completely", "emotions": ["melancholic", "dark"], "valence": -0.7, "arousal": -0.4, "genre": ["alternative rock", "art rock"]},
{"slug": "mazzy-star-fade-into-you", "artist": "Mazzy Star", "title": "Fade Into You", "query": "Mazzy Star Fade Into You official", "emotions": ["melancholic", "dreamy"], "valence": -0.35, "arousal": -0.5, "genre": ["dream pop"]},
{"slug": "portishead-roads", "artist": "Portishead", "title": "Roads", "query": "Portishead Roads", "emotions": ["melancholic", "dark"], "valence": -0.6, "arousal": -0.3, "genre": ["trip hop"]},
{"slug": "jeff-buckley-hallelujah", "artist": "Jeff Buckley", "title": "Hallelujah", "query": "Jeff Buckley Hallelujah", "emotions": ["melancholic"], "valence": -0.45, "arousal": -0.4, "genre": ["folk rock"]},
{"slug": "billie-eilish-party-over", "artist": "Billie Eilish", "title": "when the party's over", "query": "Billie Eilish when the party's over", "emotions": ["melancholic", "dark"], "valence": -0.6, "arousal": -0.45, "genre": ["pop", "electropop"]},
{"slug": "nick-drake-pink-moon", "artist": "Nick Drake", "title": "Pink Moon", "query": "Nick Drake Pink Moon", "emotions": ["melancholic", "serene"], "valence": -0.35, "arousal": -0.5, "genre": ["folk"]},
{"slug": "elliott-smith-between-bars", "artist": "Elliott Smith", "title": "Between the Bars", "query": "Elliott Smith Between the Bars", "emotions": ["melancholic"], "valence": -0.5, "arousal": -0.5, "genre": ["indie folk"]},
{"slug": "barber-adagio", "artist": "Samuel Barber", "title": "Adagio for Strings", "query": "Barber Adagio for Strings", "emotions": ["melancholic", "dark"], "valence": -0.5, "arousal": -0.2, "genre": ["classical", "orchestral"]},
{"slug": "the-xx-intro", "artist": "The xx", "title": "Intro", "query": "The xx Intro", "emotions": ["cold", "melancholic"], "valence": -0.2, "arousal": -0.05, "genre": ["indie", "minimal"]},
{"slug": "simon-garfunkel-silence", "artist": "Simon & Garfunkel", "title": "The Sound of Silence", "query": "Simon and Garfunkel The Sound of Silence", "emotions": ["melancholic", "serene"], "valence": -0.4, "arousal": -0.35, "genre": ["folk"]},
{"slug": "debussy-clair-de-lune", "artist": "Debussy", "title": "Clair de Lune", "query": "Debussy Clair de Lune", "emotions": ["serene", "dreamy"], "valence": 0.5, "arousal": -0.7, "genre": ["classical"]},
{"slug": "satie-gymnopedie-1", "artist": "Erik Satie", "title": "Gymnopédie No. 1", "query": "Erik Satie Gymnopedie No 1", "emotions": ["serene", "melancholic"], "valence": 0.25, "arousal": -0.7, "genre": ["classical"]},
{"slug": "bon-iver-holocene", "artist": "Bon Iver", "title": "Holocene", "query": "Bon Iver Holocene", "emotions": ["warm", "melancholic"], "valence": 0.3, "arousal": -0.4, "genre": ["indie folk"]},
{"slug": "fleetwood-dreams", "artist": "Fleetwood Mac", "title": "Dreams", "query": "Fleetwood Mac Dreams official", "emotions": ["warm", "dreamy"], "valence": 0.5, "arousal": -0.1, "genre": ["rock", "pop rock"]},
{"slug": "marvin-whats-going-on", "artist": "Marvin Gaye", "title": "What's Going On", "query": "Marvin Gaye What's Going On", "emotions": ["warm", "serene"], "valence": 0.5, "arousal": -0.1, "genre": ["soul"]},
{"slug": "sade-no-ordinary-love", "artist": "Sade", "title": "No Ordinary Love", "query": "Sade No Ordinary Love official", "emotions": ["warm", "dreamy"], "valence": 0.35, "arousal": -0.3, "genre": ["soul", "r&b"]},
{"slug": "coltrane-naima", "artist": "John Coltrane", "title": "Naima", "query": "John Coltrane Naima", "emotions": ["serene", "warm"], "valence": 0.4, "arousal": -0.4, "genre": ["jazz"]},
{"slug": "einaudi-nuvole-bianche", "artist": "Ludovico Einaudi", "title": "Nuvole Bianche", "query": "Ludovico Einaudi Nuvole Bianche", "emotions": ["melancholic", "serene"], "valence": 0.1, "arousal": -0.4, "genre": ["neoclassical", "classical"]},
{"slug": "air-femme-dargent", "artist": "Air", "title": "La Femme d'Argent", "query": "Air La Femme d'Argent", "emotions": ["dreamy", "serene"], "valence": 0.4, "arousal": -0.4, "genre": ["electronic", "downtempo"]},
{"slug": "sigur-ros-hoppipolla", "artist": "Sigur Rós", "title": "Hoppípolla", "query": "Sigur Ros Hoppipolla", "emotions": ["dreamy", "triumphant"], "valence": 0.6, "arousal": 0.25, "genre": ["post-rock"]},
{"slug": "avicii-levels", "artist": "Avicii", "title": "Levels", "query": "Avicii Levels original", "emotions": ["euphoric"], "valence": 0.8, "arousal": 0.8, "genre": ["edm", "house"]},
{"slug": "ewf-september", "artist": "Earth, Wind & Fire", "title": "September", "query": "Earth Wind and Fire September", "emotions": ["euphoric", "playful"], "valence": 0.85, "arousal": 0.7, "genre": ["funk", "disco"]},
{"slug": "beatles-here-comes-sun", "artist": "The Beatles", "title": "Here Comes the Sun", "query": "Beatles Here Comes the Sun", "emotions": ["warm", "euphoric"], "valence": 0.8, "arousal": 0.35, "genre": ["rock", "pop"]},
{"slug": "journey-dont-stop-believin", "artist": "Journey", "title": "Don't Stop Believin'", "query": "Journey Don't Stop Believin official", "emotions": ["triumphant", "euphoric"], "valence": 0.8, "arousal": 0.7, "genre": ["rock", "arena rock"]},
{"slug": "outkast-hey-ya", "artist": "OutKast", "title": "Hey Ya!", "query": "Outkast Hey Ya official", "emotions": ["playful", "euphoric"], "valence": 0.85, "arousal": 0.75, "genre": ["hip hop", "pop"]},
{"slug": "pharrell-happy", "artist": "Pharrell Williams", "title": "Happy", "query": "Pharrell Williams Happy official", "emotions": ["euphoric", "playful"], "valence": 0.9, "arousal": 0.65, "genre": ["pop", "soul"]},
{"slug": "metallica-master-of-puppets", "artist": "Metallica", "title": "Master of Puppets", "query": "Metallica Master of Puppets", "emotions": ["aggressive", "dark"], "valence": -0.5, "arousal": 0.9, "genre": ["metal", "thrash metal"]},
{"slug": "soad-chop-suey", "artist": "System of a Down", "title": "Chop Suey!", "query": "System of a Down Chop Suey", "emotions": ["aggressive", "tense"], "valence": -0.4, "arousal": 0.9, "genre": ["metal", "nu metal"]},
{"slug": "aphex-come-to-daddy", "artist": "Aphex Twin", "title": "Come to Daddy", "query": "Aphex Twin Come to Daddy", "emotions": ["aggressive", "dark"], "valence": -0.6, "arousal": 0.85, "genre": ["electronic", "idm"]},
{"slug": "white-stripes-seven-nation", "artist": "The White Stripes", "title": "Seven Nation Army", "query": "White Stripes Seven Nation Army", "emotions": ["tense", "aggressive"], "valence": -0.2, "arousal": 0.7, "genre": ["rock", "garage rock"]},
{"slug": "eminem-lose-yourself", "artist": "Eminem", "title": "Lose Yourself", "query": "Eminem Lose Yourself", "emotions": ["tense", "triumphant"], "valence": -0.1, "arousal": 0.8, "genre": ["hip hop"]},
{"slug": "zimmer-mountains", "artist": "Hans Zimmer", "title": "Mountains", "query": "Hans Zimmer Mountains Interstellar", "emotions": ["tense", "triumphant"], "valence": -0.05, "arousal": 0.5, "genre": ["cinematic", "orchestral"]},
{"slug": "muse-hysteria", "artist": "Muse", "title": "Hysteria", "query": "Muse Hysteria official", "emotions": ["aggressive", "tense"], "valence": -0.3, "arousal": 0.85, "genre": ["rock", "alternative rock"]},
{"slug": "tame-impala-let-it-happen", "artist": "Tame Impala", "title": "Let It Happen", "query": "Tame Impala Let It Happen", "emotions": ["hypnotic", "dreamy"], "valence": 0.3, "arousal": 0.5, "genre": ["psychedelic", "electronic"]},
{"slug": "massive-attack-teardrop", "artist": "Massive Attack", "title": "Teardrop", "query": "Massive Attack Teardrop", "emotions": ["dreamy", "melancholic"], "valence": -0.1, "arousal": -0.05, "genre": ["trip hop"]},
{"slug": "boards-of-canada-roygbiv", "artist": "Boards of Canada", "title": "Roygbiv", "query": "Boards of Canada Roygbiv", "emotions": ["dreamy", "cold"], "valence": 0.1, "arousal": 0.0, "genre": ["electronic", "idm"]},
{"slug": "lcd-dance-yrself-clean", "artist": "LCD Soundsystem", "title": "Dance Yrself Clean", "query": "LCD Soundsystem Dance Yrself Clean", "emotions": ["hypnotic", "euphoric"], "valence": 0.4, "arousal": 0.6, "genre": ["dance punk", "electronic"]},
{"slug": "radiohead-idioteque", "artist": "Radiohead", "title": "Idioteque", "query": "Radiohead Idioteque", "emotions": ["tense", "cold"], "valence": -0.3, "arousal": 0.6, "genre": ["electronic", "alternative"]}
] ]
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment