""" Phase 3, Steps 4-7: run EXP-001 (energy/silence), EXP-002 (classical features + logistic regression), EXP-003 (global vs. global+recent-window features), plus slice and filler analysis — all on the REAL 300-clip sample materialized by scripts/phase3_inventory_and_sample.py from the uploaded pipecat-ai/smart-turn-data-v3.2-train shard. Labeled everywhere as SMALL REAL-AUDIO VALIDATION — this is 300 clips from ONE of ten training shards, not a claim about full-dataset performance. The official test set (pipecat-ai/smart-turn-data-v3.2-test) is never touched by this script. """ from __future__ import annotations import csv import json import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) import numpy as np from turn_detector.audio_io import load_audio from turn_detector.baseline_energy import EnergySilenceBaseline, EnergySilenceConfig, tune_threshold from turn_detector.baseline_classifier import ClassifierBaseline, ClassifierBaselineConfig from turn_detector.features import FeatureConfig, extract_features from turn_detector.evaluation import classification_metrics, slice_metrics, measure_inference_latency from turn_detector.splits import split_random META_PATH = Path("data/raw/phase3_sample/metadata.csv") OUT_DIR = Path("data/raw/phase3_sample") def load_dataset(): with open(META_PATH) as f: rows = list(csv.DictReader(f)) for r in rows: r["endpoint_bool"] = r["endpoint_bool"] == "True" r["synthetic"] = r["synthetic"] == "True" r["midfiller"] = {"True": True, "False": False, "": None}[r["midfiller"]] r["endfiller"] = {"True": True, "False": False, "": None}[r["endfiller"]] r["duration_sec"] = float(r["duration_sec"]) return rows def main(): rows = load_dataset() print(f"Loaded {len(rows)} real metadata rows.") print("Loading real audio into memory (float32 arrays)...") t0 = time.time() for r in rows: r["_audio"] = load_audio(r["audio_path"]) print(f" loaded {len(rows)} clips in {time.time()-t0:.1f}s") # ---- dev/val split (random, 70/30, seed=42) — NOT the official test set ---- dev, val = split_random(rows, val_frac=0.3, seed=42) print(f"\nDev/val split: {len(dev)} dev / {len(val)} val (random split, seed=42)") print(f"Dev label balance: {sum(r['endpoint_bool'] for r in dev)}/{len(dev)} END") print(f"Val label balance: {sum(r['endpoint_bool'] for r in val)}/{len(val)} END") results = {"label": "SMALL REAL-AUDIO VALIDATION", "n_total": len(rows), "n_dev": len(dev), "n_val": len(val), "source_shard": "train-00000-of-00010.parquet"} # ========================================================================= # EXP-001: energy/silence baseline — tune threshold on dev, eval on val # ========================================================================= print("\n" + "=" * 70) print("EXP-001: Energy/Silence baseline") print("=" * 70) cfg = EnergySilenceConfig(silence_rms_threshold=0.02) dev_pairs = [(r["_audio"], r["endpoint_bool"]) for r in dev] best_thresh, tuning_curve = tune_threshold(dev_pairs, cfg, candidate_thresholds_sec=np.arange(0.05, 1.55, 0.05)) print(f"Tuned silence_duration_threshold_sec = {best_thresh:.2f} (dev accuracy = {tuning_curve[best_thresh]['accuracy']:.3f})") cfg_tuned = EnergySilenceConfig(silence_rms_threshold=0.02, silence_duration_threshold_sec=best_thresh) baseline1 = EnergySilenceBaseline(cfg_tuned) val_preds_1 = np.array([baseline1.predict(r["_audio"]) for r in val]) val_true = np.array([r["endpoint_bool"] for r in val]) metrics_1 = classification_metrics(val_true, val_preds_1) print(json.dumps(metrics_1, indent=2)) results["EXP-001"] = { "tuned_threshold_sec": best_thresh, "tuning_curve_dev": tuning_curve, "val_metrics": metrics_1, } # ========================================================================= # EXP-002: classical features + logistic regression # ========================================================================= print("\n" + "=" * 70) print("EXP-002: Classical features + Logistic Regression") print("=" * 70) clf = ClassifierBaseline(ClassifierBaselineConfig()) dev_audios = [r["_audio"] for r in dev] dev_labels = [r["endpoint_bool"] for r in dev] val_audios = [r["_audio"] for r in val] t0 = time.time() clf.fit(dev_audios, dev_labels) fit_time = time.time() - t0 val_preds_2 = clf.predict(val_audios) metrics_2 = classification_metrics(val_true, val_preds_2) print(json.dumps(metrics_2, indent=2)) latency_info = measure_inference_latency(lambda a: clf.predict_proba([a]), val_audios, n_warmup=3) print("Inference latency (per single clip, this machine):", json.dumps(latency_info, indent=2)) top_feats = clf.top_features(10) print("Top 10 |coefficient| features:", top_feats) results["EXP-002"] = { "fit_time_sec_dev_set": fit_time, "n_features": len(clf.feature_order_), "param_count": clf.param_count(), "model_size_bytes": clf.model_size_bytes(), "val_metrics": metrics_2, "inference_latency": latency_info, "top_features": [(k, float(v)) for k, v in top_feats], } # ========================================================================= # EXP-003: global-only vs global+recent-window features # ========================================================================= print("\n" + "=" * 70) print("EXP-003: Global-only vs Global+recent-window features") print("=" * 70) # A: global-only (disable recent windows in the classifier's feature extraction) class GlobalOnlyClassifier(ClassifierBaseline): def _featurize(self, audios): return [extract_features(a, self.cfg.feature_cfg, include_recent_windows=False) for a in audios] clf_global = GlobalOnlyClassifier(ClassifierBaselineConfig()) clf_global.fit(dev_audios, dev_labels) preds_global = clf_global.predict(val_audios) metrics_global = classification_metrics(val_true, preds_global) # B: global + recent-window (the default ClassifierBaseline, already fit above as `clf`) metrics_both = metrics_2 # already computed with recent windows included (default) delta_f1 = metrics_both["f1"] - metrics_global["f1"] delta_false_end = ( (metrics_both["false_end_rate"] - metrics_global["false_end_rate"]) if metrics_both["false_end_rate"] is not None and metrics_global["false_end_rate"] is not None else None ) delta_false_continue = ( (metrics_both["false_continue_rate"] - metrics_global["false_continue_rate"]) if metrics_both["false_continue_rate"] is not None and metrics_global["false_continue_rate"] is not None else None ) print("Global-only metrics:", json.dumps(metrics_global, indent=2)) print("Global+recent-window metrics:", json.dumps(metrics_both, indent=2)) print(f"Delta F1 (both - global-only): {delta_f1:+.4f}") print(f"Delta false_end_rate: {delta_false_end}") print(f"Delta false_continue_rate: {delta_false_continue}") results["EXP-003"] = { "global_only_val_metrics": metrics_global, "global_plus_recent_window_val_metrics": metrics_both, "delta_f1": delta_f1, "delta_false_end_rate": delta_false_end, "delta_false_continue_rate": delta_false_continue, } # ========================================================================= # Slice / filler analysis (on EXP-002's val predictions) # ========================================================================= print("\n" + "=" * 70) print("Slice analysis (EXP-002 val predictions)") print("=" * 70) val_language = np.array([r["language"] for r in val]) val_synthetic = np.array([r["synthetic"] for r in val]) lang_slices = slice_metrics(val_true, val_preds_2, val_language, min_samples=10) synth_slices = slice_metrics(val_true, val_preds_2, val_synthetic, min_samples=10) print("By language (min_samples=10):", json.dumps(lang_slices, indent=2)) print("By synthetic flag (min_samples=10):", json.dumps(synth_slices, indent=2)) # Filler analysis — NULL != False. Only compare rows where midfiller is # actually populated (not None), split True vs False. filler_known = [r for r in val if r["midfiller"] is not None] filler_unknown_n = len(val) - len(filler_known) print(f"\nFiller metadata: {len(filler_known)}/{len(val)} val rows have midfiller populated; " f"{filler_unknown_n} are NULL (metadata unavailable, NOT treated as 'no filler').") filler_results = None if len(filler_known) >= 20: fk_audios = [r["_audio"] for r in filler_known] fk_true = np.array([r["endpoint_bool"] for r in filler_known]) fk_preds = clf.predict(fk_audios) fk_midfiller = np.array(["has_midfiller" if r["midfiller"] else "no_midfiller" for r in filler_known]) filler_results = slice_metrics(fk_true, fk_preds, fk_midfiller, min_samples=10) print("Filler slice results:", json.dumps(filler_results, indent=2)) else: print("Too few val rows with populated filler metadata for a meaningful slice comparison (exploratory only).") results["slice_analysis"] = { "by_language": lang_slices, "by_synthetic": synth_slices, "filler_known_n": len(filler_known), "filler_unknown_n": filler_unknown_n, "filler_slice_results": filler_results, } # ========================================================================= # Save predictions for error analysis + full results JSON # ========================================================================= val_predictions_export = [] for r, pred1, pred2, true_label in zip(val, val_preds_1, val_preds_2, val_true): val_predictions_export.append({ "id": r["id"], "language": r["language"], "dataset": r["dataset"], "synthetic": r["synthetic"], "midfiller": r["midfiller"], "endfiller": r["endfiller"], "duration_sec": r["duration_sec"], "endpoint_bool_true": bool(true_label), "exp001_pred": bool(pred1), "exp002_pred": bool(pred2), "exp001_correct": bool(pred1 == true_label), "exp002_correct": bool(pred2 == true_label), "audio_path": r["audio_path"], }) with open(OUT_DIR / "val_predictions.json", "w") as f: json.dump(val_predictions_export, f, indent=2) with open(OUT_DIR / "phase3_results.json", "w") as f: json.dump(results, f, indent=2, default=str) print(f"\nSaved val predictions to {OUT_DIR / 'val_predictions.json'}") print(f"Saved full results to {OUT_DIR / 'phase3_results.json'}") if __name__ == "__main__": main()