Download scripts/evaluate.py from suvradeepp/tiny-hinglish-turn-detector: direct link, hf CLI and curl.
- Browser
- Download file 23.5 kB
-
https://huggingface.co/suvradeepp/tiny-hinglish-turn-detector/resolve/main/scripts/evaluate.py
- Command line
-
hf download hf://suvradeepp/tiny-hinglish-turn-detector/scripts/evaluate.py
-
curl -L -o evaluate.py https://huggingface.co/suvradeepp/tiny-hinglish-turn-detector/resolve/main/scripts/evaluate.py
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()) | |