Files
doctate/canary/sweep_threshold.py
T
Brummel 6b7a1cea49 feat: Add Canary ASR model service and sweep experiments
This commit introduces the `doctate-canary` service and associated sweep
experiments.

The service provides access to the `nvidia/canary-1b-v2` ASR model via a
native FastAPI API. It includes deployment scripts, Docker
configurations, and detailed documentation on installation, API usage,
and environment variables.

The sweep experiments aim to thoroughly evaluate the Canary model's
performance under various configurations. This includes testing
different inference pipelines (single-shot vs. buffered), decoding
strategies (greedy vs. beam search), and parameter tuning (chunk length,
overlap, batch size, precision). The goal is to reproduce previous
findings and identify optimal settings.

The commit also includes:
- Utility scripts for audio transcoding and manipulation.
- Comprehensive logging and result collection mechanisms for the sweep
  runs.
- Detailed analysis of `dur=0` occurrences and word confidence,
  concluding they are not reliable indicators of hallucination.
- Documentation on the interaction between decoding parameters,
  especially `return_hypotheses`, and the availability of confidence
  scores.
2026-04-30 14:30:12 +02:00

179 lines
6.3 KiB
Python

"""Threshold sweep — varying audio length around the 40 s Encoder-Window.
Compares three transcription paths on artificially trimmed audios:
- path1_default: model.transcribe([wav]) — what main.py uses for ≤ 25 s
- path1_bs1: model.transcribe([wav], batch_size=1) — NeMo ≥ 2.5 auto-chunking
- path2: FrameBatchMultiTaskAED with chunk_len=40 — what main.py
uses for > 25 s
Audios are 20, 30, 38 and 45 s slices of the 80.9 s source. The 25 s
threshold in main.py sits in the middle, so this sweep tells us:
- Does Path 1 truncate already at 30/38 s, or only > 40 s?
- Does the threshold need to move?
"""
import copy
import json
import os
import subprocess
import tempfile
import time
import wave
import torch
from omegaconf import OmegaConf
from nemo.collections.asr.models import ASRModel
from nemo.collections.asr.parts.utils.streaming_utils import FrameBatchMultiTaskAED
from nemo.collections.asr.parts.utils.transcribe_utils import get_buffered_pred_feat_multitaskAED
AUDIO_DIR = "/sweep/threshold_audios"
OUT_DIR = "/sweep"
RESULTS_PATH = f"{OUT_DIR}/threshold_results.jsonl"
LOG_PATH = f"{OUT_DIR}/threshold_log.txt"
def log(msg: str) -> None:
line = f"{time.strftime('%H:%M:%S')} {msg}"
print(line, flush=True)
with open(LOG_PATH, "a") as f:
f.write(line + "\n")
def transcode(src: str) -> str:
dst = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
dst.close()
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-i", src,
"-ac", "1", "-ar", "16000", "-f", "wav", dst.name],
check=True,
)
return dst.name
def wav_duration(p: str) -> float:
with wave.open(p, "rb") as w:
return w.getnframes() / float(w.getframerate())
def vram_mb() -> float:
return torch.cuda.max_memory_allocated() / (1024 * 1024) if torch.cuda.is_available() else 0
def write_manifest(wav_path, duration, pnc="yes"):
f = tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False)
json.dump({"audio_filepath": wav_path, "duration": duration,
"taskname": "asr", "source_lang": "de", "target_lang": "de",
"pnc": pnc}, f)
f.write("\n")
f.close()
return f.name
def run_path1(model, wav, batch_size):
torch.cuda.reset_peak_memory_stats()
t0 = time.monotonic()
with torch.amp.autocast("cuda", enabled=True, dtype=torch.bfloat16):
with torch.no_grad():
hyps = model.transcribe(
[wav], source_lang="de", target_lang="de",
pnc="yes", batch_size=batch_size,
)
return (getattr(hyps[0], "text", None) or str(hyps[0])).strip(), \
time.monotonic() - t0, vram_mb()
def run_path2(model, model_cfg, wav, dur, chunk_len=40.0):
manifest = write_manifest(wav, dur)
feature_stride = model_cfg.preprocessor["window_stride"]
model_stride_secs = feature_stride * 8
frame_asr = FrameBatchMultiTaskAED(
asr_model=model, frame_len=chunk_len,
total_buffer=chunk_len, batch_size=8,
)
torch.cuda.reset_peak_memory_stats()
t0 = time.monotonic()
with torch.amp.autocast("cuda", enabled=True, dtype=torch.bfloat16):
with torch.no_grad():
hyps = get_buffered_pred_feat_multitaskAED(
frame_asr, model_cfg.preprocessor, model_stride_secs,
model.device, manifest=manifest, filepaths=None, timestamps=False,
)
elapsed = time.monotonic() - t0
os.unlink(manifest)
return (getattr(hyps[0], "text", None) or str(hyps[0])).strip(), \
elapsed, vram_mb()
def main():
open(LOG_PATH, "w").close()
open(RESULTS_PATH, "w").close()
log("loading model bf16")
model = ASRModel.from_pretrained("nvidia/canary-1b-v2")
model = model.to("cuda").to(torch.bfloat16)
model.eval()
cfg = copy.deepcopy(model._cfg)
OmegaConf.set_struct(cfg.preprocessor, False)
cfg.preprocessor.dither = 0.0
cfg.preprocessor.pad_to = 0
OmegaConf.set_struct(cfg.preprocessor, True)
log(f"model loaded, vram={vram_mb():.0f}MB")
audios = sorted(os.listdir(AUDIO_DIR))
wavs = {}
for name in audios:
if not name.endswith(".m4a"):
continue
src = os.path.join(AUDIO_DIR, name)
wav = transcode(src)
dur = wav_duration(wav)
wavs[name] = (wav, dur)
log(f" {name}{dur:.1f}s")
def record(d):
with open(RESULTS_PATH, "a") as f:
f.write(json.dumps(d, ensure_ascii=False) + "\n")
for name, (wav, dur) in wavs.items():
# path 1 default (no batch_size override → triggers no auto-chunking
# because input is a list, not a single string)
log(f"path1_default on {name}")
try:
text, t, vram = run_path1(model, wav, batch_size=4)
record({"audio": name, "duration_audio": dur, "path": "path1_default",
"batch_size": 4, "duration_infer": round(t, 2),
"vram_peak_mb": round(vram, 0), "chars": len(text), "text": text})
except Exception as e:
log(f" FAIL: {e}")
record({"audio": name, "path": "path1_default", "error": str(e)})
# path 1 with batch_size=1 (auto-chunking trigger per NeMo docs)
log(f"path1_bs1 on {name}")
try:
text, t, vram = run_path1(model, wav, batch_size=1)
record({"audio": name, "duration_audio": dur, "path": "path1_bs1",
"batch_size": 1, "duration_infer": round(t, 2),
"vram_peak_mb": round(vram, 0), "chars": len(text), "text": text})
except Exception as e:
log(f" FAIL: {e}")
record({"audio": name, "path": "path1_bs1", "error": str(e)})
# path 2 (always chunked, chunk_len=40)
log(f"path2 on {name}")
try:
text, t, vram = run_path2(model, cfg, wav, dur, chunk_len=40.0)
record({"audio": name, "duration_audio": dur, "path": "path2",
"chunk_len": 40.0, "batch_size": 8,
"duration_infer": round(t, 2),
"vram_peak_mb": round(vram, 0), "chars": len(text), "text": text})
except Exception as e:
log(f" FAIL: {e}")
record({"audio": name, "path": "path2", "error": str(e)})
log("done")
if __name__ == "__main__":
main()