# Canary 1B v2 — Setup, Sweep-Reproduktion und Decoding-Schalter Selbsterhaltende Anleitung für **NVIDIA Canary 1B v2** in doctate. Alles, was zur Installation, zur Reproduktion der bisherigen Sweep-Ergebnisse und zur Konfiguration der Decoding-Schalter nötig ist, steht in diesem Dokument. Detail- und Hintergrund-Material ist als Appendix angehängt. ## 1. Was und warum - **Modell:** `nvidia/canary-1b-v2`. FastConformer-Encoder + AED (Attention-Encoder-Decoder), ca. 1 Mrd. Parameter. Vier Sprachen (en/de/fr/es), beste Deutsch-Performance unter den freien Modellen ≤ 12 GB VRAM. - **Status in doctate:** zweiter ASR-Service neben Whisper, **nicht** Drop-in-Replacement. Whisper bleibt am `/asr`-Endpoint, Canary läuft separat als nativer FastAPI-Service auf Port 9002 für Vergleichs- und Forschungsläufe. - **Lizenz:** CC-BY-4.0 (kommerzielle Nutzung erlaubt, Attribution nötig). ## 2. Installation — Containerservice auf Minerva Dies ist der Produktionspfad. Der Service läuft 24/7 auf `minerva.lan:9002`. Modellgewichte (~3 GB) werden im Volume `/opt/stacks/doctate-canary/models` gecached, sodass Restarts schnell sind. ### 2.1 Voraussetzungen auf der Zielmaschine - Linux mit Docker und `docker compose`-Plugin - NVIDIA-Treiber + `nvidia-container-toolkit` (für GPU-Zugriff aus Containern) - Mindestens 12 GB GPU-VRAM für bf16-Inferenz - Mindestens 5 GB freier Plattenplatz (3 GB Modell, ca. 4.5 GB Container-Image) - SSH-Zugriff für `deploy.sh` ### 2.2 Container-Stack Der Stack besteht aus fünf Files unter `canary/` im Repo (Inhalte siehe Appendix A): | Datei | Zweck | |---|---| | `Dockerfile` | Image-Definition (cuda-devel + Python + ffmpeg + Pip-Deps) | | `requirements.txt` | NeMo, FastAPI, HF-Hub mit Versions-Pins | | `docker-compose.yml` | Service-Definition (Port 9002, GPU-Reservierung, Modell-Volume) | | `main.py` | FastAPI-App mit `/health`, `/info`, `POST /inference` | | `deploy.sh` | rsync + remote build + Health-Polling (10-min-Timeout) | ### 2.3 Deployment Aus dem Dev-Repo: ```bash cd /home/brummel/dev/doctate/canary ./deploy.sh minerva.lan ``` `deploy.sh` führt aus: 1. `rsync` der Container-Files nach `minerva:/opt/stacks/doctate-canary/` 2. `docker compose down` auf der Remote 3. `docker pull nvidia/cuda:12.6.1-devel-ubuntu22.04` (umgeht einen TLS-Cert-Bug im Legacy-Builder, der sonst beim Pull innerhalb von `docker build` auftritt) 4. `docker build -t doctate-canary .` 5. `docker compose up -d` 6. Pollt `/health` 120 × 5 s = 10 min lang. Beim Erststart muss das Modell aus HuggingFace geladen werden — das dauert mehrere Minuten. Bei Erfolg gibt `deploy.sh` zusätzlich `/info` zurück, sonst die letzten 60 Container-Log-Zeilen. ### 2.4 API Der Service hat eine **native** Canary-API (kein Whisper-Mimik): | Methode | Pfad | Zweck | |---|---|---| | `GET` | `/health` | `{"status":"ok","model":...,"device":...,"precision":...}` | | `GET` | `/info` | Erweiterte Metadaten | | `POST` | `/inference` | Multipart-Upload, Inferenz, Antwort als Text oder JSON | Form-Felder von `POST /inference`: | Feld | Default | Werte | |---|---|---| | `file` | — | Upload (mp3/m4a/opus/wav/...). Intern via ffmpeg → 16 kHz mono WAV | | `language` | `de` | `en`/`de`/`fr`/`es` | | `pnc` | `yes` | `yes`/`no` (Punctuation/Capitalization) | | `timestamps` | `no` | `yes`/`no` (segment-level, nur in `json`-Antwort) | | `response_format` | `text` | `text` (PlainText) / `json` | Smoke-Test: ```bash curl http://minerva.lan:9002/health curl -F file=@dictation.m4a -F language=de \ http://minerva.lan:9002/inference ``` ### 2.5 Env Vars | Variable | Default | Zweck | |---|---|---| | `CANARY_MODEL` | `nvidia/canary-1b-v2` | beliebiges NeMo-ASR-Modell | | `CANARY_DEVICE` | `cuda` | `cuda` oder `cpu` | | `CANARY_PRECISION` | `bf16` | `bf16` / `fp16` / `fp32` | | `CANARY_MODELS_DIR` | `/models` | HF-Cache (volume-persistent) | | `CANARY_CHUNK_LEN_SECS` | `40.0` | Chunk-Fensterlänge für Long-Form | | `CANARY_CHUNKED_THRESHOLD_SECS` | `25.0` | ab welcher Audiolänge gechunkt wird | | `HF_HOME` | wird auf `CANARY_MODELS_DIR` gesetzt | HuggingFace-Cache | ### 2.6 VRAM-Budget Canary 1B v2 in bf16 belegt **ca. 9.7 GB** auf RTX 3060 mit `batch_size=8` für die buffered Pipeline. Das passt **nicht** gleichzeitig mit Whisper + Ollama auf eine 12-GB-Karte. Für Sweep-Läufe (Abschnitt 3) muss der Service kurzzeitig pausiert werden: ```bash ssh minerva.lan "cd /opt/stacks/doctate-canary && docker compose stop" # ... Sweep-Run hier ... ssh minerva.lan "cd /opt/stacks/doctate-canary && docker compose start" ``` ## 3. Reproduktion der Sweep-Ergebnisse vom 2026-04-30 Der Validation-Sweep hat **101 Einzeldiktate**, **47 Per-Case-Concats** und **1 globales 38.7-min-Concat** durch Canary gejagt, um die `dur=0`-Halluzinations-Heuristik gegen ein echtes Korpus zu validieren. **Ergebnis** (Disposition siehe Abschnitt 5): | Quantity | Value | |---|---:| | dur=0 Vorkommen | 81 | | davon echte Halluzinationen | 2 | | False Positives (echtes Transkript) | 79 | | Precision | **≈ 2.5 %** | Diese Schritte regenerieren die Daten von Grund auf. Die Sweep- Skripte liegen in `experiments/canary_sweep/`. ### 3.1 Cases einsammeln ```bash mkdir -p experiments/canary_sweep/cases experiments/canary_sweep/concat cd /home/brummel/dev/doctate/tmpdata/dr_mueller find . -name "*.m4a" | while read m4a_rel; do case_uuid=$(echo "$m4a_rel" | cut -d/ -f2) stem=$(basename "$m4a_rel" .m4a) dest="/home/brummel/dev/doctate/experiments/canary_sweep/cases/$case_uuid" mkdir -p "$dest" cp "./$case_uuid/$stem.m4a" "$dest/$stem.m4a" [ -f "./$case_uuid/$stem.json" ] && cp "./$case_uuid/$stem.json" "$dest/$stem.whisper.json" done ``` Resultat: `cases//.m4a` (101 Files) plus 53 zugehörige `.whisper.json` aus dem Server-State (Rest sind ältere Aufnahmen, deren Whisper-Output nur in `document.md` zusammengeführt wurde). ### 3.2 Per-Case- und globalen Concat bauen `build_per_case_concat.py` und `build_concat.py` ffmpeg-stitchen alle Audios zu einem WAV pro Case bzw. zu einem 38.7-min-Konglomerat über alle Cases. Beide Skripte schreiben jeweils ein `concat.manifest.json` mit der Reihenfolge und den Time-Offsets der Einzelschnipsel: ```bash cd experiments/canary_sweep python build_per_case_concat.py # cases//concat.wav python build_concat.py # concat/all.wav (38.7 min) ``` Beide sind idempotent. ### 3.3 GPU-Konflikt klären, Sweep ausführen Der laufende `doctate-canary`-Service belegt fast die ganze GPU. Vor dem Sweep einmal stoppen: ```bash ssh minerva.lan "cd /opt/stacks/doctate-canary && docker compose stop" ``` Daten + Skripte hochladen: ```bash rsync -av experiments/canary_sweep/ minerva.lan:/tmp/canary_sweep/ ``` **Sweep über die 101 Einzelaudios** — Mode A (Single-Shot, mit Word-Confidence): ```bash ssh minerva.lan "docker run --rm --name canary-sweep --gpus all \ -v /tmp/canary_sweep:/work \ -v /opt/stacks/doctate-canary/models:/models \ -e HF_HOME=/models \ doctate-canary:latest \ python3 /work/run_canary.py /work/cases /work/cases" ``` **Per-Case-Concats** — adaptiv (≤ 25 s → Single-Shot, > 25 s → Buffered): ```bash ssh minerva.lan "docker run --rm --name canary-percase --gpus all \ -v /tmp/canary_sweep:/work \ -v /opt/stacks/doctate-canary/models:/models \ -e HF_HOME=/models \ doctate-canary:latest \ python3 /work/run_canary_per_case.py /work/cases" ``` **Globaler 38.7-min-Concat** — Buffered ist Pflicht (Single-Shot geht in OOM): ```bash ssh minerva.lan "docker run --rm --name canary-concat-buffered --gpus all \ -v /tmp/canary_sweep:/work \ -v /opt/stacks/doctate-canary/models:/models \ -e HF_HOME=/models \ doctate-canary:latest \ python3 /work/run_canary_buffered.py \ /work/concat/all.wav /work/concat/all.canary.json" ``` Service wieder hochfahren: ```bash ssh minerva.lan "cd /opt/stacks/doctate-canary && docker compose start" ``` ### 3.4 Outputs zurücksyncen ```bash rsync -av --include='*/' \ --include='*.canary.json' --include='concat.canary.json' \ --exclude='*' \ minerva.lan:/tmp/canary_sweep/cases/ \ experiments/canary_sweep/cases/ rsync -av minerva.lan:/tmp/canary_sweep/concat/all.canary.json \ experiments/canary_sweep/concat/ ``` ### 3.5 dur=0-Inventar erzeugen `dur_zero_inventory.py` walks alle 149 `*.canary.json` Files, extrahiert jedes Wort mit `start_offset == end_offset`, und gibt ein strukturiertes JSON sowie einen Markdown-Report aus: ```bash cd experiments/canary_sweep python dur_zero_inventory.py ``` Schreibt `dur_zero_inventory.json` (alle 81 Records mit Kontext) und `dur_zero_inventory.md` (gruppiert nach File mit 3-Wörter-Kontext um jeden Treffer). ## 4. Decoding-Schalter — was, wann, wie Canary 1B v2 hat zwei Inferenz-Pipelines mit **unterschiedlichen Output-Schemata**. Die Wahl bestimmt, welche Felder im Hypothesis- Objekt gefüllt werden. ### 4.1 Pipeline-Wahl | Pipeline | Audiolänge | Aufruf | Hypothesis-Felder | |---|---|---|---| | **Single-Shot (Mode A)** | ≤ 25 s | `model.transcribe([wav], …, return_hypotheses=True)` | text, score, timestamp (word+segment), tokens, **word_confidence**, **token_confidence** | | **Buffered** | > 25 s | `FrameBatchMultiTaskAED` + `get_buffered_pred_feat_multitaskAED` | text, score, timestamp (word+segment) — **kein** word_confidence, keine tokens | Die Schwelle ist `CHUNKED_THRESHOLD_SECS=25.0` im Service. Hauptlimit der buffered Pipeline: NeMo's `_join_hypotheses` in `nemo/collections/asr/parts/utils/streaming_utils.py` mergt nur `text`, `y_sequence` und `timestamp` über die Chunks — Confidence- Felder werden wegen fehlender `_join_word_confidence`-Methode verworfen. Das ist eine NeMo-Implementations-Lücke, kein Konfigurationsfehler. Die Pro-Chunk-Confidence ginge intern, die Streaming-Aggregation wirft sie nur weg. ### 4.2 Timestamps an/aus **Schalter:** kwarg `timestamps=True` auf `model.transcribe()` bzw. auf `get_buffered_pred_feat_multitaskAED()`. **Was sie liefern:** ```json { "timestamp": { "word": [{"word": "Therapie.", "start_offset": 46, "end_offset": 51, "start": 3.68, "end": 4.08}, …], "segment": [{"segment": "Therapie.", …}, …] } } ``` `*_offset` sind Encoder-Frames (FastConformer-Stride 80 ms), `start`/`end` sind Sekunden. Die Word-Timestamps stammen aus dem **CTC-Submodell-Forced-Alignment**, nicht aus dem AED selbst. **Kosten:** Vernachlässigbar. Das CTC-Submodell läuft eh, der Schalter erhält nur dessen Output, anstatt ihn zu verwerfen. Im buffered Pfad gilt allerdings die NeMo-Empfehlung `chunk_len_in_secs=10.0` für genauere Word-Timestamps; mit unseren 40-s-Chunks sind Word-Offsets weniger präzise (innerhalb eines Chunks ok, an Chunk-Übergängen verlieren sie Genauigkeit). **Default im Service:** `timestamps=no` (segment-level, nur im JSON- Response wenn `response_format=json`). Wer Word-Timestamps für Forschungszwecke braucht (z.B. dur=0-Inventar), muss am `transcribe()`-Call selbst flippen — siehe Sweep-Skripte in Abschnitt 3. ### 4.3 Confidence an/aus **Schalter:** vier Stellen, alle vier sind nötig — eine fehlt → Wert ist `None`: ```python # 1. Decoding-Strategie auf greedy umstellen. # Beam liefert keine Confidence — confidence_cfg wird im # Beam-Pfad ignoriert. cfg = OmegaConf.to_container(model.cfg.decoding, resolve=True) cfg["strategy"] = "greedy" # 2. confidence_cfg-Flags setzen cfg.setdefault("confidence_cfg", {}) cfg["confidence_cfg"]["preserve_word_confidence"] = True cfg["confidence_cfg"]["preserve_token_confidence"] = True cfg["confidence_cfg"]["preserve_frame_confidence"] = True cfg["confidence_cfg"]["aggregation"] = "min" # 3. Greedy-spezifische Flags cfg.setdefault("greedy", {}) cfg["greedy"]["preserve_token_confidence"] = True cfg["greedy"]["preserve_alignments"] = True model.change_decoding_strategy(OmegaConf.create(cfg)) # 4. transcribe() mit return_hypotheses=True (DER stille # Stolperdraht!) hyps = model.transcribe([wav], …, return_hypotheses=True) ``` **Der stille Stolperdraht:** `return_hypotheses` ist standardmäßig `False`. Dann liefert NeMo Strings statt `Hypothesis`-Objekten — ohne dass irgendein Flag oder Log darauf hinweist. Egal welche Confidence-Flags man setzt: ohne `return_hypotheses=True` ist das Feld `None`. Das herauszufinden hat in der Diagnostik-Phase mehrere konfundierte Ablations-Runden gekostet. **Wert:** Tsallis-Entropie mit `aggregation=min`, theoretisch im Bereich [0, 1] mit höher = sicherer. In der Praxis Magnituden um 3·10⁻⁵ als Median, einzelne Wörter bei float32-Min (1.4·10⁻⁴⁵). Numerisch nutzbar nur über die relative Reihenfolge, nicht über absolute Schwellen. **Wichtig — Confidence ist als Halluzinations-Signal nicht brauchbar.** Echtes medizinisches Vokabular ("Bisoprolol", "Ramipril") und Dosierungsdigits ("100", "101") haben dieselben extrem niedrigen Confidence-Werte wie tatsächliche Halluzinationen. Confidence kann "unsicheren echten Inhalt" und "halluzinierten Inhalt" nicht trennen. Detail siehe Appendix C. ### 4.4 Was zusammen geht — Ablations-Tabelle Die kondensierte Wahrheitstabelle für die wichtigen Knöpfe: | Strategie | Chunking | timestamps | return_hypotheses | text | confidence | xatt | |---|---|---|---|---|---|---| | beam | ON | True | False (default) | full | None | None | | greedy | ON | True | False | full | None | None | | greedy | ON | True | **True** | full | **gefüllt** | None | | greedy | OFF | False | True | **degradiert** | gefüllt | gefüllt | **Lehre:** Confidence + Timestamps + voller Text gehen zusammen, sobald `strategy=greedy` und `return_hypotheses=True`. Cross-Attention (`xatt_scores`) ist die einzige Information, die `enable_chunking=False` erfordert — und damit verliert man bei Audios > 40 s die Textqualität, was Cross-Attention für Production disqualifiziert. ### 4.5 Cross-Attention (informativ, nicht in Production) Falls jemand jemals `xatt_scores` braucht: zusätzlich zu den Confidence-Flags `MultiTaskTranscriptionConfig(enable_chunking=False)` via `override_config` auf `transcribe()`. Liefert pro Output-Token eine Verteilung über Encoder-Frames; Shannon-Entropy pro Token unterscheidet (auf einer einzelnen 76-s-Aufnahme) Halluzination (Entropie ~4.7) von echtem Inhalt (~3.0). Validation auf breiterem Korpus fehlt; nicht weiterverfolgt, weil dur=0 + Confidence schon als Signale gefallen sind. ## 5. Bekannte Sackgassen | Idee | Verworfen weil | |---|---| | **`dur=0` als Halluzinations-Filter oder Advisory-Marker** | 79/81 FP auf dr_mueller — siehe Appendix C | | **Word-Confidence** als Filter oder Marker | medizinisches Vokabular und Digits haben dieselben niedrigen Werte wie Halluzinationen — siehe Appendix C | | Whisper `suppress_tokens=\d+` gegen Digit-Halluzinationen | erzeugt Halluzinations-Loops; `\d` allein lässt Digit-Sequenzen ganz verschwinden | | Whisper-Hotwords aus einzelnen Test-Cases | halluziniert in anderen Cases (Curve-Fitting auf Einzelbeispielen) | | Buffered Pipeline mit Confidence-Output | NeMo `_join_hypotheses` mergt Confidence-Felder nicht (siehe Abschnitt 4.1) | Falls Halluzinations-Detektion irgendwann nötig wird: das Signal muss aus *außerhalb* der Per-Word-Telemetrie eines einzelnen ASR- Modells kommen. Die naheliegenden Kandidaten: - LLM-Konsolidierung mit medizinischem Domänen-Wissen (geschieht implizit bereits, könnte explizit gemacht werden) - Cross-Modell-Vergleich: AED-Text vs CTC-Standalone, oder Canary vs Whisper, mit Divergenz als Signal - Domänen-Regeln: nicht-existierende Medikamentennamen, unplausible Dosierungswerte etc. — aktuell vom Gazetteer pre-LLM erledigt ## 6. Domänen-Stolperfallen ### 6.1 "1-0-1"-Notation Deutsche medizinische Diktate sprechen Dosierungsschemata als Digit-Triple ("eins null eins" = morgens 1, mittags 0, abends 1). Varianten: "1-0-0", "½-0-½", "1-0-0-1" (Vier-Slot). Canarys BPE- Tokenizer kollabiert das oft zu einem einzigen Token "101", "100" etc. — derselbe Mechanismus wie der allgemeine Digit-Collapse- Effekt. **Konsequenz:** Bare 3-Digit-Tokens im Medikationskontext sind **echter Inhalt**, nicht Halluzination. Eine Pipeline-Stufe, die "100" oder "101" als Halluzination markiert (z.B. weil Confidence niedrig ist), zerstört reale Dosierungsinformation — patient- sicherheitsrelevant. **Validierungsbeispiel** (case_c414cf52, Ramipril 5 mg 1-0-1): Der AED produzierte zunächst "100." für gesprochenes "1-0-1" (Digit-Collapse mit falscher Korrektur), dann eine Selbst-Korrektur mit "5 milligramm 101" — beide erscheinen im Transkript. Das "101" ist die korrekte Wiedergabe, das "100." der Fehler. Ein Filter, der nur niedrig-confidente Digits droppt, würde beide vernichten. ### 6.2 Stille zwischen Schnipsel-Concats Beim globalen 38.7-min-Concat (Buffered Pipeline) meldet NeMo für mehrere Chunks `text of utterance with ID: audio_X is empty`. Das sind die Übergangs-Stellen zwischen aneinandergehängten Audios mit Stille — kein Bug, sondern erwartet. Die Output-Hypothese hat trotzdem 1555 Wörter, also keine Information verloren. --- ## Appendix A — Container-Files (vollständige Inhalte) ### Dockerfile ```dockerfile FROM nvidia/cuda:12.6.1-devel-ubuntu22.04 ENV DEBIAN_FRONTEND=noninteractive \ PIP_NO_CACHE_DIR=1 \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 RUN apt-get update && apt-get install -y --no-install-recommends \ python3 python3-pip python3-dev \ ffmpeg \ libsndfile1 \ git \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . RUN pip install --upgrade pip && \ pip install -r requirements.txt COPY main.py . ENV CANARY_MODEL=nvidia/canary-1b-v2 \ CANARY_DEVICE=cuda \ CANARY_PRECISION=bf16 \ CANARY_MODELS_DIR=/models \ HF_HOME=/models VOLUME ["/models"] EXPOSE 9002 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "9002", "--workers", "1"] ``` ### requirements.txt ``` nemo_toolkit[asr]>=2.7.3,<3.0 huggingface_hub>=0.26.0,<1.0 fastapi==0.115.0 uvicorn[standard]==0.30.6 python-multipart==0.0.9 ``` **Begründung der Pins:** `nemo_toolkit ≥ 2.7.3` ist Pflicht, weil in NeMo 2.4.x ein Bug verhinderte, dass `FrameBatchMultiTaskAED` mit Canary 1B v2 funktioniert (`CanaryBPETokenizer.vocabulary` fehlte). In 2.5+ ist der Fall `tokenizer.vocab_size`-Fallback eingebaut. Wir pinnen nach unten auf 2.7.3 (in Production verifiziert) und nach oben auf < 3.0 (kein erwartet-stabiler Bruch). ### docker-compose.yml ```yaml services: doctate-canary: image: doctate-canary:latest container_name: doctate-canary environment: - CANARY_MODEL=nvidia/canary-1b-v2 - CANARY_PRECISION=bf16 - NVIDIA_VISIBLE_DEVICES=all - NVIDIA_DRIVER_CAPABILITIES=compute,utility volumes: - /opt/stacks/doctate-canary/models:/models ports: - "9002:9002" shm_size: 2g deploy: resources: reservations: devices: - driver: nvidia count: 1 capabilities: [gpu] restart: unless-stopped ``` ### deploy.sh ```bash #!/usr/bin/env bash set -euo pipefail HOST="${1:-minerva.lan}" REMOTE_DIR="/opt/stacks/doctate-canary" LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)" rsync -av --exclude 'deploy.sh' --exclude 'models/' \ "$LOCAL_DIR/" "$HOST:$REMOTE_DIR/" ssh "$HOST" "cd $REMOTE_DIR && \ docker compose down && \ docker pull nvidia/cuda:12.6.1-devel-ubuntu22.04 && \ docker build -t doctate-canary . && \ docker compose up -d" for i in $(seq 1 120); do if ssh "$HOST" "curl -sf http://localhost:9002/health" 2>/dev/null; then echo " OK"; exit 0 fi sleep 5 done ssh "$HOST" "docker logs doctate-canary --tail 60" exit 1 ``` ## Appendix B — Service-Schlüssel-Code (`main.py`-Auszug) Diese beiden Funktionen aus `canary/main.py` sind die Implementierungs-Quelle für die Pipeline-Wahl in Abschnitt 4.1: ```python def _transcribe_buffered(wav_path, duration, language, pnc, timestamps): """Long-audio path: explicit chunked inference via FrameBatchMultiTaskAED. Writes a temporary single-line JSONL manifest because language and pnc must be passed via manifest fields when using the buffered API. """ manifest_tmp = tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) json.dump({"audio_filepath": wav_path, "duration": duration, "taskname": "asr", "source_lang": language, "target_lang": language, "pnc": pnc}, manifest_tmp) manifest_tmp.write("\n"); manifest_tmp.close() frame_asr = FrameBatchMultiTaskAED( asr_model=model, frame_len=CHUNK_LEN_SECS, total_buffer=CHUNK_LEN_SECS, batch_size=8, ) with torch.amp.autocast(model.device.type, enabled=use_amp, dtype=DTYPE_MAP[PRECISION]): with torch.no_grad(): hyps = get_buffered_pred_feat_multitaskAED( frame_asr, _model_cfg.preprocessor, MODEL_STRIDE_SECS, model.device, manifest=manifest_tmp.name, filepaths=None, timestamps=timestamps) return hyps # Im /inference-Handler: chunked = duration > CHUNKED_THRESHOLD_SECS if chunked: hyps = _transcribe_buffered(wav_path, duration, language, pnc, ts_flag) else: hyps = model.transcribe([wav_path], source_lang=language, target_lang=language, pnc=pnc, timestamps=ts_flag) ``` Pre-Conditions auf das Modell-Config-Objekt, einmal beim Service- Start zu setzen (sonst inkonsistente Feature-Extraktion zwischen Single-Shot- und Buffered-Pfad): ```python _model_cfg = copy.deepcopy(model._cfg) OmegaConf.set_struct(_model_cfg.preprocessor, False) _model_cfg.preprocessor.dither = 0.0 _model_cfg.preprocessor.pad_to = 0 OmegaConf.set_struct(_model_cfg.preprocessor, True) assert _model_cfg.preprocessor.normalize == "per_feature" MODEL_STRIDE_SECS = float(_model_cfg.preprocessor["window_stride"]) * 8 ``` ## Appendix C — `dur=0` Validation auf dr_mueller ### Methodik 1. Cohorte: 101 m4a-Aufnahmen aus 48 Case-Verzeichnissen. 2. Drei Inferenz-Pässe: - **Single Audios** (101 Files): Mode A — Single-Shot mit `confidence_cfg`, `return_hypotheses=True`, `timestamps=True`. - **Per-Case-Concats** (47 Files): adaptiv (≤ 25 s → Single-Shot, > 25 s → Buffered). - **Globaler Concat** (1 File, 38.7 min): Buffered. 3. `dur_zero_inventory.py` walks alle 149 `*.canary.json` und extrahiert jedes Wort mit `start_offset == end_offset`. 4. **Ground-Truth-Annotation durch User**: Jedes flagged Vorkommen im 3-Wörter-Kontext gegen die zugehörige Audio-Datei gehört. ### Resultat | Quantity | Value | |---|---:| | dur=0 Vorkommen | 81 | | True Positives (echte Halluzinationen) | 2 | | False Positives (echtes Transkript) | 79 | | Precision | **≈ 2.5 %** | Verteilung nach Quelle × Pipeline: | Quelle | Pipeline | n_zero_dur | |---|---|---:| | single_audio | single_shot | 29 | | per_case_concat | single_shot | 1 | | per_case_concat | buffered | 21 | | global_concat | buffered | 30 | Die zwei True Positives sind beide in case_c414cf52: die Wörter "5" und "milligramm" im Cluster `Bisoprolol 5 mg 100 und Ramipril 5 mg 100. [5 milligramm 101] wird vorerst pausiert ...`. Beachte: "101" in diesem Cluster ist **echter Inhalt** (1-0-1-Schema für Ramipril, siehe Abschnitt 6.1). Die 79 False Positives sind dominant unbetonte deutsche Funktionswörter. Top-Formen (count ≥ 2): ``` 6 und 4 einer 4 sich 3 wie 3 für 3 mit 3 einen 2 wird 2 von 2 es 2 um 2 pro 2 einmal 2 im ``` Diese Wörter werden im CTC-Viterbi-Forced-Alignment auf 0-Frame- Seams zwischen Nachbarwörter gepresst, weil ihre akustische Realisierung zu kurz oder zu reduziert ist, um eigene Encoder- Frames zu beanspruchen. Strukturell sieht das identisch zu einer Halluzination aus — die Alignment-Mathematik allein kann die beiden nicht trennen. ### Disposition `dur=0` ist als Halluzinations-Signal nicht verwendbar: - **Auto-Filter:** Bei 79/81 FP würde das Droppen geflagged Wörter in jedem Diktat echten Transkript zerstören, mit dem Upside, in einer einzigen Aufnahme zwei Halluzinationen zu erwischen. Net-negativ. - **Advisory `[?word]` Marker** für die LLM-Konsolidierung: Gleiches Problem mit niedrigeren Stakes. 79/81 korrekte Wörter als unsicher zu markieren verschmutzt den Prompt und trainiert die LLM, den Marker zu ignorieren — was seinen Nutzen für die echten Halluzinationen kompromittiert. Word-Confidence verhält sich auf demselben Korpus analog: das Top-6-niedrigste-Confidence-Wort sind "SC", "Bisoprolol", "Thorazemit", "5", "ausärztliche", "einmal" — eine Mischung aus echtem medizinischen Vokabular, echten Dosierungsdigits und einzelnen tatsächlichen Halluzinationen. Confidence kann "unsicher echt" und "halluziniert" nicht trennen. ### Implikation für die Pipeline-Architektur Halluzinations-Detektion über Per-Word-Telemetrie eines einzelnen ASRs ist ein Sackgasse. Kandidaten für künftige Versuche, falls nötig: - Cross-Modell-Agreement (Canary AED vs Canary CTC-Standalone vs Whisper) als Divergenz-Signal - LLM-seitige semantische Plausibilitätsprüfung (passiert implizit, könnte explizit gemacht werden) - Domänenregeln (Medikamenten-Existenz, Dosierungs-Plausibilität) — aktuell vom Gazetteer pre-LLM erledigt ## Appendix D — Container-Image bauen ohne `deploy.sh` Manuell auf einer beliebigen GPU-Maschine: ```bash cd canary/ docker build -t doctate-canary . docker run --rm --gpus all \ -v $(pwd)/models:/models \ -p 9002:9002 \ -e HF_HOME=/models \ doctate-canary ``` Beim Erststart lädt der Container ~3 GB aus HuggingFace ins Volume `./models`. Nachfolgende Restarts nutzen den Cache.