Files
doctate/docs/canary.md
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

25 KiB
Raw Blame History

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:

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:

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:

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

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/<uuid>/<stem>.m4a (101 Files) plus 53 zugehörige <stem>.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:

cd experiments/canary_sweep
python build_per_case_concat.py    # cases/<uuid>/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:

ssh minerva.lan "cd /opt/stacks/doctate-canary && docker compose stop"

Daten + Skripte hochladen:

rsync -av experiments/canary_sweep/ minerva.lan:/tmp/canary_sweep/

Sweep über die 101 Einzelaudios — Mode A (Single-Shot, mit Word-Confidence):

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):

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):

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:

ssh minerva.lan "cd /opt/stacks/doctate-canary && docker compose start"

3.4 Outputs zurücksyncen

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:

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:

{
  "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:

# 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

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

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

#!/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:

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):

_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:

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.