suvradeepp's picture
Publish Tiny Hinglish Turn Detector development preview
35d483e verified
Raw
History Blame Contribute Delete
23.5 kB
#!/usr/bin/env python3
"""Evaluate a checkpoint with product-correct metrics and subgroup slices."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
from pathlib import Path
from typing import Any
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = REPOSITORY_ROOT / "src"
if str(SOURCE_ROOT) not in sys.path:
sys.path.insert(0, str(SOURCE_ROOT))
OFFICIAL_TEST_DATASET_ID = "pipecat-ai/smart-turn-data-v3.2-test"
OFFICIAL_TEST_REVISION = "0500378e8ed6d38e37b016e24d261e8e6c6a6859"
OFFICIAL_TEST_EXPECTED_ROWS = 31_527
OFFICIAL_TEST_LOCAL_DIR = REPOSITORY_ROOT / "data/raw/smart-turn-data-v3.2-test"
OFFICIAL_TEST_HF_SOURCES = {
OFFICIAL_TEST_DATASET_ID,
f"hf://datasets/{OFFICIAL_TEST_DATASET_ID}",
}
OFFICIAL_TEST_SHARDS = {
"data/train-00000-of-00010.parquet": (
486_502_678,
"a87c75806b814ee7379998b6f9dc65a6433c01bfec2875e62c5d1ccd2b37257a",
),
"data/train-00001-of-00010.parquet": (
489_429_742,
"c408bd3b31cc3cb907280fa5d3186f0f5ba08c6beb84532a3828d685b967b7d8",
),
"data/train-00002-of-00010.parquet": (
479_920_042,
"2b50ff3346f8aecc6c4b0b706b593c5b3b174b73ce84c795b515b68e6abb3788",
),
"data/train-00003-of-00010.parquet": (
486_565_988,
"0afd86b7d1cdf03ffb804a00278fb76477d29ef008b71ad1d7a746bb1b25850c",
),
"data/train-00004-of-00010.parquet": (
477_831_330,
"4c600774512010880f72dd86cc8abe5d46df594615e472351bd270cc94e1ff66",
),
"data/train-00005-of-00010.parquet": (
495_015_303,
"b4537a3b96498481b98c5d60b8d84ad05109ae0acbeafc18759b15ff1d0d9335",
),
"data/train-00006-of-00010.parquet": (
481_054_312,
"ef0eb0085b55e05fc5594f035c8011afb78d039e1b3e50c579a96e9295d9acec",
),
"data/train-00007-of-00010.parquet": (
478_266_840,
"eddd1db2f95fff2f08e18ca1fe73fe0c5d9eb8030bea97da2d109297a0158b67",
),
"data/train-00008-of-00010.parquet": (
479_859_534,
"96314dc8bb77515a5d1d02e8cb7c1410da54169d33d6f73ca2f87738ec1269f0",
),
"data/train-00009-of-00010.parquet": (
483_305_860,
"769283c79bea4ae6eebdfe7d09fe481f8a154c53b5e3c29fe2c71d7b55dfc862",
),
}
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:
try:
return path.resolve().relative_to(REPOSITORY_ROOT).as_posix()
except ValueError:
return path.name
def _evaluation_source_evidence(
source: str | None,
*,
dataset_id: str | None,
revision: str | None,
) -> dict[str, Any]:
local = _local_source_path(source)
if local is not None and local.is_file() and not local.is_symlink():
return {
"kind": "file",
"path": _portable_path(local),
"bytes": local.stat().st_size,
"sha256": _sha256(local),
"dataset_id": dataset_id,
"revision": revision,
}
if local is not None:
return {
"kind": "directory",
"path": _portable_path(local),
"dataset_id": dataset_id,
"revision": revision,
}
return {
"kind": "huggingface",
"identifier": dataset_id or source,
"revision": revision,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--checkpoint", required=True)
parser.add_argument("--source", help="manifest, local dataset, or HF dataset ID")
parser.add_argument(
"--dataset-id",
help=(
"source provenance identity; required and checked for a local official-test snapshot"
),
)
parser.add_argument("--revision", help="immutable dataset revision")
parser.add_argument("--split", default="test")
parser.add_argument("--source-root", help="base directory for manifest source_file entries")
parser.add_argument("--output", default="artifacts/evaluation/metrics.json")
parser.add_argument(
"--predictions-output",
help="optional JSONL path (default: beside metrics); keep raw record IDs out of reports/",
)
parser.add_argument("--batch-size", type=int, default=32)
parser.add_argument("--num-workers", type=int, default=0)
parser.add_argument("--max-examples", type=int)
parser.add_argument("--threshold", type=float, help="default: calibrated checkpoint threshold")
parser.add_argument("--min-slice-count", type=int, default=25)
parser.add_argument("--bootstrap-samples", type=int, default=1_000)
parser.add_argument("--device", default="auto")
parser.add_argument("--smoke-test", action="store_true")
parser.add_argument(
"--allow-sealed-test",
action="store_true",
help="explicitly permit the official smart-turn test after model/threshold freeze",
)
parser.add_argument(
"--frozen-manifest",
help="hash-bound manifest produced by scripts/freeze_candidate.py",
)
return parser.parse_args()
def _device(torch: Any, requested: str) -> Any:
if requested != "auto":
return torch.device(requested)
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
def _hf_dataset_id_from_source(source: str | None) -> str | None:
"""Return an exact canonical HF identity; never infer one from a path basename."""
if not source:
return None
normalized = source.rstrip("/")
if normalized in OFFICIAL_TEST_HF_SOURCES:
return OFFICIAL_TEST_DATASET_ID
return None
def _is_official_test_source(
source: str | None,
dataset_id: str | None = None,
) -> bool:
inferred = _hf_dataset_id_from_source(source)
if dataset_id is not None and inferred is not None and dataset_id != inferred:
raise ValueError("--dataset-id conflicts with the exact Hugging Face source identity")
return (dataset_id or inferred) == OFFICIAL_TEST_DATASET_ID
def _local_source_path(source: str | None) -> Path | None:
if not source:
return None
candidate = Path(source).expanduser()
if not candidate.is_absolute():
candidate = REPOSITORY_ROOT / candidate
return candidate.resolve() if candidate.exists() else None
def _validate_official_test_request(args: argparse.Namespace) -> bool:
"""Fail closed when any official-test control is incomplete or contradictory."""
try:
official = _is_official_test_source(args.source, args.dataset_id)
except ValueError as exc:
raise SystemExit(str(exc)) from exc
local_source = _local_source_path(args.source)
canonical_local_source = (
local_source is not None and local_source == OFFICIAL_TEST_LOCAL_DIR.resolve()
)
official_controls_used = bool(args.allow_sealed_test or args.frozen_manifest)
if canonical_local_source and args.dataset_id != OFFICIAL_TEST_DATASET_ID:
raise SystemExit(
f"the local official-test snapshot requires --dataset-id {OFFICIAL_TEST_DATASET_ID}"
)
if official_controls_used and not official:
raise SystemExit(
"official-test controls require the exact dataset identity "
f"--dataset-id {OFFICIAL_TEST_DATASET_ID} (or that exact HF source)"
)
if not official:
return False
if args.smoke_test:
raise SystemExit("official-test provenance cannot be combined with --smoke-test")
if args.revision != OFFICIAL_TEST_REVISION:
raise SystemExit(
"official test requires the exact pinned revision: " + OFFICIAL_TEST_REVISION
)
if not args.allow_sealed_test or not args.frozen_manifest:
raise SystemExit("official test requires both --allow-sealed-test and --frozen-manifest")
if args.max_examples is not None:
raise SystemExit("--max-examples is forbidden for official-test evaluation")
if args.split not in {"test", "train"}:
raise SystemExit(
"official-test --split must be semantic 'test' or physical Hugging Face 'train'"
)
return True
def _verify_local_official_snapshot(source: Path) -> int:
"""Verify the immutable local snapshot before loading any official-test rows."""
if not source.is_dir() or source.is_symlink():
raise ValueError("local official-test source must be a non-symlink directory")
actual = {
path.relative_to(source).as_posix() for path in source.rglob("*.parquet") if path.is_file()
}
expected = set(OFFICIAL_TEST_SHARDS)
if actual != expected:
missing = sorted(expected - actual)
unexpected = sorted(actual - expected)
raise ValueError(
f"official-test shard inventory mismatch: missing={missing}, unexpected={unexpected}"
)
try:
import pyarrow.parquet as parquet
except ImportError as exc:
raise ValueError("official-test snapshot verification requires pyarrow") from exc
rows = 0
for relative, (expected_bytes, expected_sha256) in OFFICIAL_TEST_SHARDS.items():
path = source / relative
if path.is_symlink() or not path.is_file():
raise ValueError(f"official-test shard is not a regular file: {relative}")
if path.stat().st_size != expected_bytes:
raise ValueError(f"official-test shard size mismatch: {relative}")
if _sha256(path) != expected_sha256:
raise ValueError(f"official-test shard SHA-256 mismatch: {relative}")
try:
rows += int(parquet.ParquetFile(path).metadata.num_rows)
except Exception as exc:
raise ValueError(f"cannot read official-test Parquet metadata: {relative}") from exc
if rows != OFFICIAL_TEST_EXPECTED_ROWS:
raise ValueError(
"official-test row-count mismatch: "
f"expected {OFFICIAL_TEST_EXPECTED_ROWS}, found {rows}"
)
return rows
def main() -> int:
args = parse_args()
official_test = _validate_official_test_request(args)
# The dedicated repository is semantically the official test set, but its
# sole physical Hugging Face split is named ``train``. Keep reports honest
# while loading the upstream layout correctly.
source_split = "train" if official_test else args.split
report_split = "test" if official_test else args.split
try:
import torch
except ImportError as exc:
raise SystemExit("Evaluation requires PyTorch") from exc
from turn_detection.models import LogMelConfig, LogMelFrontend, load_model_checkpoint
from turn_detection.provenance import verify_freeze_manifest
from turn_detection.training.datasets import (
AudioFeatureCollator,
SyntheticFeatureDataset,
build_record_dataloader,
)
from turn_detection.training.metrics import (
binary_classification_metrics,
grouped_bootstrap_interval,
metrics_at_fpr_budgets,
operational_metrics,
reliability_bins,
sliced_metrics,
)
checkpoint_path = Path(args.checkpoint)
if not checkpoint_path.is_absolute():
checkpoint_path = REPOSITORY_ROOT / checkpoint_path
device = _device(torch, args.device)
model, checkpoint = load_model_checkpoint(checkpoint_path, map_location=device)
model.to(device).eval()
metadata = dict(checkpoint.get("metadata", {}))
run_metadata = metadata.get("run_metadata", {})
if not isinstance(run_metadata, dict):
run_metadata = {}
smoke_checkpoint = bool(metadata.get("smoke_test", False))
training_status = str(
run_metadata.get("status", "smoke" if smoke_checkpoint else "development")
)
development_only = smoke_checkpoint or training_status.lower() != "final"
feature_config = LogMelConfig.from_mapping(metadata.get("feature_config", {}))
frontend = LogMelFrontend(feature_config)
max_seconds = float(metadata.get("max_seconds", 8.0))
threshold = float(
args.threshold if args.threshold is not None else checkpoint.get("threshold", 0.5)
)
dataset_revision = args.revision
freeze_manifest_sha256: str | None = None
if official_test:
if not args.allow_sealed_test or not args.frozen_manifest:
raise SystemExit(
"official test requires both --allow-sealed-test and --frozen-manifest"
)
frozen_path = Path(args.frozen_manifest)
if not frozen_path.is_absolute():
frozen_path = REPOSITORY_ROOT / frozen_path
try:
frozen = verify_freeze_manifest(
frozen_path,
REPOSITORY_ROOT,
checkpoint_path=checkpoint_path,
threshold=threshold,
)
except ValueError as exc:
raise SystemExit(f"official-test freeze validation failed: {exc}") from exc
frozen_dataset = frozen["official_test"]
if frozen_dataset.get("dataset_id") != OFFICIAL_TEST_DATASET_ID:
raise SystemExit("official-test freeze targets the wrong dataset identity")
if frozen_dataset.get("revision") != args.revision:
raise SystemExit("official-test revision differs from frozen manifest")
freeze_manifest_sha256 = _sha256(frozen_path)
local_source = _local_source_path(args.source)
if local_source is not None:
try:
_verify_local_official_snapshot(local_source)
except ValueError as exc:
raise SystemExit(f"official-test snapshot validation failed: {exc}") from exc
elif _hf_dataset_id_from_source(args.source) is None:
raise SystemExit("declared local official-test source does not exist")
if args.smoke_test:
from torch.utils.data import DataLoader
dataset = SyntheticFeatureDataset(32, feature_config.n_mels, 96, seed=18)
loader = DataLoader(
dataset,
batch_size=args.batch_size,
collate_fn=AudioFeatureCollator(frontend, max_seconds=1.0),
)
else:
if not args.source:
raise SystemExit("--source is required unless --smoke-test is used")
loader = build_record_dataloader(
args.source,
split=source_split,
frontend=frontend,
batch_size=args.batch_size,
max_seconds=max_seconds,
shuffle=False,
num_workers=args.num_workers,
token=os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN"),
revision=dataset_revision,
max_examples=args.max_examples,
source_root=args.source_root,
)
labels: list[int] = []
probabilities: list[float] = []
records: list[dict[str, Any]] = []
languages: list[Any] = []
datasets: list[Any] = []
synthetic_values: list[Any] = []
filler_types: list[str] = []
duration_bins: list[str] = []
turn_ids: list[str] = []
turn_id_observed: list[bool] = []
group_ids: list[str] = []
durations: list[float] = []
with torch.inference_mode():
for batch in loader:
features = batch["log_mel"].to(device)
mask = batch["attention_mask"].to(device)
output = model(features, mask)
batch_probabilities = torch.sigmoid(output.endpoint_logits).cpu().tolist()
batch_labels = [int(value) for value in batch["endpoint"].tolist()]
for index, (target, probability) in enumerate(
zip(batch_labels, batch_probabilities, strict=True)
):
midfiller = float(batch["midfiller"][index])
endfiller = float(batch["endfiller"][index])
if midfiller == 1.0:
filler_type = "midfiller"
elif endfiller == 1.0:
filler_type = "endfiller"
elif midfiller == 0.0 and endfiller == 0.0:
filler_type = "no_filler"
else:
filler_type = "unknown"
duration = batch["duration_seconds"][index]
if duration is None:
duration_bin = "unknown"
elif float(duration) < 2.0:
duration_bin = "<2s"
elif float(duration) < 4.0:
duration_bin = "2-4s"
elif float(duration) < 8.0:
duration_bin = "4-8s"
else:
duration_bin = ">=8s"
record = {
"record_id": batch["record_id"][index],
"turn_id": batch["turn_id"][index],
"group_id": batch["group_id"][index],
"label": target,
"probability": float(probability),
"prediction": int(probability >= threshold),
"language": batch["language"][index],
"dataset": batch["dataset"][index],
"synthetic": batch["synthetic"][index],
"filler_type": filler_type,
"duration_bin": duration_bin,
}
records.append(record)
filler_types.append(filler_type)
duration_bins.append(duration_bin)
labels.extend(batch_labels)
probabilities.extend(float(value) for value in batch_probabilities)
languages.extend(batch["language"])
datasets.extend(batch["dataset"])
synthetic_values.extend(batch["synthetic"])
turn_ids.extend(batch["turn_id"])
turn_id_observed.extend(bool(value) for value in batch["turn_id_observed"])
group_ids.extend(batch["group_id"])
durations.extend(
float(value) for value in batch["duration_seconds"] if value is not None
)
if not labels:
raise SystemExit("evaluation source produced no examples")
if official_test and len(labels) != OFFICIAL_TEST_EXPECTED_ROWS:
raise SystemExit(
"official-test evaluation was incomplete: "
f"expected {OFFICIAL_TEST_EXPECTED_ROWS} examples, evaluated {len(labels)}"
)
total_audio_seconds = sum(durations) if len(durations) == len(labels) else None
slice_report = sliced_metrics(
labels,
probabilities,
{
"language": languages,
"dataset": datasets,
"synthetic": synthetic_values,
"filler_type": filler_types,
"duration_bin": duration_bins,
},
threshold=threshold,
min_count=args.min_slice_count,
)
def worst_group(metric: str, maximize: bool) -> dict[str, Any] | None:
candidates: list[dict[str, Any]] = []
for dimension, values in slice_report.items():
for value, metrics in values.items():
score = metrics.get(metric)
if score is not None:
candidates.append(
{
"dimension": dimension,
"value": value,
"count": metrics["count"],
metric: score,
}
)
if not candidates:
return None
return sorted(candidates, key=lambda item: float(item[metric]), reverse=maximize)[0]
has_observed_turn_ids = bool(turn_id_observed) and all(turn_id_observed)
operational = operational_metrics(
labels,
probabilities,
threshold,
turn_ids=turn_ids if has_observed_turn_ids else None,
total_audio_seconds=total_audio_seconds,
)
operational["sequence_metrics_available"] = has_observed_turn_ids
operational["scope"] = (
"observed turn/conversation sequences"
if has_observed_turn_ids
else "independent labeled clips; per-hour rate is a clip-normalized proxy, not an "
"online conversation measurement"
)
if not has_observed_turn_ids:
operational["sequence_metrics_unavailable_reason"] = (
"source records contain no genuine turn_id or conversation_id"
)
report = {
"checkpoint": _portable_path(checkpoint_path),
"checkpoint_sha256": _sha256(checkpoint_path),
"dataset_revision": dataset_revision,
"official_test": official_test,
"freeze_manifest_sha256": freeze_manifest_sha256,
"split": report_split,
"source_split": source_split,
"evaluation_source": _evaluation_source_evidence(
args.source,
dataset_id=args.dataset_id,
revision=args.revision or metadata.get("data_revision"),
),
"development_only": development_only,
"training_status": training_status,
"data_scope": metadata.get("data_scope"),
"data_revision": metadata.get("data_revision"),
"threshold": threshold,
"metrics": binary_classification_metrics(labels, probabilities, threshold),
"operating_points": metrics_at_fpr_budgets(labels, probabilities),
"operating_points_note": (
"Label-dependent threshold sweep for curve analysis only. The deployed result is "
"`metrics` at the checkpoint's frozen validation-selected threshold."
),
"operational": operational,
"slices": slice_report,
"worst_groups": {
"highest_false_positive_rate": worst_group("false_positive_rate", True),
"lowest_recall": worst_group("recall", False),
},
"grouped_bootstrap_95ci": {
"false_positive_rate": grouped_bootstrap_interval(
labels,
probabilities,
group_ids,
threshold,
metric="false_positive_rate",
samples=args.bootstrap_samples,
seed=17,
),
"recall": grouped_bootstrap_interval(
labels,
probabilities,
group_ids,
threshold,
metric="recall",
samples=args.bootstrap_samples,
seed=17,
),
},
"reliability": reliability_bins(labels, probabilities),
}
output_path = Path(args.output)
if not output_path.is_absolute():
output_path = REPOSITORY_ROOT / output_path
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(
json.dumps(report, indent=2, sort_keys=True, allow_nan=False), encoding="utf-8"
)
prediction_path = (
Path(args.predictions_output)
if args.predictions_output
else output_path.with_name(output_path.stem + ".predictions.jsonl")
)
if not prediction_path.is_absolute():
prediction_path = REPOSITORY_ROOT / prediction_path
prediction_path.parent.mkdir(parents=True, exist_ok=True)
with prediction_path.open("w", encoding="utf-8") as handle:
for record in records:
handle.write(json.dumps(record, ensure_ascii=False, allow_nan=False) + "\n")
print(json.dumps({"metrics": report["metrics"], "output": str(output_path)}, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())