Download scripts/export_onnx.py from suvradeepp/tiny-hinglish-turn-detector: direct link, hf CLI and curl.
- Browser
- Download file 17.9 kB
-
https://huggingface.co/suvradeepp/tiny-hinglish-turn-detector/resolve/main/scripts/export_onnx.py
- Command line
-
hf download hf://suvradeepp/tiny-hinglish-turn-detector/scripts/export_onnx.py
-
curl -L -o export_onnx.py https://huggingface.co/suvradeepp/tiny-hinglish-turn-detector/resolve/main/scripts/export_onnx.py
17.9 kB
| #!/usr/bin/env python3 | |
| """Export a self-describing endpoint model to ONNX and optionally INT8.""" | |
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import inspect | |
| import json | |
| import math | |
| 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)) | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--checkpoint", required=True) | |
| parser.add_argument("--output", required=True, help="FP32 .onnx output path") | |
| parser.add_argument("--opset", type=int, default=17) | |
| parser.add_argument( | |
| "--quantize", | |
| choices=("none", "dynamic", "static"), | |
| default="none", | |
| help="dynamic suits transformers; static suits the TinyTCN CNN", | |
| ) | |
| parser.add_argument( | |
| "--calibration-npz", | |
| help="static INT8 arrays: log_mel [N,M,T], frame_mask [N,T]", | |
| ) | |
| parser.add_argument("--skip-parity", action="store_true") | |
| return parser.parse_args() | |
| 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 _file_evidence(path: Path) -> dict[str, Any]: | |
| if path.is_symlink() or not path.is_file(): | |
| raise SystemExit(f"cannot bind non-regular source file: {path}") | |
| resolved = path.resolve() | |
| try: | |
| portable = resolved.relative_to(REPOSITORY_ROOT).as_posix() | |
| except ValueError: | |
| portable = resolved.name | |
| return { | |
| "path": portable, | |
| "bytes": resolved.stat().st_size, | |
| "sha256": _sha256(resolved), | |
| } | |
| def _deployment_source_paths() -> list[Path]: | |
| """Return the exact executable source surface shipped with an export.""" | |
| paths = [ | |
| *sorted((REPOSITORY_ROOT / "src" / "turn_detection").rglob("*.py")), | |
| *sorted((REPOSITORY_ROOT / "scripts").glob("*.py")), | |
| *sorted((REPOSITORY_ROOT / "scripts").glob("*.sh")), | |
| *( | |
| path | |
| for path in sorted((REPOSITORY_ROOT / "deployment").rglob("*")) | |
| if path.is_file() and "__pycache__" not in path.parts and path.suffix != ".pyc" | |
| ), | |
| REPOSITORY_ROOT / "app.py", | |
| REPOSITORY_ROOT / "pyproject.toml", | |
| REPOSITORY_ROOT / "space" / "requirements.txt", | |
| *sorted(REPOSITORY_ROOT.glob("requirements-*.txt")), | |
| ] | |
| return sorted(set(paths), key=lambda path: path.relative_to(REPOSITORY_ROOT).as_posix()) | |
| def _legacy_export_without_onnx_package( | |
| torch: Any, | |
| model: Any, | |
| model_args: tuple[Any, ...], | |
| output_path: Path, | |
| *, | |
| input_names: list[str], | |
| output_names: list[str], | |
| dynamic_axes: dict[str, dict[int, str]], | |
| opset: int, | |
| ) -> None: | |
| """Serialize via Torch's private legacy graph only when ``onnx`` is absent. | |
| This narrow fallback is useful in network-restricted build environments. | |
| It is intentionally not used for arbitrary exporter failures, and the | |
| resulting graph is still required to pass ONNX Runtime parity below. | |
| """ | |
| graph, params, _ = torch.onnx.utils._model_to_graph( | |
| model, | |
| model_args, | |
| input_names=input_names, | |
| output_names=output_names, | |
| operator_export_type=torch.onnx.OperatorExportTypes.ONNX, | |
| do_constant_folding=True, | |
| training=torch.onnx.TrainingMode.EVAL, | |
| dynamic_axes=dynamic_axes, | |
| ) | |
| serialized, *_ = graph._export_onnx( | |
| params, | |
| opset, | |
| dynamic_axes, | |
| False, | |
| torch.onnx.OperatorExportTypes.ONNX, | |
| True, | |
| False, | |
| {}, | |
| True, | |
| "", | |
| {}, | |
| ) | |
| output_path.write_bytes(serialized) | |
| def _quantize_dynamic(source: Path, destination: Path) -> None: | |
| try: | |
| from onnxruntime.quantization import QuantType, quantize_dynamic | |
| except ImportError as exc: | |
| raise SystemExit("INT8 export requires onnxruntime") from exc | |
| quantize_dynamic( | |
| str(source), | |
| str(destination), | |
| weight_type=QuantType.QInt8, | |
| per_channel=True, | |
| ) | |
| def _quantize_static(source: Path, destination: Path, calibration_path: Path) -> None: | |
| try: | |
| import numpy as np | |
| from onnxruntime.quantization import ( | |
| CalibrationDataReader, | |
| CalibrationMethod, | |
| QuantFormat, | |
| QuantType, | |
| quantize_static, | |
| ) | |
| except ImportError as exc: | |
| raise SystemExit("static INT8 export requires numpy and onnxruntime") from exc | |
| loaded = np.load(calibration_path) | |
| if "log_mel" not in loaded or "frame_mask" not in loaded: | |
| raise SystemExit("calibration NPZ needs log_mel and frame_mask arrays") | |
| features = loaded["log_mel"].astype("float32") | |
| masks = loaded["frame_mask"].astype("float32") | |
| if features.ndim != 3 or masks.shape != (features.shape[0], features.shape[2]): | |
| raise SystemExit("invalid calibration shapes") | |
| class Reader(CalibrationDataReader): | |
| def __init__(self) -> None: | |
| self.index = 0 | |
| def get_next(self) -> dict[str, Any] | None: | |
| if self.index >= features.shape[0]: | |
| return None | |
| item = { | |
| "log_mel": features[self.index : self.index + 1], | |
| "frame_mask": masks[self.index : self.index + 1], | |
| } | |
| self.index += 1 | |
| return item | |
| quantize_static( | |
| str(source), | |
| str(destination), | |
| Reader(), | |
| quant_format=QuantFormat.QDQ, | |
| activation_type=QuantType.QInt8, | |
| weight_type=QuantType.QInt8, | |
| per_channel=True, | |
| calibrate_method=CalibrationMethod.MinMax, | |
| ) | |
| def _parity_check(model_path: Path, features: Any, mask: Any, expected: Any) -> float: | |
| try: | |
| import numpy as np | |
| import onnxruntime as ort | |
| except ImportError as exc: | |
| raise SystemExit("ONNX parity checking requires numpy and onnxruntime") from exc | |
| session = ort.InferenceSession(str(model_path), providers=["CPUExecutionProvider"]) | |
| actual = session.run( | |
| ["endpoint_probability"], | |
| { | |
| "log_mel": features.detach().cpu().numpy().astype("float32"), | |
| "frame_mask": mask.detach().cpu().numpy().astype("float32"), | |
| }, | |
| )[0] | |
| return float(np.max(np.abs(actual - expected.detach().cpu().numpy()))) | |
| def main() -> int: | |
| args = parse_args() | |
| try: | |
| import torch | |
| from torch import nn | |
| except ImportError as exc: | |
| raise SystemExit("ONNX export requires PyTorch") from exc | |
| from turn_detection.models import ( | |
| LogMelConfig, | |
| build_runtime_metadata, | |
| load_model_checkpoint, | |
| ) | |
| checkpoint_path = Path(args.checkpoint) | |
| if not checkpoint_path.is_absolute(): | |
| checkpoint_path = REPOSITORY_ROOT / checkpoint_path | |
| output_path = Path(args.output) | |
| if not output_path.is_absolute(): | |
| output_path = REPOSITORY_ROOT / output_path | |
| if output_path.suffix.lower() != ".onnx": | |
| raise SystemExit("--output must end in .onnx") | |
| output_path.parent.mkdir(parents=True, exist_ok=True) | |
| model, checkpoint = load_model_checkpoint(checkpoint_path, map_location="cpu") | |
| model.eval() | |
| checkpoint_metadata = dict(checkpoint.get("metadata", {})) | |
| feature_config = LogMelConfig.from_mapping(checkpoint_metadata.get("feature_config", {})) | |
| max_seconds = float(checkpoint_metadata.get("max_seconds", 8.0)) | |
| frames = max( | |
| 2, int(round(max_seconds * feature_config.sample_rate / feature_config.hop_length)) | |
| ) | |
| model_type = str(checkpoint["model_config"].get("type", "tiny_tcn")) | |
| fixed_frames = model_type in {"whisper", "whisper_teacher", "teacher"} | |
| threshold = float(checkpoint.get("threshold", 0.5)) | |
| if not math.isfinite(threshold): | |
| raise SystemExit("checkpoint threshold must be finite") | |
| run_metadata = checkpoint_metadata.get("run_metadata", {}) | |
| if not isinstance(run_metadata, dict): | |
| run_metadata = {} | |
| smoke_test = bool(checkpoint_metadata.get("smoke_test", False)) | |
| training_status = str(run_metadata.get("status", "smoke" if smoke_test else "development")) | |
| # Only the exact, explicit status "final" opens the final-release path. | |
| # Candidate/production/release-like free text remains development-only. | |
| development_only = smoke_test or training_status.lower() != "final" | |
| data_scope = checkpoint_metadata.get("data_scope") | |
| try: | |
| metadata = build_runtime_metadata( | |
| feature_config, | |
| max_seconds=max_seconds, | |
| threshold=threshold, | |
| model_name=str(checkpoint_metadata.get("run_name", output_path.stem)), | |
| architecture=model_type, | |
| model_version=str(checkpoint.get("format_version", 1)), | |
| development_only=development_only, | |
| training_status=training_status, | |
| data_scope=None if data_scope is None else str(data_scope), | |
| data_revision=( | |
| None | |
| if checkpoint_metadata.get("data_revision") is None | |
| else str(checkpoint_metadata["data_revision"]) | |
| ), | |
| parameter_count=sum(parameter.numel() for parameter in model.parameters()), | |
| ) | |
| except ValueError as exc: | |
| raise SystemExit( | |
| f"checkpoint preprocessing cannot be represented by the current runtime: {exc}. " | |
| "Export a deployment-compatible distilled TinyTCN student." | |
| ) from exc | |
| class EndpointWrapper(nn.Module): | |
| def __init__(self, wrapped: nn.Module) -> None: | |
| super().__init__() | |
| self.wrapped = wrapped | |
| def forward(self, log_mel: Any, frame_mask: Any) -> Any: | |
| return torch.sigmoid(self.wrapped(log_mel, frame_mask > 0.5).endpoint_logits) | |
| wrapper = EndpointWrapper(model).eval() | |
| generator = torch.Generator().manual_seed(17) | |
| dummy_features = torch.randn( | |
| (1, feature_config.n_mels, frames), generator=generator, dtype=torch.float32 | |
| ) | |
| dummy_mask = torch.ones((1, frames), dtype=torch.float32) | |
| with torch.inference_mode(): | |
| expected = wrapper(dummy_features, dummy_mask) | |
| dynamic_axes = { | |
| "log_mel": {0: "batch"}, | |
| "frame_mask": {0: "batch"}, | |
| "endpoint_probability": {0: "batch"}, | |
| } | |
| if not fixed_frames: | |
| dynamic_axes["log_mel"][2] = "frames" | |
| dynamic_axes["frame_mask"][1] = "frames" | |
| try: | |
| exporter_options: dict[str, Any] = {} | |
| if "dynamo" in inspect.signature(torch.onnx.export).parameters: | |
| exporter_options["dynamo"] = False | |
| torch.onnx.export( | |
| wrapper, | |
| (dummy_features, dummy_mask), | |
| str(output_path), | |
| input_names=["log_mel", "frame_mask"], | |
| output_names=["endpoint_probability"], | |
| dynamic_axes=dynamic_axes, | |
| opset_version=args.opset, | |
| do_constant_folding=True, | |
| **exporter_options, | |
| ) | |
| except Exception as exc: | |
| missing_module = isinstance(exc, ModuleNotFoundError) and getattr(exc, "name", None) in { | |
| "onnx", | |
| "onnxscript", | |
| } | |
| missing_message = str(exc) in { | |
| "Module onnx is not installed!", | |
| "No module named 'onnx'", | |
| "No module named 'onnxscript'", | |
| } | |
| if not (missing_module or missing_message): | |
| raise | |
| print( | |
| "warning: onnx package unavailable; using Torch's private legacy serializer", | |
| file=sys.stderr, | |
| ) | |
| try: | |
| _legacy_export_without_onnx_package( | |
| torch, | |
| wrapper, | |
| (dummy_features, dummy_mask), | |
| output_path, | |
| input_names=["log_mel", "frame_mask"], | |
| output_names=["endpoint_probability"], | |
| dynamic_axes=dynamic_axes, | |
| opset=args.opset, | |
| ) | |
| except Exception as fallback_exc: | |
| raise SystemExit( | |
| "ONNX package is unavailable and Torch's private fallback was incompatible" | |
| ) from fallback_exc | |
| parity: dict[str, float | None] = { | |
| "fp32_max_abs_error": None, | |
| "int8_max_abs_error": None, | |
| } | |
| if not args.skip_parity: | |
| parity["fp32_max_abs_error"] = _parity_check( | |
| output_path, dummy_features, dummy_mask, expected | |
| ) | |
| if parity["fp32_max_abs_error"] > 1e-4: | |
| raise SystemExit(f"FP32 ONNX parity failed: {parity['fp32_max_abs_error']:.6g}") | |
| quantized_path: Path | None = None | |
| if args.quantize != "none": | |
| quantized_path = output_path.with_name(output_path.stem + ".int8.onnx") | |
| if args.quantize == "dynamic": | |
| _quantize_dynamic(output_path, quantized_path) | |
| else: | |
| if not args.calibration_npz: | |
| raise SystemExit("--quantize static requires --calibration-npz") | |
| _quantize_static(output_path, quantized_path, Path(args.calibration_npz)) | |
| if not args.skip_parity: | |
| parity["int8_max_abs_error"] = _parity_check( | |
| quantized_path, dummy_features, dummy_mask, expected | |
| ) | |
| files: dict[str, dict[str, Any]] = { | |
| "fp32": { | |
| "filename": output_path.name, | |
| "bytes": output_path.stat().st_size, | |
| "sha256": _sha256(output_path), | |
| } | |
| } | |
| if quantized_path is not None: | |
| files["int8"] = { | |
| "filename": quantized_path.name, | |
| "bytes": quantized_path.stat().st_size, | |
| "sha256": _sha256(quantized_path), | |
| "quantization": args.quantize, | |
| } | |
| resolved_config_path = checkpoint_path.parent / "resolved_config.json" | |
| resolved_config_evidence: dict[str, Any] | None = None | |
| training_data: dict[str, Any] | None = None | |
| if resolved_config_path.is_file(): | |
| try: | |
| resolved_config = json.loads(resolved_config_path.read_text(encoding="utf-8")) | |
| except json.JSONDecodeError as exc: | |
| raise SystemExit("resolved_config.json is invalid") from exc | |
| resolved_config_evidence = _file_evidence(resolved_config_path) | |
| data_config = resolved_config.get("data", {}) | |
| if isinstance(data_config, dict): | |
| sources: dict[str, Any] = {} | |
| for key in ("train_source", "validation_source"): | |
| value = data_config.get(key) | |
| if not isinstance(value, str): | |
| continue | |
| candidate = Path(value) | |
| if not candidate.is_absolute(): | |
| candidate = REPOSITORY_ROOT / candidate | |
| sources[key] = ( | |
| _file_evidence(candidate) if candidate.is_file() else {"identifier": value} | |
| ) | |
| training_data = { | |
| "revision": data_config.get("revision"), | |
| "scope": data_config.get("scope"), | |
| "sources": sources, | |
| } | |
| source_files = [_file_evidence(path) for path in _deployment_source_paths()] | |
| source_inventory_sha256 = hashlib.sha256( | |
| json.dumps(source_files, sort_keys=True, separators=(",", ":")).encode("utf-8") | |
| ).hexdigest() | |
| export_manifest = { | |
| "format_version": 2, | |
| "task": "audio-turn-end-detection", | |
| "model_type": model_type, | |
| "parameter_count": sum(parameter.numel() for parameter in model.parameters()), | |
| "checkpoint": { | |
| "filename": checkpoint_path.name, | |
| "bytes": checkpoint_path.stat().st_size, | |
| "sha256": _sha256(checkpoint_path), | |
| "selected_epoch": checkpoint.get("epoch"), | |
| }, | |
| "model_config": checkpoint.get("model_config"), | |
| "threshold": threshold, | |
| "controller": metadata["controller"], | |
| "resolved_config": resolved_config_evidence, | |
| "training_data": training_data, | |
| "source_files": source_files, | |
| "source_inventory_sha256": source_inventory_sha256, | |
| "input_names": ["log_mel", "frame_mask"], | |
| "output_names": ["endpoint_probability"], | |
| "input_dtypes": {"log_mel": "float32", "frame_mask": "float32"}, | |
| "input_shapes": { | |
| "log_mel": ["batch", feature_config.n_mels, frames if fixed_frames else "frames"], | |
| "frame_mask": ["batch", frames if fixed_frames else "frames"], | |
| }, | |
| "dynamic_frames": not fixed_frames, | |
| "files": files, | |
| "parity": parity, | |
| "quantized_threshold_recalibration_required": quantized_path is not None, | |
| "development_only": development_only, | |
| "training_status": training_status, | |
| "data_scope": data_scope, | |
| "data_revision": checkpoint_metadata.get("data_revision"), | |
| "notes": ( | |
| "Whisper export uses a fixed time axis dictated by encoder positional embeddings." | |
| if fixed_frames | |
| else "TinyTCN accepts a dynamic number of log-mel frames." | |
| ), | |
| } | |
| metadata_path = output_path.parent / "model_metadata.json" | |
| metadata_path.write_text( | |
| json.dumps(metadata, indent=2, sort_keys=True, allow_nan=False), encoding="utf-8" | |
| ) | |
| export_manifest_path = output_path.parent / "export_manifest.json" | |
| export_manifest_path.write_text( | |
| json.dumps(export_manifest, indent=2, sort_keys=True, allow_nan=False), | |
| encoding="utf-8", | |
| ) | |
| print( | |
| json.dumps( | |
| { | |
| "model": str(output_path), | |
| "metadata": str(metadata_path), | |
| "export_manifest": str(export_manifest_path), | |
| **parity, | |
| }, | |
| indent=2, | |
| ) | |
| ) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |