Commit e0635928 by PLN (Algolia)

feat(emotion): paired whole-mix-vs-stem comparator — the katana for #92

The whole-mix-vs-stem decision (#92) was about to be called on a rigged
comparison. `fit` grades whole-mix on every mix-analyzed track (163 so far,
heading to 534) and `fit --stem` on every stem-analyzed track (24) — different
SETS and different SIZES. The unpaired numbers (calibrated LOO 0.418 whole-mix
vs 0.618 stem) screamed "whole-mix decisively wins," but most of that gap was
sample size: 163 points fit a stable affine (in-sample→LOO gap 0.005), 24 points
overfit it (gap 0.063). That's a sample-size artifact masquerading as a
mode-quality verdict — exactly the kind of premature conclusion the build-the-
katana-first discipline exists to catch.

Approach: extract the raw/in-sample/LOO scoring into a shared `_evaluate()` so
every mode is graded by identical code, then add `paired` — whole-mix vs stem
head-to-head restricted to the slugs analyzed BOTH ways (the only honest
comparison). Reports both modes' raw + calibrated-LOO V/A-err, quad-hit,
emo-hit, plus an in-sample→LOO conditioning gap with an overfit flag.

The surprise (n=26 paired, validated this run): on equal footing the verdict
nearly inverts. whole-mix cal·LOO 0.569 / quad 54% / emo 23% vs stem 0.605 /
58% / 27% — whole-mix wins distance by Δ0.037, but stem wins BOTH quadrant and
emotion-hit, and both overfit (n=26 too small). The honest #92 answer is now
"inconclusive until more stems exist," not "whole-mix decisively wins." Caveat:
the paired subset is the hard cases (first-fetched = aggressive techno/dnb/
dubstep, the high-arousal region drift + the ontology critique both flagged).

Regression: `fit` output unchanged after the refactor. Interim calibration JSON
(n=163 fit) left uncommitted — it regenerates at the full 534.
parent a94bf65a
...@@ -14,6 +14,7 @@ also re-derive the emotion LABEL from the calibrated V/A (nearest circumplex anc ...@@ -14,6 +14,7 @@ also re-derive the emotion LABEL from the calibrated V/A (nearest circumplex anc
re-grade — if de-compression is right, labels improve too. 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 fit [--stem] # fit + LOO report → calibration.json
python3 emotion_calibrate.py paired # whole-mix vs stem head-to-head, SAME slugs
python3 emotion_calibrate.py apply V A # map one (valence, arousal) point python3 emotion_calibrate.py apply V A # map one (valence, arousal) point
Feeds emotion_ontology/emotion_timeline once trusted. See [[feedback_build_katana_first]].""" Feeds emotion_ontology/emotion_timeline once trusted. See [[feedback_build_katana_first]]."""
...@@ -66,36 +67,49 @@ def _quad(v, a): ...@@ -66,36 +67,49 @@ def _quad(v, a):
return ("+" if v >= 0 else "-") + ("+" if a >= 0 else "-") return ("+" if v >= 0 else "-") + ("+" if a >= 0 else "-")
def fit(stem=False): def _emos():
rows = _pairs(stem) return {t["slug"]: set(t["emotions"]) for t in json.loads(GT.read_text())["tracks"]}
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)
def _evaluate(pv, pa, gv, ga, slugs, emos):
"""raw + in-sample-calibrated + LOO-calibrated metrics for one mode on one track set.
Returns {coef:(va_a,va_b,ar_a,ar_b), raw, cal, loo} where each metric is (err,quad,hit).
LOO = leave-one-out cross-validation, so 'helps' is a generalization claim not an
in-sample illusion. Shared by fit() and paired() so both score identically."""
pv = pv.astype(float); pa = pa.astype(float)
va_a, va_b = _fit_affine(pv, gv) va_a, va_b = _fit_affine(pv, gv)
ar_a, ar_b = _fit_affine(pa, ga) ar_a, ar_b = _fit_affine(pa, ga)
def metrics(vv, aa): def metrics(vv, aa):
err = np.mean(np.hypot(vv - gv, aa - ga)) err = float(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))]) quad = float(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))]) hit = float(np.mean([_nearest_emotion(vv[i], aa[i]) in emos[slugs[i]] for i in range(len(vv))]))
return err, quad, hit return err, quad, hit
raw = metrics(pv, pa) raw = metrics(pv, pa)
cal_in = metrics(va_a * pv + va_b, ar_a * pa + ar_b) 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) loo_v, loo_a = np.zeros_like(pv), np.zeros_like(pa)
for i in range(len(rows)): for i in range(len(slugs)):
m = np.ones(len(rows), bool); m[i] = False m = np.ones(len(slugs), bool); m[i] = False
a1, b1 = _fit_affine(pv[m], gv[m]); a2, b2 = _fit_affine(pa[m], ga[m]) 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 loo_v[i] = a1 * pv[i] + b1; loo_a[i] = a2 * pa[i] + b2
cal_loo = metrics(loo_v, loo_a) cal_loo = metrics(loo_v, loo_a)
return {"coef": (va_a, va_b, ar_a, ar_b), "raw": raw, "cal": cal_in, "loo": cal_loo}
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).")
slugs = [r[4] for r in rows]
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])
ev = _evaluate(pv, pa, gv, ga, slugs, _emos())
va_a, va_b, ar_a, ar_b = ev["coef"]
raw, cal_in, cal_loo = ev["raw"], ev["cal"], ev["loo"]
print(f"⛵ V/A calibration — {len(rows)} tracks ({'stem' if stem else 'whole-mix'})\n") 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" valence: gt ≈ {va_a:.2f}·pred {va_b:+.2f} (×{va_a:.1f} de-compression)")
...@@ -119,6 +133,55 @@ def fit(stem=False): ...@@ -119,6 +133,55 @@ def fit(stem=False):
print(f" ✓ {out.name} (+ {OUT.name})") print(f" ✓ {out.name} (+ {OUT.name})")
def _paired_rows():
"""(slug, mix_v, mix_a, stem_v, stem_a, gt_v, gt_a) for tracks analyzed BOTH ways.
The only honest whole-mix-vs-stem comparison is on the SAME tracks — fit/--stem grade
different-sized sets (whole-mix scales to 534, stems to ~150), so their LOO numbers
aren't directly comparable. This restricts to the common subset."""
gt = {t["slug"]: t for t in json.loads(GT.read_text())["tracks"]}
rows = []
for slug, t in gt.items():
fm = ANALYSIS / f"{slug}_mix.json"
fs = ANALYSIS / f"{slug}.json"
if not (fm.exists() and fs.exists()):
continue
m = json.loads(fm.read_text())["summary"]
s = json.loads(fs.read_text())["summary"]
rows.append((slug, m["valence"], m["arousal"], s["valence"], s["arousal"],
t["valence"], t["arousal"]))
return rows
def paired():
"""Whole-mix vs stem-aware, head-to-head on the SAME slugs — THE #92 decision."""
rows = _paired_rows()
if len(rows) < 6:
sys.exit(f"only {len(rows)} tracks analyzed both whole-mix AND stem — need ≥6. "
f"Run `emotion_corpus.py analyze` (stem) on more tracks first.")
slugs = [r[0] for r in rows]
mv = np.array([r[1] for r in rows]); ma = np.array([r[2] for r in rows])
sv = np.array([r[3] for r in rows]); sa = np.array([r[4] for r in rows])
gv = np.array([r[5] for r in rows]); ga = np.array([r[6] for r in rows])
emos = _emos()
mix = _evaluate(mv, ma, gv, ga, slugs, emos)
stm = _evaluate(sv, sa, gv, ga, slugs, emos)
print(f"⛵ whole-mix vs stem — {len(rows)} tracks (PAIRED, same slugs)\n")
print(f" {'':<22}{'V/A err':>9}{'quad':>7}{'emo':>6}")
for label, ev in (("whole-mix", mix), ("stem", stm)):
for k, tag in (("raw", "raw"), ("loo", "cal·LOO")):
r = ev[k]
print(f" {label+' '+tag:<22}{r[0]:>9.3f}{r[1]*100:>6.0f}%{r[2]*100:>5.0f}%")
win = "whole-mix" if mix["loo"][0] <= stm["loo"][0] else "stem"
d = abs(mix["loo"][0] - stm["loo"][0])
print(f"\n → {win} wins on calibrated LOO V/A err (Δ {d:.3f})")
# conditioning: a big in-sample→LOO gap = overfit (untrustworthy on a small set)
for label, ev in (("whole-mix", mix), ("stem", stm)):
gap = ev["loo"][0] - ev["cal"][0]
flag = " ⚠ overfit" if gap > 0.04 else ""
print(f" {label:<10} in-sample {ev['cal'][0]:.3f} → LOO {ev['loo'][0]:.3f} (gap {gap:+.3f}){flag}")
def apply(v, a): def apply(v, a):
c = json.loads(OUT.read_text()) c = json.loads(OUT.read_text())
return (round(c["valence"]["a"] * v + c["valence"]["b"], 3), return (round(c["valence"]["a"] * v + c["valence"]["b"], 3),
...@@ -129,10 +192,12 @@ def main(): ...@@ -129,10 +192,12 @@ def main():
args = sys.argv[1:] args = sys.argv[1:]
if args and args[0] == "fit": if args and args[0] == "fit":
fit(stem="--stem" in args) fit(stem="--stem" in args)
elif args and args[0] == "paired":
paired()
elif args and args[0] == "apply" and len(args) >= 3: elif args and args[0] == "apply" and len(args) >= 3:
print(apply(float(args[1]), float(args[2]))) print(apply(float(args[1]), float(args[2])))
else: else:
sys.exit("usage: emotion_calibrate.py [fit [--stem] | apply <V> <A>]") sys.exit("usage: emotion_calibrate.py [fit [--stem] | paired | apply <V> <A>]")
if __name__ == "__main__": if __name__ == "__main__":
......
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