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.
This commit is contained in:
+734
@@ -0,0 +1,734 @@
|
||||
# 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/<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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```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.
|
||||
Reference in New Issue
Block a user