| |
| """Train the interpretable acoustic baseline or sweep fixed silence policies.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import hashlib |
| import json |
| import sys |
| from dataclasses import replace |
| from pathlib import Path |
|
|
| ROOT = Path(__file__).resolve().parents[1] |
| SRC = ROOT / "src" |
| if str(SRC) not in sys.path: |
| sys.path.insert(0, str(SRC)) |
|
|
|
|
| def _path(value: str) -> Path: |
| candidate = Path(value) |
| return candidate if candidate.is_absolute() else ROOT / candidate |
|
|
|
|
| def _sha256(path: Path) -> str: |
| digest = hashlib.sha256() |
| with path.open("rb") as handle: |
| for block in iter(lambda: handle.read(1024 * 1024), b""): |
| digest.update(block) |
| return digest.hexdigest() |
|
|
|
|
| def _portable_path(path: Path) -> str: |
| """Prefer a repository-relative path without rejecting external outputs.""" |
|
|
| resolved = path.resolve() |
| try: |
| return resolved.relative_to(ROOT.resolve()).as_posix() |
| except ValueError: |
| return str(resolved) |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description=__doc__) |
| subparsers = parser.add_subparsers(dest="command", required=True) |
|
|
| audio = subparsers.add_parser("audio", help="fit waveform-statistics logistic regression") |
| audio.add_argument("--manifest", default="data/processed/partial-iid-splits.jsonl") |
| audio.add_argument( |
| "--source-root", |
| default=".", |
| help="base for source_file paths stored in the audited manifest", |
| ) |
| audio.add_argument("--train-split", default="train") |
| audio.add_argument("--validation-split", default="validation") |
| audio.add_argument( |
| "--revision", |
| default="e564e2ac567f774d1880aa1db6ce97afb8c519b7", |
| help="immutable upstream revision represented by the manifest", |
| ) |
| audio.add_argument("--output", default="artifacts/partial-baseline") |
| audio.add_argument( |
| "--summary-report", |
| help="optional second location for the compact metrics JSON (for tracked reports)", |
| ) |
| audio.add_argument("--max-examples", type=int, help="debug cap per split") |
| audio.add_argument("--max-seconds", type=float, default=4.0) |
| audio.add_argument("--epochs", type=int, default=800) |
| audio.add_argument("--learning-rate", type=float, default=0.05) |
| audio.add_argument("--l2", type=float, default=1e-3) |
| audio.add_argument("--fpr-budget", type=float, default=0.02) |
|
|
| timeout = subparsers.add_parser("timeouts", help="evaluate fixed VAD silence timeouts") |
| timeout.add_argument("--input", required=True, help="PauseCheckpoint JSONL") |
| timeout.add_argument("--output", required=True) |
| timeout.add_argument( |
| "--timeouts-ms", |
| default="200,400,600,800,1200", |
| help="comma-separated timeout policies", |
| ) |
| return parser.parse_args() |
|
|
|
|
| def _audio(args: argparse.Namespace) -> int: |
| from turn_detection.baselines import extract_manifest_features, fit_logistic_baseline |
| from turn_detection.data import read_manifest, write_json, write_manifest |
| from turn_detection.training.metrics import ( |
| binary_classification_metrics, |
| reliability_bins, |
| threshold_at_max_fpr, |
| ) |
|
|
| manifest = _path(args.manifest) |
| source_root = _path(args.source_root) |
| output = _path(args.output) |
| output.mkdir(parents=True, exist_ok=True) |
| all_rows = list(read_manifest(manifest)) |
| train_rows = [row for row in all_rows if row.get("split") == args.train_split] |
| validation_rows = [row for row in all_rows if row.get("split") == args.validation_split] |
| if not train_rows or not validation_rows: |
| raise SystemExit("both requested train and validation splits must be non-empty") |
| train_groups = {row.get("group_id") for row in train_rows if row.get("group_id")} |
| validation_groups = {row.get("group_id") for row in validation_rows if row.get("group_id")} |
| overlap = train_groups & validation_groups |
| if overlap: |
| raise SystemExit(f"refusing leaky manifest: {len(overlap)} group IDs cross splits") |
| all_groups = train_groups | validation_groups |
| observed_group_rows: dict[str, int] = {} |
| for row in all_rows: |
| group_id = row.get("group_id") |
| if group_id: |
| observed_group_rows[str(group_id)] = observed_group_rows.get(str(group_id), 0) + 1 |
| revisions = { |
| str(row["source_revision"]) for row in all_rows if row.get("source_revision") is not None |
| } |
|
|
| train_x, train_y, train_info = extract_manifest_features( |
| train_rows, |
| source_root=source_root, |
| max_examples=args.max_examples, |
| max_seconds=args.max_seconds, |
| ) |
| validation_x, validation_y, validation_info = extract_manifest_features( |
| validation_rows, |
| source_root=source_root, |
| max_examples=args.max_examples, |
| max_seconds=args.max_seconds, |
| ) |
| model = fit_logistic_baseline( |
| train_x, |
| train_y, |
| epochs=args.epochs, |
| learning_rate=args.learning_rate, |
| l2=args.l2, |
| ) |
| validation_probabilities = model.predict_proba(validation_x) |
| selected = threshold_at_max_fpr( |
| validation_y.tolist(), |
| validation_probabilities.tolist(), |
| max_false_positive_rate=args.fpr_budget, |
| ) |
| model = replace(model, threshold=float(selected["threshold"])) |
| train_probabilities = model.predict_proba(train_x) |
|
|
| model_path = output / "model.json" |
| write_json(model_path, model.to_dict()) |
| report = { |
| "status": "development_only", |
| "scope": "audited local training shard; official test remains sealed", |
| "manifest": args.manifest, |
| "manifest_sha256": _sha256(manifest), |
| "model": { |
| "path": _portable_path(model_path), |
| "bytes": model_path.stat().st_size, |
| "sha256": _sha256(model_path), |
| }, |
| "data_revision": next(iter(revisions)) if len(revisions) == 1 else args.revision, |
| "source_root": args.source_root, |
| "train_split": args.train_split, |
| "validation_split": args.validation_split, |
| "train_examples": int(len(train_y)), |
| "validation_examples": int(len(validation_y)), |
| "group_overlap_count": 0, |
| "group_count": len(all_groups), |
| "largest_group_rows": max(observed_group_rows.values(), default=0), |
| "grouping_note": ( |
| "All observed groups are singleton rows; speaker/conversation/voice identity " |
| "separation is not established." |
| if observed_group_rows and max(observed_group_rows.values()) == 1 |
| else "Groups contain repeated observed linkage keys." |
| ), |
| "hyperparameters": { |
| "epochs": args.epochs, |
| "learning_rate": args.learning_rate, |
| "l2": args.l2, |
| "max_seconds": args.max_seconds, |
| "fpr_budget": args.fpr_budget, |
| }, |
| "threshold_selection": selected, |
| "train_metrics_at_validation_threshold": binary_classification_metrics( |
| train_y.tolist(), train_probabilities.tolist(), model.threshold |
| ), |
| "validation_metrics": binary_classification_metrics( |
| validation_y.tolist(), validation_probabilities.tolist(), model.threshold |
| ), |
| "validation_reliability": reliability_bins( |
| validation_y.tolist(), validation_probabilities.tolist() |
| ), |
| "limitations": [ |
| "One of 83 upstream training shards was locally available.", |
| "This is an interpretable sanity baseline, not the submitted neural model.", |
| "The threshold was selected on this validation split and must not be tuned on test.", |
| ], |
| } |
| write_json(output / "metrics.json", report) |
| if args.summary_report: |
| write_json(_path(args.summary_report), report) |
| predictions = [] |
| for info, probability in zip(validation_info, validation_probabilities.tolist(), strict=True): |
| predictions.append( |
| { |
| **info, |
| "probability": float(probability), |
| "prediction": int(probability >= model.threshold), |
| "threshold": model.threshold, |
| } |
| ) |
| write_manifest(output / "validation_predictions.jsonl", predictions) |
| |
| write_manifest(output / "train_examples.jsonl", train_info) |
| print( |
| json.dumps({"model": model.to_dict(), "validation": report["validation_metrics"]}, indent=2) |
| ) |
| return 0 |
|
|
|
|
| def _timeouts(args: argparse.Namespace) -> int: |
| from turn_detection.baselines import ( |
| fixed_timeout_sweep, |
| load_checkpoints_jsonl, |
| ) |
| from turn_detection.data import write_json |
|
|
| try: |
| timeouts = [float(value.strip()) for value in args.timeouts_ms.split(",") if value.strip()] |
| except ValueError as exc: |
| raise SystemExit("--timeouts-ms must contain comma-separated numbers") from exc |
| if not timeouts: |
| raise SystemExit("provide at least one fixed timeout") |
| checkpoints = load_checkpoints_jsonl(_path(args.input)) |
| results = fixed_timeout_sweep(checkpoints, timeouts) |
| output = _path(args.output) |
| write_json( |
| output, |
| { |
| "baseline": "fixed_silence_timeout", |
| "checkpoint_source": args.input, |
| "results": results, |
| }, |
| ) |
| print(json.dumps(results, indent=2)) |
| return 0 |
|
|
|
|
| def main() -> int: |
| args = parse_args() |
| return _audio(args) if args.command == "audio" else _timeouts(args) |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|