Commit f67edc67 by PLN (Algolia)

feat(emotion): fit whole-mix V/A calibration on full 60-track corpus

#91 batch finished — all 60 famous tracks fetched + whole-mix analyzed.
First honest baseline on the full corpus (was n=5 before):

  top-1 emotion hit : 22%   (47% w/ overlap)
  V/A quadrant hit  : 42%
  mean V/A error    : 0.573

The center-compression hypothesis is confirmed at scale: 'playful' is the
predicted top emotion for ~18 of 60 tracks (Fleetwood, Marvin, Coltrane,
Avicii, Journey, Eminem...) because the distribution-weighted mean of the
circumplex anchors collapses toward the warm-positive centre.

Affine de-compression (gt ≈ a·pred + b, leave-one-out CV) learns exactly
that bias and corrects it:
  valence:  ×1.03  −0.25   (reads +0.25 too WARM — the playful pull)
  arousal:  ×1.70  −0.39   (compressed ~1.7x — needs the most stretch)
  LOO V/A err 0.573 → 0.519,  quad 42% → 50%.  Modest but real & cross-validated.

calibrate.py now writes per-mode files (calibration.whole-mix.json /
calibration.stem.json) so the #92 whole-mix-vs-stem decision can compare both
without one clobbering the other; calibration.json mirrors the freshest fit.

Stem-aware batch (demucs, all 60) launched in background to settle #92 on the
full corpus, not the n=5 that PLN rightly refused to conclude on.
parent 73063f4a
{
"source": "whole-mix",
"n": 60,
"valence": {
"a": 1.0336009052315478,
"b": -0.25087256856138784
},
"arousal": {
"a": 1.69672304851225,
"b": -0.39451139501465854
},
"raw": {
"va_err": 0.5731779158725924,
"quad": 0.4166666666666667,
"emo_hit": 0.21666666666666667
},
"loo": {
"va_err": 0.519061292820291,
"quad": 0.5,
"emo_hit": 0.21666666666666667
}
}
\ No newline at end of file
{
"source": "whole-mix",
"n": 60,
"valence": {
"a": 1.0336009052315478,
"b": -0.25087256856138784
},
"arousal": {
"a": 1.69672304851225,
"b": -0.39451139501465854
},
"raw": {
"va_err": 0.5731779158725924,
"quad": 0.4166666666666667,
"emo_hit": 0.21666666666666667
},
"loo": {
"va_err": 0.519061292820291,
"quad": 0.5,
"emo_hit": 0.21666666666666667
}
}
\ No newline at end of file
...@@ -28,9 +28,15 @@ import emotion_ontology as EMO ...@@ -28,9 +28,15 @@ import emotion_ontology as EMO
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
GT = HERE / "emotion_corpus_gt.json" GT = HERE / "emotion_corpus_gt.json"
ANALYSIS = HERE / "_corpus_cache" / "analysis" ANALYSIS = HERE / "_corpus_cache" / "analysis"
# Per-mode files so whole-mix and stem calibrations coexist for the #92 decision;
# `calibration.json` is the symlink/copy of whichever mode we ultimately trust.
OUT = HERE / "calibration.json" OUT = HERE / "calibration.json"
def _out(stem):
return HERE / f"calibration.{'stem' if stem else 'whole-mix'}.json"
def _pairs(stem=False): def _pairs(stem=False):
"""(pred_v, pred_a, gt_v, gt_a, slug) for every track that's both GT and analyzed.""" """(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"]} gt = {t["slug"]: t for t in json.loads(GT.read_text())["tracks"]}
...@@ -101,13 +107,16 @@ def fit(stem=False): ...@@ -101,13 +107,16 @@ def fit(stem=False):
verdict = "✓ helps" if cal_loo[0] < raw[0] else "✗ no gain — need more/cleaner data" 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})") print(f"\n {verdict} (LOO V/A err {raw[0]:.3f} → {cal_loo[0]:.3f})")
OUT.write_text(json.dumps({ payload = json.dumps({
"source": "stem" if stem else "whole-mix", "n": len(rows), "source": "stem" if stem else "whole-mix", "n": len(rows),
"valence": {"a": va_a, "b": va_b}, "arousal": {"a": ar_a, "b": ar_b}, "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]}, "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]}, "loo": {"va_err": cal_loo[0], "quad": cal_loo[1], "emo_hit": cal_loo[2]},
}, indent=1)) }, indent=1)
print(f" ✓ {OUT.name}") out = _out(stem)
out.write_text(payload)
OUT.write_text(payload) # also publish the freshest fit as the active calibration
print(f" ✓ {out.name} (+ {OUT.name})")
def apply(v, a): def apply(v, a):
......
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