"""Interpretability & error-analysis helpers (Plan E). Pure, torch-free functions over a note DataFrame that carries the ground-truth ``velocity`` and a model prediction column (default ``pred``). Kept separate from plotting/model code so the aggregation logic is unit-testable. """ from __future__ import annotations import numpy as np import pandas as pd ON_BEAT_TOL = 0.1 # |phase_beat - nearest pulse| below this = "on-beat" def add_metrical_cols(df: pd.DataFrame, beats_per_bar: int = 4) -> pd.DataFrame: """Derive beat position from the phase features (E-GMD test data is all 4/4). Adds: ``beat_in_bar`` (0-indexed), ``is_downbeat`` (beat 1), ``is_backbeat`` (beats 2 & 4), ``metrical_class`` ("on-beat"/"off-beat" by proximity of ``phase_beat`` to an integer pulse). """ out = df.copy() beat = np.floor(out["phase_bar"].to_numpy() * beats_per_bar).astype(int) beat = np.clip(beat, 0, beats_per_bar - 1) out["beat_in_bar"] = beat out["is_downbeat"] = beat == 0 out["is_backbeat"] = np.isin(beat, [1, 3]) # 0-indexed beats 2 & 4 pb = out["phase_beat"].to_numpy() dist = np.minimum(pb, 1.0 - pb) # distance to nearest pulse out["metrical_class"] = np.where(dist < ON_BEAT_TOL, "on-beat", "off-beat") return out def residual_table(df: pd.DataFrame, by, pred_col: str = "pred") -> pd.DataFrame: """Per-group error decomposition. Returns a frame indexed by the group key with n, true_mean, pred_mean, true_std, pred_std, bias (pred-true), and mae.""" tmp = df.assign(_ae=(df[pred_col] - df["velocity"]).abs()) g = tmp.groupby(by) res = pd.DataFrame({ "n": g.size(), "true_mean": g["velocity"].mean(), "pred_mean": g[pred_col].mean(), "true_std": g["velocity"].std(), "pred_std": g[pred_col].std(), "mae": g["_ae"].mean(), }) res["bias"] = res["pred_mean"] - res["true_mean"] return res def dynamic_level_table(df: pd.DataFrame, pred_col: str = "pred", n_bins: int = 8, lo: float = 0.0, hi: float = 127.0) -> pd.DataFrame: """Bin notes by TRUE velocity and report mean prediction + bias per bin. Exposes regression-to-the-mean: a point estimator predicts soft notes too loud (bias > 0) and loud notes too soft (bias < 0).""" edges = np.linspace(lo, hi, n_bins + 1) b = np.clip(np.digitize(df["velocity"].to_numpy(), edges[1:-1]), 0, n_bins - 1) tmp = df.assign(_bin=b, _ae=(df[pred_col] - df["velocity"]).abs()) g = tmp.groupby("_bin") res = pd.DataFrame({ "n": g.size(), "true_mean": g["velocity"].mean(), "pred_mean": g[pred_col].mean(), "mae": g["_ae"].mean(), }) res["bias"] = res["pred_mean"] - res["true_mean"] res["bin_lo"] = edges[:-1][res.index] res["bin_hi"] = edges[1:][res.index] return res def embedding_2d(weight, n_components: int = 2) -> np.ndarray: """PCA-project embedding rows to 2D via mean-centered SVD (deterministic). weight: array [n, d]. Returns [n, n_components].""" W = np.asarray(weight, dtype=float) Wc = W - W.mean(axis=0, keepdims=True) _, _, Vt = np.linalg.svd(Wc, full_matrices=False) return Wc @ Vt[:n_components].T