Add Canopy-M inference script and package
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +178 -147
- canopy/__init__.py +6 -0
- canopy/data/__init__.py +3 -0
- canopy/data/commonvoice.py +113 -0
- canopy/data/dataset.py +104 -0
- canopy/data/generic.py +75 -0
- canopy/data/manifest.py +124 -0
- canopy/data/sampler.py +128 -0
- canopy/data/splits.py +188 -0
- canopy/data/validation.py +193 -0
- canopy/decoding/__init__.py +4 -0
- canopy/decoding/beam.py +132 -0
- canopy/decoding/greedy.py +43 -0
- canopy/evaluation/__init__.py +3 -0
- canopy/evaluation/metrics.py +135 -0
- canopy/evaluation/runner.py +98 -0
- canopy/evaluation/status.py +79 -0
- canopy/frontend/__init__.py +5 -0
- canopy/frontend/audio.py +69 -0
- canopy/frontend/augment.py +56 -0
- canopy/frontend/features.py +111 -0
- canopy/inference/__init__.py +3 -0
- canopy/inference/benchmark.py +64 -0
- canopy/inference/quantize.py +36 -0
- canopy/inference/transcriber.py +101 -0
- canopy/language_model/__init__.py +4 -0
- canopy/language_model/arpa.py +188 -0
- canopy/language_model/kenlm_backend.py +62 -0
- canopy/language_model/text.py +38 -0
- canopy/languages.py +92 -0
- canopy/models/__init__.py +4 -0
- canopy/models/canopy_ctc.py +107 -0
- canopy/models/config.py +58 -0
- canopy/models/modules.py +183 -0
- canopy/text/__init__.py +3 -0
- canopy/text/normalize.py +110 -0
- canopy/text/rules.py +88 -0
- canopy/tokenizer/__init__.py +5 -0
- canopy/tokenizer/base.py +96 -0
- canopy/tokenizer/char.py +76 -0
- canopy/tokenizer/diagnostics.py +95 -0
- canopy/tokenizer/spm.py +111 -0
- canopy/training/__init__.py +3 -0
- canopy/training/checkpoint.py +77 -0
- canopy/training/losses.py +30 -0
- canopy/training/run.py +48 -0
- canopy/training/schedule.py +41 -0
- canopy/training/trainer.py +385 -0
- canopy/utils/__init__.py +13 -0
- canopy/utils/env.py +64 -0
README.md
CHANGED
|
@@ -1,147 +1,178 @@
|
|
| 1 |
-
---
|
| 2 |
-
language:
|
| 3 |
-
- ur
|
| 4 |
-
- sd
|
| 5 |
-
license: apache-2.0
|
| 6 |
-
pretty_name: Canopy-M
|
| 7 |
-
pipeline_tag: automatic-speech-recognition
|
| 8 |
-
library_name: pytorch
|
| 9 |
-
tags:
|
| 10 |
-
- automatic-speech-recognition
|
| 11 |
-
- speech
|
| 12 |
-
- ctc
|
| 13 |
-
- conformer
|
| 14 |
-
- urdu
|
| 15 |
-
- sindhi
|
| 16 |
-
---
|
| 17 |
-
|
| 18 |
-
# Canopy-M
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
![
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
-
|
| 130 |
-
-
|
| 131 |
-
-
|
| 132 |
-
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
language:
|
| 3 |
+
- ur
|
| 4 |
+
- sd
|
| 5 |
+
license: apache-2.0
|
| 6 |
+
pretty_name: Canopy-M
|
| 7 |
+
pipeline_tag: automatic-speech-recognition
|
| 8 |
+
library_name: pytorch
|
| 9 |
+
tags:
|
| 10 |
+
- automatic-speech-recognition
|
| 11 |
+
- speech
|
| 12 |
+
- ctc
|
| 13 |
+
- conformer
|
| 14 |
+
- urdu
|
| 15 |
+
- sindhi
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
# Canopy-M
|
| 19 |
+
|
| 20 |
+
Canopy-M was trained on **Urdu** and **Sindhi**. The numbers below are the full **development** evaluation (greedy CTC, language id on, no language model). The held-out test split has not been scored.
|
| 21 |
+
|
| 22 |
+
| | Urdu `urd` | Sindhi `snd` | Macro |
|
| 23 |
+
|---|---:|---:|---:|
|
| 24 |
+
| CER | 0.132 [0.065, 0.211] | 0.107 [0.091, 0.123] | 0.119 |
|
| 25 |
+
| WER | 0.267 [0.166, 0.383] | 0.271 [0.240, 0.304] | 0.269 |
|
| 26 |
+
| Utterances | 14,681 | 496 | |
|
| 27 |
+
| Speakers | 63 | 164 | |
|
| 28 |
+
|
| 29 |
+
Intervals are 95% speaker-cluster bootstrap. Lower is better. Run `canopy_exp_002_urd_snd_20261005-120848`, checkpoint selected at step 40,000, evaluated 2026-10-06.
|
| 30 |
+
|
| 31 |
+

|
| 32 |
+
|
| 33 |
+
## Model
|
| 34 |
+
|
| 35 |
+
| | |
|
| 36 |
+
|---|---|
|
| 37 |
+
| Architecture | Conformer-CTC, Metro-style Macaron blocks |
|
| 38 |
+
| Size | 57.9M inference parameters, d=384, 12 layers, 6 heads |
|
| 39 |
+
| Languages | Urdu (id 1), Sindhi (id 2). Id 0 is unconditioned |
|
| 40 |
+
| Conditioning | language embedding and a language-ID head |
|
| 41 |
+
| Tokenizer | character, `char_urd_snd` |
|
| 42 |
+
| Frontend | 16 kHz, 80-bin log-mel, 4× conv subsampling, 25 frames/s |
|
| 43 |
+
| Objective | `(1 − 0.3) · CTC + 0.3 · auxiliary CTC` at layer 6 |
|
| 44 |
+
| Decoder in this table | greedy CTC, no n-gram |
|
| 45 |
+
| Training | 40,000 steps, bf16, RTX 3090, about 9,114 hours of audio seen, ~50/50 Urdu/Sindhi |
|
| 46 |
+
| Code | Apache-2.0 |
|
| 47 |
+
|
| 48 |
+
One block is half SwiGLU, rotary attention, a squeeze-excite depthwise convolution (kernel 31), and a second half SwiGLU, then RMSNorm. The auxiliary head is dropped at export. Sindhi keeps its own letters: Urdu yeh, kaf, and heh mappings are not applied to Sindhi.
|
| 49 |
+
|
| 50 |
+
## Use
|
| 51 |
+
|
| 52 |
+
The model repository is private. Log in once with a token that can read `ProximaAI/Canopy-M`.
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
pip install torch torchaudio soundfile numpy pyyaml huggingface_hub
|
| 56 |
+
hf auth login
|
| 57 |
+
hf download ProximaAI/Canopy-M --local-dir Canopy-M
|
| 58 |
+
python Canopy-M/canopy_m.py clip.wav --language urd
|
| 59 |
+
python Canopy-M/canopy_m.py clip.wav --language snd --device cpu
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
`--language` accepts `urd`, `ur`, `urdu`, `snd`, `sd`, or `sindhi`. That selects the language embedding and keeps the CTC output inside that language’s letters. Decoding is greedy CTC, the same setup as the table above.
|
| 63 |
+
|
| 64 |
+
```python
|
| 65 |
+
from canopy_m import CanopyM
|
| 66 |
+
|
| 67 |
+
asr = CanopyM.from_pretrained("ProximaAI/Canopy-M") # cuda when available, otherwise cpu
|
| 68 |
+
print(asr.transcribe("clip.wav", language="urd"))
|
| 69 |
+
print(asr.transcribe("clip.wav", language="snd"))
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
Run that from the `Canopy-M` directory, or add it to `PYTHONPATH`. `canopy_m.py` and the `canopy` package are both in this repository, next to `canopy-m.pt`. A 16 kHz mono file is the expected input. Other sample rates are resampled. Files longer than 30 seconds are transcribed in chunks.
|
| 73 |
+
|
| 74 |
+
## Training data
|
| 75 |
+
|
| 76 |
+
About **186.7 hours** used in training. YouTube Sindhi is train-only and is excluded from the scores.
|
| 77 |
+
|
| 78 |
+
| Language | Source | Train hours | Dev hours | Labels |
|
| 79 |
+
|---|---|---:|---:|---|
|
| 80 |
+
| Urdu | Common Voice + mahwiz + UrduSpeech | ~79.6 | ~17 | human |
|
| 81 |
+
| Sindhi | Common Voice | 71.5 | 0.10 | human; mostly unvalidated `other` |
|
| 82 |
+
| Sindhi | FLEURS `sd_in` | 12.3 | 1.33 | human, CC-BY-4.0 |
|
| 83 |
+
| Sindhi | YouTube | 23.7 | — | Whisper pseudo-labels, train only |
|
| 84 |
+
|
| 85 |
+
## Benchmark
|
| 86 |
+
|
| 87 |
+
Same development clips and the same `eval_norm` for every system. Whisper is forced to the `urdu` or `sindhi` token, fp16, greedy, `max_new_tokens=225`.
|
| 88 |
+
|
| 89 |
+
| System | Params | Urdu CER | Urdu WER | Sindhi CER | Sindhi WER | Macro CER |
|
| 90 |
+
|---|---:|---:|---:|---:|---:|---:|
|
| 91 |
+
| **Canopy-M** | 57.9M | **0.132** | **0.267** | **0.107** | **0.271** | **0.119** |
|
| 92 |
+
| Whisper-tiny | 39M | 1.024 | 1.477 | 1.430 | 1.599 | 1.227 |
|
| 93 |
+
| Whisper-base | 74M | 0.855 | 1.247 | 0.624 | 1.083 | 0.739 |
|
| 94 |
+
| whisper-small-urdu | 244M | 0.177 | 0.330 | not run | not run | — |
|
| 95 |
+
|
| 96 |
+
`whisper-small-urdu` is `khawajaaliarshad/whisper-small-urdu`, an Urdu-only fine-tune, so it was not run on Sindhi. A value above 1.0 means the output is longer than the reference, usually a repetition or the wrong script.
|
| 97 |
+
|
| 98 |
+

|
| 99 |
+
|
| 100 |
+
### Urdu by source
|
| 101 |
+
|
| 102 |
+
14,681 utterances, 63 speakers: Common Voice 7,772 / 54 speakers, UrduSpeech 4,296 / 7, mahwiz 2,613 / 2.
|
| 103 |
+
|
| 104 |
+
| System | CV CER | UrduSpeech CER | mahwiz CER |
|
| 105 |
+
|---|---:|---:|---:|
|
| 106 |
+
| Canopy-M | 0.248 | 0.022 | 0.027 |
|
| 107 |
+
| Whisper-tiny | 1.505 | 0.642 | 0.451 |
|
| 108 |
+
| Whisper-base | 1.520 | 0.209 | 0.283 |
|
| 109 |
+
| whisper-small-urdu | 0.278 | 0.069 | 0.111 |
|
| 110 |
+
|
| 111 |
+

|
| 112 |
+
|
| 113 |
+
Paired speaker-cluster test, Δ = whisper-small-urdu minus Canopy-M. Positive means Canopy-M makes fewer errors. “No clear difference” means the 95% interval includes zero.
|
| 114 |
+
|
| 115 |
+
| Subset | ΔCER [95% CI] | Verdict |
|
| 116 |
+
|---|---|---|
|
| 117 |
+
| All Urdu | +0.046 [−0.003, +0.118] | no clear difference |
|
| 118 |
+
| Common Voice | +0.030 [−0.042, +0.184] | no clear difference |
|
| 119 |
+
| UrduSpeech | +0.047 [+0.021, +0.090] | Canopy-M lower CER |
|
| 120 |
+
| mahwiz | +0.084 [+0.074, +0.099] | Canopy-M lower CER |
|
| 121 |
+
|
| 122 |
+
whisper-small-urdu was fine-tuned on Common Voice Urdu 24.0 with its own split, so its Common Voice number may include clips it trained on. Canopy-M trained on the UrduSpeech and mahwiz training splits, so those two rows are in-domain for Canopy-M.
|
| 123 |
+
|
| 124 |
+
### Sindhi by source
|
| 125 |
+
|
| 126 |
+
496 utterances, 164 speakers: Common Voice 70 / 14 speakers, FLEURS 426 / 150 speakers. FLEURS `sd_in` is India-recorded Sindhi.
|
| 127 |
+
|
| 128 |
+
| System | CV CER | FLEURS CER |
|
| 129 |
+
|---|---:|---:|
|
| 130 |
+
| Canopy-M | 0.286 | 0.095 |
|
| 131 |
+
| Whisper-tiny | 1.911 | 1.399 |
|
| 132 |
+
| Whisper-base | 0.690 | 0.619 |
|
| 133 |
+
|
| 134 |
+

|
| 135 |
+
|
| 136 |
+
Against zero-shot Whisper-base, Canopy-M is lower on every Sindhi subset. All Sindhi ΔCER is +0.517 [+0.470, +0.568]. Whisper-base did not train on this Sindhi data. Canopy-M did.
|
| 137 |
+
|
| 138 |
+
### Robustness
|
| 139 |
+
|
| 140 |
+
Median utterance CER is 0.059 on Urdu and 0.078 on Sindhi. Canopy-M produced no runaway outputs (hypothesis longer than 3× the reference) and no empty outputs. Whisper-tiny runaway rate is 10.3% on Urdu and 9.1% on Sindhi.
|
| 141 |
+
|
| 142 |
+

|
| 143 |
+
|
| 144 |
+

|
| 145 |
+
|
| 146 |
+

|
| 147 |
+
|
| 148 |
+
Decode real-time factor on an RTX 3090 is about 0.0006 for Canopy-M, 0.0218 for Whisper-tiny, 0.0100 for Whisper-base, and 0.0069 for whisper-small-urdu. Batch sizes differed, so treat the speed column as indicative.
|
| 149 |
+
|
| 150 |
+
## Intended use
|
| 151 |
+
|
| 152 |
+
Research transcription of read Urdu and Sindhi at 16 kHz, one language per utterance, with the language given.
|
| 153 |
+
|
| 154 |
+
## Out of scope
|
| 155 |
+
|
| 156 |
+
Surveillance, speaker identification, and any decision about a person made without review. Production deployment while the test split is unscored. Telephone, broadcast, conversational, and code-switched speech.
|
| 157 |
+
|
| 158 |
+
## Limitations
|
| 159 |
+
|
| 160 |
+
- These scores are the development split. The test split (Urdu ~37 h, Sindhi FLEURS ~3.3 h plus a small Common Voice test) has not been opened.
|
| 161 |
+
- Sindhi Common Voice development audio is 0.10 hours (70 utterances). Most of the Sindhi score is FLEURS.
|
| 162 |
+
- Common Voice Sindhi training audio is mostly unvalidated `other`, included with `--include-unvalidated`.
|
| 163 |
+
- Urdu Common Voice CER is 0.248. The headline 0.132 is pulled down by UrduSpeech and mahwiz.
|
| 164 |
+
- A wrong language id can cross Urdu and Sindhi, which share a Perso-Arabic script.
|
| 165 |
+
- No word n-gram was used in this table. Fusion can still overwrite a correct acoustic hypothesis.
|
| 166 |
+
|
| 167 |
+
## Citation
|
| 168 |
+
|
| 169 |
+
```bibtex
|
| 170 |
+
@software{canopy_m_2026,
|
| 171 |
+
title = {Canopy-M: Conformer-CTC speech recognition for Urdu and Sindhi},
|
| 172 |
+
year = {2026},
|
| 173 |
+
url = {https://github.com/mwzkhalil/canopy},
|
| 174 |
+
license = {Apache-2.0}
|
| 175 |
+
}
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
Also cite Common Voice, FLEURS, and the Metro-ASR / Conformer designs the block follows.
|
canopy/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Canopy: multilingual non-autoregressive CTC ASR for Pakistani languages."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.0.dev0"
|
| 4 |
+
|
| 5 |
+
BLANK_ID = 0
|
| 6 |
+
SAMPLE_RATE = 16000
|
canopy/data/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from canopy.data.manifest import PROVENANCES, REQUIRED_FIELDS, load_manifest, make_row, validate_row
|
| 2 |
+
|
| 3 |
+
__all__ = ["PROVENANCES", "REQUIRED_FIELDS", "load_manifest", "make_row", "validate_row"]
|
canopy/data/commonvoice.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Import a locally downloaded Common Voice locale into a Canopy manifest.
|
| 2 |
+
|
| 3 |
+
Common Voice is obtained by each user from Mozilla Data Collective under the
|
| 4 |
+
platform terms, which forbid re-hosting any part of the dataset and forbid
|
| 5 |
+
attempting to identify speakers. This importer only reads local files. The
|
| 6 |
+
CV ``client_id`` is re-hashed into an opaque ``speaker_id`` used solely for
|
| 7 |
+
split construction.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import csv
|
| 13 |
+
import hashlib
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from canopy.data.manifest import make_row
|
| 19 |
+
|
| 20 |
+
CV_LICENSE = "CC0-1.0 (Mozilla Data Collective terms apply: no redistribution)"
|
| 21 |
+
CV_SOURCE = "cv_scripted_speech"
|
| 22 |
+
|
| 23 |
+
# Which TSV files map to which validation status. `invalidated.tsv` is never imported.
|
| 24 |
+
CV_TABLES = {"validated": "validated.tsv", "other": "other.tsv"}
|
| 25 |
+
|
| 26 |
+
csv.field_size_limit(min(sys.maxsize, 2**31 - 1))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _speaker_hash(client_id: str) -> str:
|
| 30 |
+
return "cv:" + hashlib.sha256(client_id.encode("utf-8")).hexdigest()[:16]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _read_tsv(path: Path) -> list[dict]:
|
| 34 |
+
with open(path, encoding="utf-8", newline="") as f:
|
| 35 |
+
return list(csv.DictReader(f, delimiter="\t", quoting=csv.QUOTE_NONE))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def read_clip_durations(locale_dir: Path) -> dict[str, float]:
|
| 39 |
+
p = locale_dir / "clip_durations.tsv"
|
| 40 |
+
if not p.exists():
|
| 41 |
+
return {}
|
| 42 |
+
out = {}
|
| 43 |
+
for row in _read_tsv(p):
|
| 44 |
+
clip = row.get("clip")
|
| 45 |
+
dur = row.get("duration[ms]") or row.get("duration")
|
| 46 |
+
if clip and dur:
|
| 47 |
+
out[clip] = float(dur) / 1000.0
|
| 48 |
+
return out
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def import_locale(locale_dir: str | os.PathLike, *, language: str, version: str, tables: tuple[str, ...] = ("validated",),
|
| 52 |
+
probe_missing_durations: bool = True, audio_root: str | os.PathLike | None = None) -> tuple[list[dict], dict]:
|
| 53 |
+
"""Return (rows, stats) for one CV locale directory (the one containing ``clips/``)."""
|
| 54 |
+
from canopy.data.generic import check_importable
|
| 55 |
+
|
| 56 |
+
check_importable(CV_SOURCE)
|
| 57 |
+
locale_dir = Path(locale_dir)
|
| 58 |
+
clips_dir = Path(audio_root) if audio_root else locale_dir / "clips"
|
| 59 |
+
durations = read_clip_durations(locale_dir)
|
| 60 |
+
stats = {"locale_dir": str(locale_dir), "language": language, "version": version, "tables": {},
|
| 61 |
+
"missing_audio": 0, "missing_duration": 0, "empty_text": 0}
|
| 62 |
+
rows: list[dict] = []
|
| 63 |
+
seen: set[str] = set()
|
| 64 |
+
for table in tables:
|
| 65 |
+
if table not in CV_TABLES:
|
| 66 |
+
raise ValueError(f"unknown CV table {table!r}; choose from {list(CV_TABLES)} ('invalidated' is never imported)")
|
| 67 |
+
path = locale_dir / CV_TABLES[table]
|
| 68 |
+
if not path.exists():
|
| 69 |
+
raise FileNotFoundError(f"{path} not found; is {locale_dir} an extracted CV locale directory?")
|
| 70 |
+
n = 0
|
| 71 |
+
for rec in _read_tsv(path):
|
| 72 |
+
clip = rec["path"]
|
| 73 |
+
if clip in seen:
|
| 74 |
+
continue
|
| 75 |
+
seen.add(clip)
|
| 76 |
+
text = (rec.get("sentence") or "").strip()
|
| 77 |
+
if not text:
|
| 78 |
+
stats["empty_text"] += 1
|
| 79 |
+
continue
|
| 80 |
+
audio = clips_dir / clip
|
| 81 |
+
if not audio.exists():
|
| 82 |
+
stats["missing_audio"] += 1
|
| 83 |
+
continue
|
| 84 |
+
dur = durations.get(clip)
|
| 85 |
+
if dur is None and probe_missing_durations:
|
| 86 |
+
try:
|
| 87 |
+
from canopy.frontend.audio import audio_info
|
| 88 |
+
|
| 89 |
+
dur = audio_info(audio).duration
|
| 90 |
+
except Exception:
|
| 91 |
+
dur = None
|
| 92 |
+
if not dur:
|
| 93 |
+
stats["missing_duration"] += 1
|
| 94 |
+
continue
|
| 95 |
+
extra = {k: rec[k] for k in ("sentence_id", "variant", "accents", "segment") if rec.get(k)}
|
| 96 |
+
extra["cv_locale"] = rec.get("locale") or locale_dir.name
|
| 97 |
+
rows.append(make_row(
|
| 98 |
+
id=f"cv{version}:{language}:{Path(clip).stem}",
|
| 99 |
+
audio_path=str(audio.resolve()),
|
| 100 |
+
duration=dur,
|
| 101 |
+
language=language,
|
| 102 |
+
speaker_id=_speaker_hash(rec["client_id"]),
|
| 103 |
+
text_raw=text,
|
| 104 |
+
source=CV_SOURCE,
|
| 105 |
+
source_version=version,
|
| 106 |
+
license=CV_LICENSE,
|
| 107 |
+
provenance="human",
|
| 108 |
+
validation="validated" if table == "validated" else "unvalidated",
|
| 109 |
+
extra=extra,
|
| 110 |
+
))
|
| 111 |
+
n += 1
|
| 112 |
+
stats["tables"][table] = n
|
| 113 |
+
return rows, stats
|
canopy/data/dataset.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import random
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
from torch.utils.data import Dataset
|
| 8 |
+
|
| 9 |
+
from canopy.frontend.audio import load_audio
|
| 10 |
+
from canopy.frontend.augment import speed_perturb
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def ctc_required_frames(tokens: list[int]) -> int:
|
| 14 |
+
"""Minimum CTC frames: one per token plus a blank between adjacent repeats."""
|
| 15 |
+
repeats = sum(1 for a, b in zip(tokens, tokens[1:]) if a == b)
|
| 16 |
+
return len(tokens) + repeats
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class FilterStats:
|
| 21 |
+
kept: int = 0
|
| 22 |
+
empty_target: int = 0
|
| 23 |
+
ctc_infeasible: int = 0
|
| 24 |
+
unknown_language: int = 0
|
| 25 |
+
|
| 26 |
+
def as_dict(self) -> dict:
|
| 27 |
+
return dict(self.__dict__)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ASRDataset(Dataset):
|
| 31 |
+
"""Utterance dataset. Features are computed on the device in the trainer.
|
| 32 |
+
|
| 33 |
+
Rows whose target cannot be emitted by CTC at the encoder frame rate (even
|
| 34 |
+
at the fastest speed perturbation) are removed up front and counted.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
def __init__(self, rows: list[dict], tokenizer, lang2id: dict[str, int], *, encoder_fps: float = 25.0,
|
| 38 |
+
speed_factors: tuple[float, ...] = (1.0,), train: bool = False, filter_targets: bool = True):
|
| 39 |
+
self.tokenizer = tokenizer
|
| 40 |
+
self.lang2id = lang2id
|
| 41 |
+
self.train = train
|
| 42 |
+
self.speed_factors = tuple(speed_factors) if train else (1.0,)
|
| 43 |
+
self.stats = FilterStats()
|
| 44 |
+
fastest = max(self.speed_factors)
|
| 45 |
+
self.rows: list[dict] = []
|
| 46 |
+
self.targets: list[list[int]] = []
|
| 47 |
+
for r in rows:
|
| 48 |
+
if r["language"] not in lang2id:
|
| 49 |
+
self.stats.unknown_language += 1
|
| 50 |
+
continue
|
| 51 |
+
ids = tokenizer.encode(r["text_train"], language=r["language"])
|
| 52 |
+
if filter_targets:
|
| 53 |
+
if not ids:
|
| 54 |
+
self.stats.empty_target += 1
|
| 55 |
+
continue
|
| 56 |
+
frames = int(r["duration"] / fastest * encoder_fps)
|
| 57 |
+
if ctc_required_frames(ids) > frames:
|
| 58 |
+
self.stats.ctc_infeasible += 1
|
| 59 |
+
continue
|
| 60 |
+
self.rows.append(r)
|
| 61 |
+
self.targets.append(ids)
|
| 62 |
+
self.stats.kept = len(self.rows)
|
| 63 |
+
|
| 64 |
+
def __len__(self) -> int:
|
| 65 |
+
return len(self.rows)
|
| 66 |
+
|
| 67 |
+
def duration(self, i: int) -> float:
|
| 68 |
+
return self.rows[i]["duration"]
|
| 69 |
+
|
| 70 |
+
def __getitem__(self, i: int) -> dict:
|
| 71 |
+
r = self.rows[i]
|
| 72 |
+
wav = load_audio(r["audio_path"])
|
| 73 |
+
if self.train and len(self.speed_factors) > 1:
|
| 74 |
+
f = random.choice(self.speed_factors)
|
| 75 |
+
wav = speed_perturb(wav, f)
|
| 76 |
+
return {
|
| 77 |
+
"id": r["id"],
|
| 78 |
+
"wav": wav,
|
| 79 |
+
"tokens": torch.tensor(self.targets[i], dtype=torch.long),
|
| 80 |
+
"lang_id": self.lang2id[r["language"]],
|
| 81 |
+
"language": r["language"],
|
| 82 |
+
"speaker_id": r["speaker_id"],
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def collate(batch: list[dict]) -> dict:
|
| 87 |
+
B = len(batch)
|
| 88 |
+
wav_lens = torch.tensor([b["wav"].numel() for b in batch], dtype=torch.long)
|
| 89 |
+
tgt_lens = torch.tensor([b["tokens"].numel() for b in batch], dtype=torch.long)
|
| 90 |
+
wavs = torch.zeros(B, int(wav_lens.max()))
|
| 91 |
+
tgts = torch.zeros(B, int(tgt_lens.max()), dtype=torch.long)
|
| 92 |
+
for i, b in enumerate(batch):
|
| 93 |
+
wavs[i, : b["wav"].numel()] = b["wav"]
|
| 94 |
+
tgts[i, : b["tokens"].numel()] = b["tokens"]
|
| 95 |
+
return {
|
| 96 |
+
"ids": [b["id"] for b in batch],
|
| 97 |
+
"languages": [b["language"] for b in batch],
|
| 98 |
+
"speaker_ids": [b["speaker_id"] for b in batch],
|
| 99 |
+
"wav": wavs,
|
| 100 |
+
"wav_lengths": wav_lens,
|
| 101 |
+
"targets": tgts,
|
| 102 |
+
"target_lengths": tgt_lens,
|
| 103 |
+
"lang_ids": torch.tensor([b["lang_id"] for b in batch], dtype=torch.long),
|
| 104 |
+
}
|
canopy/data/generic.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Import a corpus described by a simple TSV (path, text, speaker[, duration][, split]).
|
| 2 |
+
|
| 3 |
+
Used for corpora other than Common Voice (e.g. FLEURS exported locally). The
|
| 4 |
+
caller must supply the dataset id, version and licence exactly as recorded in
|
| 5 |
+
data/LICENSES.json; the importer refuses datasets whose release status there is
|
| 6 |
+
blocked or excluded.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import csv
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
from canopy.data.manifest import make_row
|
| 17 |
+
|
| 18 |
+
BLOCKED_STATUSES = {"blocked_pending_review", "excluded", "eval_only_pending_review"}
|
| 19 |
+
DEFAULT_LICENSES = Path(__file__).resolve().parents[2] / "data" / "LICENSES.json"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def license_record(dataset_id: str, licenses_path: str | os.PathLike = DEFAULT_LICENSES) -> dict:
|
| 23 |
+
with open(licenses_path, encoding="utf-8") as f:
|
| 24 |
+
db = json.load(f)
|
| 25 |
+
datasets = db["datasets"]
|
| 26 |
+
if isinstance(datasets, dict):
|
| 27 |
+
rec = datasets.get(dataset_id)
|
| 28 |
+
else:
|
| 29 |
+
rec = next((d for d in datasets if d.get("id") == dataset_id), None)
|
| 30 |
+
if rec is None:
|
| 31 |
+
raise KeyError(f"dataset {dataset_id!r} is not in {licenses_path}; audit its licence before importing")
|
| 32 |
+
return rec
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def check_importable(dataset_id: str, licenses_path: str | os.PathLike = DEFAULT_LICENSES) -> dict:
|
| 36 |
+
rec = license_record(dataset_id, licenses_path)
|
| 37 |
+
status = rec.get("release_status")
|
| 38 |
+
if status in BLOCKED_STATUSES:
|
| 39 |
+
raise PermissionError(f"dataset {dataset_id!r} has release_status={status!r} in {licenses_path}; "
|
| 40 |
+
"it must not be used until the licence review is resolved")
|
| 41 |
+
return rec
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def import_tsv(tsv: str | os.PathLike, *, dataset_id: str, language: str, version: str, audio_root: str | os.PathLike | None = None,
|
| 45 |
+
licenses_path: str | os.PathLike = DEFAULT_LICENSES, provenance: str = "human") -> list[dict]:
|
| 46 |
+
rec = check_importable(dataset_id, licenses_path)
|
| 47 |
+
license_str = rec.get("license", "unknown")
|
| 48 |
+
tsv = Path(tsv)
|
| 49 |
+
root = Path(audio_root) if audio_root else tsv.parent
|
| 50 |
+
rows = []
|
| 51 |
+
with open(tsv, encoding="utf-8", newline="") as f:
|
| 52 |
+
for i, r in enumerate(csv.DictReader(f, delimiter="\t", quoting=csv.QUOTE_NONE)):
|
| 53 |
+
audio = Path(r["path"])
|
| 54 |
+
if not audio.is_absolute():
|
| 55 |
+
audio = root / audio
|
| 56 |
+
dur = r.get("duration")
|
| 57 |
+
if not dur:
|
| 58 |
+
from canopy.frontend.audio import audio_info
|
| 59 |
+
|
| 60 |
+
dur = audio_info(audio).duration
|
| 61 |
+
extra = {"split": r["split"]} if r.get("split") else None
|
| 62 |
+
rows.append(make_row(
|
| 63 |
+
id=f"{dataset_id}:{language}:{r.get('id') or i}",
|
| 64 |
+
audio_path=str(audio.resolve()),
|
| 65 |
+
duration=float(dur),
|
| 66 |
+
language=language,
|
| 67 |
+
speaker_id=f"{dataset_id}:{r['speaker']}",
|
| 68 |
+
text_raw=r["text"],
|
| 69 |
+
source=dataset_id,
|
| 70 |
+
source_version=version,
|
| 71 |
+
license=license_str,
|
| 72 |
+
provenance=provenance,
|
| 73 |
+
extra=extra,
|
| 74 |
+
))
|
| 75 |
+
return rows
|
canopy/data/manifest.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Manifest schema.
|
| 2 |
+
|
| 3 |
+
A manifest is a JSONL file with one utterance per line. Manifests reference
|
| 4 |
+
audio on the local disk and are never committed or published with text:
|
| 5 |
+
Common Voice terms forbid re-hosting any part of the corpus.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
from collections.abc import Iterable
|
| 12 |
+
|
| 13 |
+
from canopy.text.normalize import get_normalizer
|
| 14 |
+
from canopy.utils.io import read_jsonl, sha256_text
|
| 15 |
+
|
| 16 |
+
REQUIRED_FIELDS = (
|
| 17 |
+
"id", "audio_path", "duration", "language", "speaker_id", "text_raw", "text_train", "text_eval",
|
| 18 |
+
"sentence_key", "source", "source_version", "license", "provenance",
|
| 19 |
+
)
|
| 20 |
+
# `human`: human transcript; `pseudo`: model-generated label; `synthetic`: synthetic audio (e.g. TTS).
|
| 21 |
+
PROVENANCES = ("human", "pseudo", "synthetic")
|
| 22 |
+
SPLITS = ("train", "dev", "test")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def sentence_key(language: str, text_eval: str) -> str:
|
| 26 |
+
"""Identity of a sentence for leakage control: same language + same scoring text."""
|
| 27 |
+
return sha256_text(f"{language}\t{text_eval}")[:20]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def make_row(*, id: str, audio_path: str, duration: float, language: str, speaker_id: str, text_raw: str,
|
| 31 |
+
source: str, source_version: str, license: str, provenance: str = "human",
|
| 32 |
+
validation: str | None = None, extra: dict | None = None) -> dict:
|
| 33 |
+
if provenance not in PROVENANCES:
|
| 34 |
+
raise ValueError(f"provenance must be one of {PROVENANCES}, got {provenance!r}")
|
| 35 |
+
norm = get_normalizer(language)
|
| 36 |
+
text_train = norm.train_norm(text_raw)
|
| 37 |
+
text_eval = norm.eval_norm(text_raw)
|
| 38 |
+
row = {
|
| 39 |
+
"id": id,
|
| 40 |
+
"audio_path": audio_path,
|
| 41 |
+
"duration": round(float(duration), 4),
|
| 42 |
+
"language": language,
|
| 43 |
+
"speaker_id": speaker_id,
|
| 44 |
+
"text_raw": text_raw,
|
| 45 |
+
"text_train": text_train,
|
| 46 |
+
"text_eval": text_eval,
|
| 47 |
+
"sentence_key": sentence_key(language, text_eval),
|
| 48 |
+
"source": source,
|
| 49 |
+
"source_version": source_version,
|
| 50 |
+
"license": license,
|
| 51 |
+
"provenance": provenance,
|
| 52 |
+
}
|
| 53 |
+
if validation is not None:
|
| 54 |
+
row["validation"] = validation
|
| 55 |
+
if extra:
|
| 56 |
+
row.update(extra)
|
| 57 |
+
return row
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def validate_row(row: dict) -> list[str]:
|
| 61 |
+
errors = [f"missing field {k!r}" for k in REQUIRED_FIELDS if k not in row]
|
| 62 |
+
if errors:
|
| 63 |
+
return errors
|
| 64 |
+
if row["provenance"] not in PROVENANCES:
|
| 65 |
+
errors.append(f"bad provenance {row['provenance']!r}")
|
| 66 |
+
if "split" in row and row["split"] not in SPLITS and not str(row["split"]).startswith("fold"):
|
| 67 |
+
errors.append(f"bad split {row['split']!r}")
|
| 68 |
+
if not isinstance(row["duration"], (int, float)) or row["duration"] <= 0:
|
| 69 |
+
errors.append("non-positive duration")
|
| 70 |
+
return errors
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def load_manifest(paths: str | os.PathLike | Iterable[str | os.PathLike], *, languages: Iterable[str] | None = None,
|
| 74 |
+
splits: Iterable[str] | None = None, provenances: Iterable[str] | None = ("human",),
|
| 75 |
+
include_unvalidated: bool = False,
|
| 76 |
+
min_duration: float = 0.0, max_duration: float | None = None) -> list[dict]:
|
| 77 |
+
"""Load and filter one or more manifests.
|
| 78 |
+
|
| 79 |
+
``provenances`` defaults to human-labelled data only; pseudo-labelled or
|
| 80 |
+
synthetic rows must be requested explicitly. Rows whose audio failed or
|
| 81 |
+
lacks community validation (``validation == "unvalidated"``) are excluded
|
| 82 |
+
unless ``include_unvalidated`` is set.
|
| 83 |
+
"""
|
| 84 |
+
if isinstance(paths, (str, os.PathLike)):
|
| 85 |
+
paths = [paths]
|
| 86 |
+
langs = set(languages) if languages else None
|
| 87 |
+
spl = set(splits) if splits else None
|
| 88 |
+
prov = set(provenances) if provenances else None
|
| 89 |
+
rows = []
|
| 90 |
+
for p in paths:
|
| 91 |
+
for row in read_jsonl(p):
|
| 92 |
+
if langs is not None and row["language"] not in langs:
|
| 93 |
+
continue
|
| 94 |
+
if spl is not None and row.get("split") not in spl:
|
| 95 |
+
continue
|
| 96 |
+
if prov is not None and row.get("provenance") not in prov:
|
| 97 |
+
continue
|
| 98 |
+
if not include_unvalidated and row.get("validation") == "unvalidated":
|
| 99 |
+
continue
|
| 100 |
+
d = row["duration"]
|
| 101 |
+
if d < min_duration or (max_duration is not None and d > max_duration):
|
| 102 |
+
continue
|
| 103 |
+
rows.append(row)
|
| 104 |
+
return rows
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def summarize(rows: Iterable[dict]) -> dict:
|
| 108 |
+
out: dict = {}
|
| 109 |
+
for r in rows:
|
| 110 |
+
key = (r["language"], r.get("split", "unsplit"))
|
| 111 |
+
s = out.setdefault(key, {"utterances": 0, "hours": 0.0, "speakers": set(), "sentences": set()})
|
| 112 |
+
s["utterances"] += 1
|
| 113 |
+
s["hours"] += r["duration"] / 3600
|
| 114 |
+
s["speakers"].add(r["speaker_id"])
|
| 115 |
+
s["sentences"].add(r["sentence_key"])
|
| 116 |
+
result: dict = {}
|
| 117 |
+
for (lang, split), s in sorted(out.items()):
|
| 118 |
+
result.setdefault(lang, {})[split] = {
|
| 119 |
+
"utterances": s["utterances"],
|
| 120 |
+
"hours": round(s["hours"], 4),
|
| 121 |
+
"speakers": len(s["speakers"]),
|
| 122 |
+
"sentences": len(s["sentences"]),
|
| 123 |
+
}
|
| 124 |
+
return result
|
canopy/data/sampler.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Duration-bucketed batching and temperature-balanced multilingual sampling.
|
| 2 |
+
|
| 3 |
+
Language ``l`` is drawn with probability ``p_l ∝ min(h_l, cap_l) ** (1/T)``
|
| 4 |
+
where ``h_l`` is its training hours. T=1 is proportional sampling, T→∞ is
|
| 5 |
+
uniform. ``cap_l`` bounds the effective size of dominant languages (Pashto has
|
| 6 |
+
>3,000 validated hours against ~10 h for most CV-Pakistan languages).
|
| 7 |
+
Each virtual epoch draws ``epoch_hours`` of audio in total.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import math
|
| 13 |
+
import random
|
| 14 |
+
from collections import defaultdict
|
| 15 |
+
from collections.abc import Iterator, Sequence
|
| 16 |
+
|
| 17 |
+
from torch.utils.data import Sampler
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def language_probabilities(hours: dict[str, float], temperature: float, caps: dict[str, float] | None = None,
|
| 21 |
+
default_cap: float | None = None) -> dict[str, float]:
|
| 22 |
+
caps = caps or {}
|
| 23 |
+
eff = {}
|
| 24 |
+
for lang, h in hours.items():
|
| 25 |
+
cap = caps.get(lang, default_cap)
|
| 26 |
+
eff[lang] = min(h, cap) if cap is not None else h
|
| 27 |
+
if math.isinf(temperature):
|
| 28 |
+
w = {lang: 1.0 for lang in eff}
|
| 29 |
+
else:
|
| 30 |
+
w = {lang: h ** (1.0 / temperature) for lang, h in eff.items() if h > 0}
|
| 31 |
+
z = sum(w.values())
|
| 32 |
+
return {lang: v / z for lang, v in w.items()}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _pack(indices: list[int], durations: Sequence[float], max_batch_seconds: float, max_batch_size: int) -> list[list[int]]:
|
| 36 |
+
batches, cur, cur_max = [], [], 0.0
|
| 37 |
+
for i in indices:
|
| 38 |
+
d = durations[i]
|
| 39 |
+
new_max = max(cur_max, d)
|
| 40 |
+
if cur and (new_max * (len(cur) + 1) > max_batch_seconds or len(cur) >= max_batch_size):
|
| 41 |
+
batches.append(cur)
|
| 42 |
+
cur, new_max = [], d
|
| 43 |
+
cur.append(i)
|
| 44 |
+
cur_max = new_max
|
| 45 |
+
if cur:
|
| 46 |
+
batches.append(cur)
|
| 47 |
+
return batches
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class DurationBatchSampler(Sampler[list[int]]):
|
| 51 |
+
"""Deterministic, sorted batches for evaluation (padding-efficient)."""
|
| 52 |
+
|
| 53 |
+
def __init__(self, durations: Sequence[float], max_batch_seconds: float, max_batch_size: int = 256):
|
| 54 |
+
order = sorted(range(len(durations)), key=lambda i: durations[i])
|
| 55 |
+
self.batches = _pack(order, durations, max_batch_seconds, max_batch_size)
|
| 56 |
+
|
| 57 |
+
def __iter__(self) -> Iterator[list[int]]:
|
| 58 |
+
return iter(self.batches)
|
| 59 |
+
|
| 60 |
+
def __len__(self) -> int:
|
| 61 |
+
return len(self.batches)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class BalancedBatchSampler(Sampler[list[int]]):
|
| 65 |
+
def __init__(self, languages: Sequence[str], durations: Sequence[float], *, max_batch_seconds: float,
|
| 66 |
+
max_batch_size: int = 128, temperature: float = 5.0, caps_hours: dict[str, float] | None = None,
|
| 67 |
+
default_cap_hours: float | None = None, epoch_hours: float | None = None, bucket_size: int = 64,
|
| 68 |
+
seed: int = 0):
|
| 69 |
+
self.languages = list(languages)
|
| 70 |
+
self.durations = list(durations)
|
| 71 |
+
self.max_batch_seconds = max_batch_seconds
|
| 72 |
+
self.max_batch_size = max_batch_size
|
| 73 |
+
self.bucket_size = bucket_size
|
| 74 |
+
self.seed = seed
|
| 75 |
+
self.by_lang: dict[str, list[int]] = defaultdict(list)
|
| 76 |
+
for i, lang in enumerate(self.languages):
|
| 77 |
+
self.by_lang[lang].append(i)
|
| 78 |
+
self.hours = {lang: sum(self.durations[i] for i in idx) / 3600 for lang, idx in self.by_lang.items()}
|
| 79 |
+
self.probs = language_probabilities(self.hours, temperature, caps_hours, default_cap_hours)
|
| 80 |
+
total_h = sum(self.hours.values())
|
| 81 |
+
self.epoch_hours = epoch_hours if epoch_hours is not None else total_h
|
| 82 |
+
self.epoch = 0
|
| 83 |
+
self.start_batch = 0
|
| 84 |
+
self._batches: list[list[int]] = []
|
| 85 |
+
self._audit: dict = {}
|
| 86 |
+
self.set_epoch(0)
|
| 87 |
+
|
| 88 |
+
def set_epoch(self, epoch: int) -> None:
|
| 89 |
+
self.epoch = epoch
|
| 90 |
+
rng = random.Random(f"{self.seed}:{epoch}")
|
| 91 |
+
drawn: list[int] = []
|
| 92 |
+
audit = {}
|
| 93 |
+
for lang in sorted(self.by_lang):
|
| 94 |
+
p = self.probs.get(lang, 0.0)
|
| 95 |
+
budget = p * self.epoch_hours * 3600
|
| 96 |
+
pool = self.by_lang[lang]
|
| 97 |
+
got, secs, cycles = [], 0.0, 0
|
| 98 |
+
order: list[int] = []
|
| 99 |
+
while secs < budget and pool:
|
| 100 |
+
if not order:
|
| 101 |
+
order = pool[:]
|
| 102 |
+
rng.shuffle(order)
|
| 103 |
+
cycles += 1
|
| 104 |
+
i = order.pop()
|
| 105 |
+
got.append(i)
|
| 106 |
+
secs += self.durations[i]
|
| 107 |
+
drawn.extend(got)
|
| 108 |
+
audit[lang] = {"probability": round(p, 6), "utterances": len(got), "seconds": round(secs, 2),
|
| 109 |
+
"passes_over_data": round(secs / max(1e-9, self.hours[lang] * 3600), 3)}
|
| 110 |
+
rng.shuffle(drawn)
|
| 111 |
+
batches: list[list[int]] = []
|
| 112 |
+
chunk = self.bucket_size * max(1, self.max_batch_size // 4)
|
| 113 |
+
for s in range(0, len(drawn), chunk):
|
| 114 |
+
part = sorted(drawn[s : s + chunk], key=lambda i: self.durations[i])
|
| 115 |
+
batches.extend(_pack(part, self.durations, self.max_batch_seconds, self.max_batch_size))
|
| 116 |
+
rng.shuffle(batches)
|
| 117 |
+
self._batches = batches
|
| 118 |
+
self._audit = {"epoch": epoch, "languages": audit, "batches": len(batches)}
|
| 119 |
+
|
| 120 |
+
def audit(self) -> dict:
|
| 121 |
+
return self._audit
|
| 122 |
+
|
| 123 |
+
def __iter__(self) -> Iterator[list[int]]:
|
| 124 |
+
start, self.start_batch = self.start_batch, 0
|
| 125 |
+
return iter(self._batches[start:])
|
| 126 |
+
|
| 127 |
+
def __len__(self) -> int:
|
| 128 |
+
return len(self._batches)
|
canopy/data/splits.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Speaker- AND sentence-disjoint split construction.
|
| 2 |
+
|
| 3 |
+
Common Voice re-records each sentence by several speakers, so speaker-disjoint
|
| 4 |
+
splits alone leak transcripts from train into test. The procedure, per
|
| 5 |
+
language:
|
| 6 |
+
|
| 7 |
+
1. Assign whole speakers to ``test``, then ``dev``, until each reaches its
|
| 8 |
+
target share of hours and minimum speaker count; the rest go to ``train``.
|
| 9 |
+
2. Drop dev rows whose sentence occurs in test, and train rows whose sentence
|
| 10 |
+
occurs in dev or test. Dropped rows are returned and counted, never
|
| 11 |
+
silently discarded.
|
| 12 |
+
|
| 13 |
+
Languages with too few speakers for a fixed held-out set get k speaker folds
|
| 14 |
+
instead (``fold`` field); evaluation then cross-validates over folds.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import random
|
| 20 |
+
from collections import defaultdict
|
| 21 |
+
from collections.abc import Iterable
|
| 22 |
+
from dataclasses import dataclass, field
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class SplitConfig:
|
| 27 |
+
test_fraction: float = 0.10
|
| 28 |
+
dev_fraction: float = 0.10
|
| 29 |
+
min_test_speakers: int = 3
|
| 30 |
+
min_dev_speakers: int = 2
|
| 31 |
+
# One speaker may provide at most this share of a held-out split's target hours
|
| 32 |
+
# (unless no other speaker is left).
|
| 33 |
+
max_speaker_share: float = 0.34
|
| 34 |
+
# Below this many speakers a language gets speaker folds instead of fixed splits.
|
| 35 |
+
min_speakers_for_fixed: int = 10
|
| 36 |
+
n_folds: int = 5
|
| 37 |
+
seed: int = 1234
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class SplitResult:
|
| 42 |
+
rows: list[dict]
|
| 43 |
+
dropped: list[dict]
|
| 44 |
+
report: dict = field(default_factory=dict)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _by(rows: Iterable[dict], key: str) -> dict[str, list[dict]]:
|
| 48 |
+
out: dict[str, list[dict]] = defaultdict(list)
|
| 49 |
+
for r in rows:
|
| 50 |
+
out[r[key]].append(r)
|
| 51 |
+
return out
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _hours(rows: Iterable[dict]) -> float:
|
| 55 |
+
return sum(r["duration"] for r in rows) / 3600
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _pick_speakers(speakers: list[str], spk_hours: dict[str, float], target_h: float, min_spk: int,
|
| 59 |
+
max_share: float) -> list[str]:
|
| 60 |
+
chosen: list[str] = []
|
| 61 |
+
total = 0.0
|
| 62 |
+
cap = max_share * target_h
|
| 63 |
+
for s in list(speakers):
|
| 64 |
+
if total >= target_h and len(chosen) >= min_spk:
|
| 65 |
+
break
|
| 66 |
+
if spk_hours[s] > cap and len(speakers) - len(chosen) > min_spk:
|
| 67 |
+
continue
|
| 68 |
+
chosen.append(s)
|
| 69 |
+
total += spk_hours[s]
|
| 70 |
+
if len(chosen) < min_spk:
|
| 71 |
+
for s in speakers:
|
| 72 |
+
if s not in chosen:
|
| 73 |
+
chosen.append(s)
|
| 74 |
+
if len(chosen) >= min_spk:
|
| 75 |
+
break
|
| 76 |
+
return chosen
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _fixed_split(rows: list[dict], cfg: SplitConfig, rng: random.Random) -> tuple[list[dict], list[dict], dict]:
|
| 80 |
+
spk_rows = _by(rows, "speaker_id")
|
| 81 |
+
spk_hours = {s: _hours(rs) for s, rs in spk_rows.items()}
|
| 82 |
+
speakers = sorted(spk_rows)
|
| 83 |
+
rng.shuffle(speakers)
|
| 84 |
+
total_h = _hours(rows)
|
| 85 |
+
|
| 86 |
+
test_spk = _pick_speakers(speakers, spk_hours, cfg.test_fraction * total_h, cfg.min_test_speakers, cfg.max_speaker_share)
|
| 87 |
+
rest = [s for s in speakers if s not in set(test_spk)]
|
| 88 |
+
dev_spk = _pick_speakers(rest, spk_hours, cfg.dev_fraction * total_h, cfg.min_dev_speakers, cfg.max_speaker_share)
|
| 89 |
+
assign = {s: "train" for s in speakers}
|
| 90 |
+
assign.update({s: "test" for s in test_spk})
|
| 91 |
+
assign.update({s: "dev" for s in dev_spk})
|
| 92 |
+
|
| 93 |
+
test_sent = {r["sentence_key"] for r in rows if assign[r["speaker_id"]] == "test"}
|
| 94 |
+
dev_sent = {r["sentence_key"] for r in rows if assign[r["speaker_id"]] == "dev"}
|
| 95 |
+
kept, dropped = [], []
|
| 96 |
+
for r in rows:
|
| 97 |
+
split = assign[r["speaker_id"]]
|
| 98 |
+
leak = (split == "dev" and r["sentence_key"] in test_sent) or (
|
| 99 |
+
split == "train" and (r["sentence_key"] in test_sent or r["sentence_key"] in dev_sent))
|
| 100 |
+
out = dict(r, split=split)
|
| 101 |
+
if leak:
|
| 102 |
+
out["drop_reason"] = "sentence_in_heldout"
|
| 103 |
+
dropped.append(out)
|
| 104 |
+
else:
|
| 105 |
+
kept.append(out)
|
| 106 |
+
return kept, dropped, {"mode": "fixed", "test_speakers": len(test_spk), "dev_speakers": len(dev_spk)}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _fold_split(rows: list[dict], cfg: SplitConfig, rng: random.Random) -> tuple[list[dict], list[dict], dict]:
|
| 110 |
+
spk_rows = _by(rows, "speaker_id")
|
| 111 |
+
speakers = sorted(spk_rows)
|
| 112 |
+
rng.shuffle(speakers)
|
| 113 |
+
speakers.sort(key=lambda s: -_hours(spk_rows[s]))
|
| 114 |
+
k = max(2, min(cfg.n_folds, len(speakers)))
|
| 115 |
+
load = [0.0] * k
|
| 116 |
+
fold_of: dict[str, int] = {}
|
| 117 |
+
for s in speakers:
|
| 118 |
+
i = min(range(k), key=lambda j: load[j])
|
| 119 |
+
fold_of[s] = i
|
| 120 |
+
load[i] += _hours(spk_rows[s])
|
| 121 |
+
kept = [dict(r, split=f"fold{fold_of[r['speaker_id']]}", fold=fold_of[r["speaker_id"]]) for r in rows]
|
| 122 |
+
return kept, [], {"mode": "speaker_folds", "n_folds": k, "fold_hours": [round(x, 3) for x in load]}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def make_splits(rows: list[dict], cfg: SplitConfig | None = None, respect_existing: bool = True) -> SplitResult:
|
| 126 |
+
"""Assign ``split`` to every row. Rows that already carry a split (e.g. FLEURS
|
| 127 |
+
official dev/test) keep it when ``respect_existing`` is set."""
|
| 128 |
+
cfg = cfg or SplitConfig()
|
| 129 |
+
fixed_rows = [r for r in rows if respect_existing and r.get("split")]
|
| 130 |
+
todo = [r for r in rows if not (respect_existing and r.get("split"))]
|
| 131 |
+
out_rows, dropped = list(fixed_rows), []
|
| 132 |
+
report: dict = {"config": cfg.__dict__, "languages": {}}
|
| 133 |
+
for lang, lrows in sorted(_by(todo, "language").items()):
|
| 134 |
+
rng = random.Random(f"{cfg.seed}:{lang}")
|
| 135 |
+
n_spk = len({r["speaker_id"] for r in lrows})
|
| 136 |
+
if n_spk < cfg.min_speakers_for_fixed:
|
| 137 |
+
kept, drop, info = _fold_split(lrows, cfg, rng)
|
| 138 |
+
else:
|
| 139 |
+
kept, drop, info = _fixed_split(lrows, cfg, rng)
|
| 140 |
+
out_rows.extend(kept)
|
| 141 |
+
dropped.extend(drop)
|
| 142 |
+
info.update({
|
| 143 |
+
"speakers": n_spk,
|
| 144 |
+
"input_hours": round(_hours(lrows), 4),
|
| 145 |
+
"dropped_rows": len(drop),
|
| 146 |
+
"dropped_hours": round(_hours(drop), 4),
|
| 147 |
+
"splits": {s: {"rows": len(rs), "hours": round(_hours(rs), 4), "speakers": len({r['speaker_id'] for r in rs})}
|
| 148 |
+
for s, rs in sorted(_by(kept, "split").items())},
|
| 149 |
+
})
|
| 150 |
+
report["languages"][lang] = info
|
| 151 |
+
report["leakage"] = check_disjoint(out_rows)
|
| 152 |
+
return SplitResult(rows=out_rows, dropped=dropped, report=report)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def check_disjoint(rows: Iterable[dict]) -> dict:
|
| 156 |
+
"""Count speaker and sentence overlaps between splits, per language. All counts must be 0."""
|
| 157 |
+
per: dict[str, dict[str, dict[str, set]]] = defaultdict(lambda: defaultdict(lambda: {"spk": set(), "sent": set()}))
|
| 158 |
+
for r in rows:
|
| 159 |
+
split = r.get("split")
|
| 160 |
+
if split is None:
|
| 161 |
+
continue
|
| 162 |
+
group = "fold" if str(split).startswith("fold") else "fixed"
|
| 163 |
+
per[(r["language"], group)][split]["spk"].add(r["speaker_id"])
|
| 164 |
+
per[(r["language"], group)][split]["sent"].add(r["sentence_key"])
|
| 165 |
+
out: dict = {"speaker_overlaps": 0, "sentence_overlaps": 0, "details": []}
|
| 166 |
+
for (lang, group), splits in per.items():
|
| 167 |
+
names = sorted(splits)
|
| 168 |
+
for i, a in enumerate(names):
|
| 169 |
+
for b in names[i + 1:]:
|
| 170 |
+
s = len(splits[a]["spk"] & splits[b]["spk"])
|
| 171 |
+
t = len(splits[a]["sent"] & splits[b]["sent"])
|
| 172 |
+
# Folds share sentences by design; the train view drops them (fold_view).
|
| 173 |
+
if group == "fold":
|
| 174 |
+
t = 0
|
| 175 |
+
if s or t:
|
| 176 |
+
out["details"].append({"language": lang, "a": a, "b": b, "speakers": s, "sentences": t})
|
| 177 |
+
out["speaker_overlaps"] += s
|
| 178 |
+
out["sentence_overlaps"] += t
|
| 179 |
+
return out
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def fold_view(rows: list[dict], language: str, fold: int) -> tuple[list[dict], list[dict]]:
|
| 183 |
+
"""(train, test) for one speaker fold, with test sentences removed from train."""
|
| 184 |
+
lrows = [r for r in rows if r["language"] == language and "fold" in r]
|
| 185 |
+
test = [r for r in lrows if r["fold"] == fold]
|
| 186 |
+
test_sent = {r["sentence_key"] for r in test}
|
| 187 |
+
train = [r for r in lrows if r["fold"] != fold and r["sentence_key"] not in test_sent]
|
| 188 |
+
return train, test
|
canopy/data/validation.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Automated data-quality checks producing ``data_quality_report.json``.
|
| 2 |
+
|
| 3 |
+
Examples in the report are utterance IDs only (no transcripts), so the report
|
| 4 |
+
can be shared without redistributing corpus text.
|
| 5 |
+
|
| 6 |
+
Speaker near-duplicate detection is deliberately NOT implemented for Common
|
| 7 |
+
Voice: its terms forbid attempts to determine speaker identity.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import unicodedata
|
| 13 |
+
from collections import Counter, defaultdict
|
| 14 |
+
from collections.abc import Callable
|
| 15 |
+
|
| 16 |
+
from canopy.data.manifest import validate_row
|
| 17 |
+
from canopy.data.splits import check_disjoint
|
| 18 |
+
from canopy.languages import LanguageRegistry, get_registry
|
| 19 |
+
|
| 20 |
+
MAX_EXAMPLES = 20
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def char_script(ch: str) -> str:
|
| 24 |
+
if ch.isspace():
|
| 25 |
+
return "space"
|
| 26 |
+
cat = unicodedata.category(ch)
|
| 27 |
+
if cat == "Nd":
|
| 28 |
+
return "digit"
|
| 29 |
+
if cat[0] == "M":
|
| 30 |
+
return "mark"
|
| 31 |
+
name = unicodedata.name(ch, "")
|
| 32 |
+
if name.startswith("ARABIC"):
|
| 33 |
+
return "Arab"
|
| 34 |
+
if name.startswith("LATIN"):
|
| 35 |
+
return "Latn"
|
| 36 |
+
if name.startswith(("DEVANAGARI",)):
|
| 37 |
+
return "Deva"
|
| 38 |
+
if name.startswith(("GURMUKHI",)):
|
| 39 |
+
return "Guru"
|
| 40 |
+
if name.startswith(("TIBETAN",)):
|
| 41 |
+
return "Tibt"
|
| 42 |
+
if ch in "'\u2019\u02bc\u200c":
|
| 43 |
+
return "joiner_or_apostrophe"
|
| 44 |
+
return "other"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class _Issues:
|
| 48 |
+
def __init__(self):
|
| 49 |
+
self.counts: dict[str, Counter] = defaultdict(Counter)
|
| 50 |
+
self.examples: dict[str, dict[str, list[str]]] = defaultdict(lambda: defaultdict(list))
|
| 51 |
+
|
| 52 |
+
def add(self, lang: str, issue: str, utt_id: str):
|
| 53 |
+
self.counts[lang][issue] += 1
|
| 54 |
+
ex = self.examples[lang][issue]
|
| 55 |
+
if len(ex) < MAX_EXAMPLES:
|
| 56 |
+
ex.append(utt_id)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def validate_manifest(rows: list[dict], *, registry: LanguageRegistry | None = None, check_audio: bool = False,
|
| 60 |
+
expected_sr: int = 16000, min_duration: float = 0.3, max_duration: float = 30.0,
|
| 61 |
+
max_chars_per_second: float = 30.0, min_chars_per_second: float = 0.3,
|
| 62 |
+
token_counter: Callable[[str], tuple[int, int]] | None = None, frames_per_second: float = 25.0) -> dict:
|
| 63 |
+
"""Return the data-quality report dict.
|
| 64 |
+
|
| 65 |
+
``token_counter(text) -> (n_tokens, n_adjacent_repeats)`` enables the CTC
|
| 66 |
+
feasibility check at ``frames_per_second`` (encoder output rate).
|
| 67 |
+
"""
|
| 68 |
+
registry = registry or get_registry()
|
| 69 |
+
issues = _Issues()
|
| 70 |
+
ids = Counter(r.get("id") for r in rows)
|
| 71 |
+
audio_hashes: dict[str, str] = {}
|
| 72 |
+
sent_counts: dict[str, Counter] = defaultdict(Counter)
|
| 73 |
+
char_scripts: dict[str, Counter] = defaultdict(Counter)
|
| 74 |
+
|
| 75 |
+
for r in rows:
|
| 76 |
+
lang = r.get("language", "<none>")
|
| 77 |
+
uid = r.get("id", "<no-id>")
|
| 78 |
+
for e in validate_row(r):
|
| 79 |
+
issues.add(lang, f"schema:{e}", uid)
|
| 80 |
+
if ids[uid] > 1:
|
| 81 |
+
issues.add(lang, "duplicate_id", uid)
|
| 82 |
+
if lang not in registry:
|
| 83 |
+
issues.add(lang, "unknown_language_code", uid)
|
| 84 |
+
allowed_scripts = set()
|
| 85 |
+
else:
|
| 86 |
+
L = registry[lang]
|
| 87 |
+
allowed_scripts = {L.primary_script, *L.alternate_scripts}
|
| 88 |
+
text = r.get("text_train", "")
|
| 89 |
+
raw = r.get("text_raw", "")
|
| 90 |
+
dur = float(r.get("duration", 0) or 0)
|
| 91 |
+
if not text.strip():
|
| 92 |
+
issues.add(lang, "empty_transcript", uid)
|
| 93 |
+
if dur < min_duration:
|
| 94 |
+
issues.add(lang, "too_short", uid)
|
| 95 |
+
if dur > max_duration:
|
| 96 |
+
issues.add(lang, "too_long", uid)
|
| 97 |
+
if dur > 0 and text:
|
| 98 |
+
cps = len(text) / dur
|
| 99 |
+
if cps > max_chars_per_second:
|
| 100 |
+
issues.add(lang, "impossible_ratio_too_many_chars", uid)
|
| 101 |
+
elif cps < min_chars_per_second:
|
| 102 |
+
issues.add(lang, "impossible_ratio_too_few_chars", uid)
|
| 103 |
+
if token_counter is not None and text and dur > 0:
|
| 104 |
+
n_tok, n_rep = token_counter(text)
|
| 105 |
+
if n_tok + n_rep > int(dur * frames_per_second):
|
| 106 |
+
issues.add(lang, "ctc_infeasible", uid)
|
| 107 |
+
sent_counts[lang][r.get("sentence_key")] += 1
|
| 108 |
+
|
| 109 |
+
for ch in raw:
|
| 110 |
+
cat = unicodedata.category(ch)
|
| 111 |
+
o = ord(ch)
|
| 112 |
+
if cat in ("Co", "Cn", "Cs"):
|
| 113 |
+
issues.add(lang, "unicode_unassigned_or_private", uid)
|
| 114 |
+
elif 0xFB50 <= o <= 0xFDFF or 0xFE70 <= o <= 0xFEFE:
|
| 115 |
+
issues.add(lang, "unicode_presentation_form_in_raw", uid)
|
| 116 |
+
elif cat == "Cf" and ch != "\u200c":
|
| 117 |
+
issues.add(lang, "unicode_invisible_format_char_in_raw", uid)
|
| 118 |
+
word_scripts_mixed = False
|
| 119 |
+
for word in text.split():
|
| 120 |
+
s = {char_script(c) for c in word} - {"mark", "digit", "joiner_or_apostrophe"}
|
| 121 |
+
if len(s) > 1:
|
| 122 |
+
word_scripts_mixed = True
|
| 123 |
+
if word_scripts_mixed:
|
| 124 |
+
issues.add(lang, "mixed_script_word", uid)
|
| 125 |
+
for ch in text:
|
| 126 |
+
sc = char_script(ch)
|
| 127 |
+
char_scripts[lang][sc] += 1
|
| 128 |
+
if sc in ("Arab", "Latn", "Deva", "Guru", "Tibt") and allowed_scripts and sc not in allowed_scripts:
|
| 129 |
+
issues.add(lang, f"char_outside_registered_scripts:{sc}", uid)
|
| 130 |
+
if sc == "other":
|
| 131 |
+
issues.add(lang, "unsupported_character", uid)
|
| 132 |
+
|
| 133 |
+
if check_audio:
|
| 134 |
+
_check_audio(r, lang, uid, issues, expected_sr, audio_hashes)
|
| 135 |
+
|
| 136 |
+
per_lang: dict[str, dict] = {}
|
| 137 |
+
for lang in sorted({r.get("language", "<none>") for r in rows}):
|
| 138 |
+
lrows = [r for r in rows if r.get("language") == lang]
|
| 139 |
+
sc = sent_counts[lang]
|
| 140 |
+
per_lang[lang] = {
|
| 141 |
+
"utterances": len(lrows),
|
| 142 |
+
"hours": round(sum(r["duration"] for r in lrows) / 3600, 4),
|
| 143 |
+
"speakers": len({r["speaker_id"] for r in lrows}),
|
| 144 |
+
"unique_sentences": len(sc),
|
| 145 |
+
"max_recordings_per_sentence": max(sc.values()) if sc else 0,
|
| 146 |
+
"mean_recordings_per_sentence": round(len(lrows) / max(1, len(sc)), 3),
|
| 147 |
+
"duplicate_transcript_rows": sum(c - 1 for c in sc.values() if c > 1),
|
| 148 |
+
"char_scripts": dict(char_scripts[lang]),
|
| 149 |
+
"issues": dict(issues.counts.get(lang, {})),
|
| 150 |
+
"issue_examples": {k: v for k, v in issues.examples.get(lang, {}).items()},
|
| 151 |
+
}
|
| 152 |
+
leakage = check_disjoint(rows)
|
| 153 |
+
total_issues = sum(sum(c.values()) for c in issues.counts.values())
|
| 154 |
+
return {
|
| 155 |
+
"n_utterances": len(rows),
|
| 156 |
+
"checks": {
|
| 157 |
+
"audio_checked": check_audio,
|
| 158 |
+
"near_duplicate_speakers": "not performed: Common Voice terms prohibit attempts to identify speakers",
|
| 159 |
+
"thresholds": {"min_duration": min_duration, "max_duration": max_duration,
|
| 160 |
+
"max_chars_per_second": max_chars_per_second, "min_chars_per_second": min_chars_per_second,
|
| 161 |
+
"ctc_frames_per_second": frames_per_second if token_counter else None},
|
| 162 |
+
},
|
| 163 |
+
"speaker_leakage": {"speaker_overlaps": leakage["speaker_overlaps"], "details": leakage["details"]},
|
| 164 |
+
"sentence_leakage": {"sentence_overlaps": leakage["sentence_overlaps"]},
|
| 165 |
+
"total_issue_count": total_issues,
|
| 166 |
+
"languages": per_lang,
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _check_audio(r: dict, lang: str, uid: str, issues: _Issues, expected_sr: int, audio_hashes: dict[str, str]):
|
| 171 |
+
from canopy.frontend.audio import audio_info
|
| 172 |
+
from canopy.utils.io import sha256_file
|
| 173 |
+
|
| 174 |
+
path = r.get("audio_path")
|
| 175 |
+
try:
|
| 176 |
+
info = audio_info(path)
|
| 177 |
+
except Exception:
|
| 178 |
+
issues.add(lang, "broken_audio", uid)
|
| 179 |
+
return
|
| 180 |
+
if info.frames == 0:
|
| 181 |
+
issues.add(lang, "broken_audio", uid)
|
| 182 |
+
return
|
| 183 |
+
if info.sample_rate != expected_sr:
|
| 184 |
+
issues.add(lang, "sample_rate_not_16k", uid)
|
| 185 |
+
if info.channels != 1:
|
| 186 |
+
issues.add(lang, "not_mono", uid)
|
| 187 |
+
if abs(info.duration - float(r["duration"])) > 0.1:
|
| 188 |
+
issues.add(lang, "duration_mismatch", uid)
|
| 189 |
+
h = sha256_file(path)
|
| 190 |
+
if h in audio_hashes:
|
| 191 |
+
issues.add(lang, "duplicate_audio", uid)
|
| 192 |
+
else:
|
| 193 |
+
audio_hashes[h] = uid
|
canopy/decoding/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from canopy.decoding.beam import prefix_beam_search
|
| 2 |
+
from canopy.decoding.greedy import ctc_collapse, greedy_decode, language_mask_bias
|
| 3 |
+
|
| 4 |
+
__all__ = ["ctc_collapse", "greedy_decode", "language_mask_bias", "prefix_beam_search"]
|
canopy/decoding/beam.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CTC prefix beam search with optional word-level n-gram shallow fusion.
|
| 2 |
+
|
| 3 |
+
score(prefix) = log P_ctc(prefix) + alpha * log P_lm(words) + beta * |words|
|
| 4 |
+
|
| 5 |
+
A word is scored by the LM when it is completed, i.e. when the next emitted
|
| 6 |
+
token string contains a space (character tokenizers emit " "; SentencePiece
|
| 7 |
+
pieces that start a word begin with "▁", rendered as " ").
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import math
|
| 13 |
+
from collections.abc import Sequence
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
NEG_INF = -math.inf
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _lse(a: float, b: float) -> float:
|
| 21 |
+
if a == NEG_INF:
|
| 22 |
+
return b
|
| 23 |
+
if b == NEG_INF:
|
| 24 |
+
return a
|
| 25 |
+
m = max(a, b)
|
| 26 |
+
return m + math.log(math.exp(a - m) + math.exp(b - m))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class _LMState:
|
| 30 |
+
__slots__ = ("raw", "history", "lm", "words")
|
| 31 |
+
|
| 32 |
+
def __init__(self, raw: str, history: tuple[str, ...], lm: float, words: int):
|
| 33 |
+
self.raw, self.history, self.lm, self.words = raw, history, lm, words
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _pending_word(raw: str) -> str | None:
|
| 37 |
+
if not raw or raw.endswith(" "):
|
| 38 |
+
return None
|
| 39 |
+
parts = raw.split()
|
| 40 |
+
return parts[-1] if parts else None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def prefix_beam_search(log_probs: np.ndarray, token_strings: Sequence[str], *, beam: int = 10, lm=None,
|
| 44 |
+
alpha: float = 0.5, beta: float = 1.0, token_prune: int = 20, prune_logp: float = -12.0,
|
| 45 |
+
allowed: np.ndarray | None = None, blank: int = 0) -> list[int]:
|
| 46 |
+
"""Return the best token-id sequence for one utterance.
|
| 47 |
+
|
| 48 |
+
``log_probs``: (T, V) natural-log posteriors. ``token_strings[i]``: surface
|
| 49 |
+
string of token i ("" for blank). ``lm``: object with
|
| 50 |
+
``score(history, word) -> ln prob``, ``score_end(history) -> ln prob`` and
|
| 51 |
+
``order``. ``allowed``: optional bool (V,) output mask.
|
| 52 |
+
"""
|
| 53 |
+
T, V = log_probs.shape
|
| 54 |
+
lp = log_probs.copy()
|
| 55 |
+
if allowed is not None:
|
| 56 |
+
lp[:, ~allowed] = NEG_INF
|
| 57 |
+
use_lm = lm is not None and alpha != 0.0
|
| 58 |
+
hist_len = max(0, (lm.order - 1)) if use_lm else 0
|
| 59 |
+
|
| 60 |
+
beams: dict[tuple[int, ...], tuple[float, float]] = {(): (0.0, NEG_INF)}
|
| 61 |
+
lm_states: dict[tuple[int, ...], _LMState] = {(): _LMState("", (), 0.0, 0)}
|
| 62 |
+
|
| 63 |
+
def lm_state(prefix: tuple[int, ...]) -> _LMState:
|
| 64 |
+
st = lm_states.get(prefix)
|
| 65 |
+
if st is not None:
|
| 66 |
+
return st
|
| 67 |
+
parent = lm_state(prefix[:-1])
|
| 68 |
+
s = token_strings[prefix[-1]]
|
| 69 |
+
raw = parent.raw + s
|
| 70 |
+
history, lm_score, words = parent.history, parent.lm, parent.words
|
| 71 |
+
if " " in s:
|
| 72 |
+
w = _pending_word(parent.raw)
|
| 73 |
+
if w is not None:
|
| 74 |
+
words += 1
|
| 75 |
+
if use_lm:
|
| 76 |
+
lm_score += lm.score(history, w)
|
| 77 |
+
history = (history + (w,))[-hist_len:] if hist_len else ()
|
| 78 |
+
st = _LMState(raw, history, lm_score, words)
|
| 79 |
+
lm_states[prefix] = st
|
| 80 |
+
return st
|
| 81 |
+
|
| 82 |
+
def total(prefix, pb, pnb):
|
| 83 |
+
st = lm_state(prefix)
|
| 84 |
+
return _lse(pb, pnb) + alpha * st.lm + beta * st.words
|
| 85 |
+
|
| 86 |
+
for t in range(T):
|
| 87 |
+
row = lp[t]
|
| 88 |
+
cand = np.argsort(row)[::-1][:token_prune]
|
| 89 |
+
cand = [int(c) for c in cand if row[c] > prune_logp or c == blank]
|
| 90 |
+
nxt: dict[tuple[int, ...], list[float]] = {}
|
| 91 |
+
|
| 92 |
+
def add(p, b, nb, nxt=nxt):
|
| 93 |
+
cur = nxt.get(p)
|
| 94 |
+
if cur is None:
|
| 95 |
+
nxt[p] = [b, nb]
|
| 96 |
+
else:
|
| 97 |
+
cur[0] = _lse(cur[0], b)
|
| 98 |
+
cur[1] = _lse(cur[1], nb)
|
| 99 |
+
|
| 100 |
+
for prefix, (pb, pnb) in beams.items():
|
| 101 |
+
for c in cand:
|
| 102 |
+
p = float(row[c])
|
| 103 |
+
if c == blank:
|
| 104 |
+
add(prefix, _lse(pb, pnb) + p, NEG_INF)
|
| 105 |
+
continue
|
| 106 |
+
last = prefix[-1] if prefix else None
|
| 107 |
+
ext = prefix + (c,)
|
| 108 |
+
if c == last:
|
| 109 |
+
add(ext, NEG_INF, pb + p)
|
| 110 |
+
add(prefix, NEG_INF, pnb + p)
|
| 111 |
+
else:
|
| 112 |
+
add(ext, NEG_INF, _lse(pb, pnb) + p)
|
| 113 |
+
scored = sorted(nxt.items(), key=lambda kv: total(kv[0], kv[1][0], kv[1][1]), reverse=True)[:beam]
|
| 114 |
+
beams = {k: (v[0], v[1]) for k, v in scored}
|
| 115 |
+
|
| 116 |
+
best, best_score = (), NEG_INF
|
| 117 |
+
for prefix, (pb, pnb) in beams.items():
|
| 118 |
+
st = lm_state(prefix)
|
| 119 |
+
lm_score, words = st.lm, st.words
|
| 120 |
+
w = _pending_word(st.raw)
|
| 121 |
+
history = st.history
|
| 122 |
+
if w is not None:
|
| 123 |
+
words += 1
|
| 124 |
+
if use_lm:
|
| 125 |
+
lm_score += lm.score(history, w)
|
| 126 |
+
history = (history + (w,))[-hist_len:] if hist_len else ()
|
| 127 |
+
if use_lm:
|
| 128 |
+
lm_score += lm.score_end(history)
|
| 129 |
+
s = _lse(pb, pnb) + alpha * lm_score + beta * words
|
| 130 |
+
if s > best_score:
|
| 131 |
+
best, best_score = prefix, s
|
| 132 |
+
return list(best)
|
canopy/decoding/greedy.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Sequence
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
from canopy.tokenizer.base import BLANK_ID
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def ctc_collapse(ids: Sequence[int], blank: int = BLANK_ID) -> list[int]:
|
| 11 |
+
"""Merge repeats, then remove blanks."""
|
| 12 |
+
out, prev = [], None
|
| 13 |
+
for i in ids:
|
| 14 |
+
if i != prev and i != blank:
|
| 15 |
+
out.append(int(i))
|
| 16 |
+
prev = i
|
| 17 |
+
return out
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def language_mask_bias(tokenizer, languages: Sequence[str | None], vocab_size: int, device=None) -> torch.Tensor | None:
|
| 21 |
+
"""(B, V) additive bias: 0 for allowed tokens, -inf for tokens never seen in that
|
| 22 |
+
language's training text (output design O2). Rows for ``None`` or unknown
|
| 23 |
+
languages are all zeros (unrestricted)."""
|
| 24 |
+
if not any(languages):
|
| 25 |
+
return None
|
| 26 |
+
bias = torch.zeros(len(languages), vocab_size, device=device)
|
| 27 |
+
for b, lang in enumerate(languages):
|
| 28 |
+
if not lang:
|
| 29 |
+
continue
|
| 30 |
+
mask = tokenizer.language_mask(lang)
|
| 31 |
+
if mask is None:
|
| 32 |
+
continue
|
| 33 |
+
m = torch.tensor(mask, dtype=torch.bool, device=device)
|
| 34 |
+
bias[b, ~m] = float("-inf")
|
| 35 |
+
return bias
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@torch.no_grad()
|
| 39 |
+
def greedy_decode(log_probs: torch.Tensor, lengths: torch.Tensor, mask_bias: torch.Tensor | None = None) -> list[list[int]]:
|
| 40 |
+
if mask_bias is not None:
|
| 41 |
+
log_probs = log_probs + mask_bias.unsqueeze(1)
|
| 42 |
+
best = log_probs.argmax(dim=-1).cpu()
|
| 43 |
+
return [ctc_collapse(best[b, : int(lengths[b])].tolist()) for b in range(best.shape[0])]
|
canopy/evaluation/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from canopy.evaluation.metrics import aggregate, corpus_error_rate, edit_counts, score_utterances
|
| 2 |
+
|
| 3 |
+
__all__ = ["aggregate", "corpus_error_rate", "edit_counts", "score_utterances"]
|
canopy/evaluation/metrics.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""WER / CER with per-language, macro, weighted and worst-k aggregates and
|
| 2 |
+
speaker-cluster bootstrap confidence intervals.
|
| 3 |
+
|
| 4 |
+
Both reference and hypothesis are scored in the language's ``eval_norm`` view.
|
| 5 |
+
CER counts characters of the eval_norm string including single spaces between
|
| 6 |
+
words.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import random
|
| 12 |
+
from collections import defaultdict
|
| 13 |
+
from collections.abc import Sequence
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class EditCounts:
|
| 19 |
+
sub: int = 0
|
| 20 |
+
dele: int = 0
|
| 21 |
+
ins: int = 0
|
| 22 |
+
ref_len: int = 0
|
| 23 |
+
|
| 24 |
+
@property
|
| 25 |
+
def errors(self) -> int:
|
| 26 |
+
return self.sub + self.dele + self.ins
|
| 27 |
+
|
| 28 |
+
def __add__(self, o: EditCounts) -> EditCounts:
|
| 29 |
+
return EditCounts(self.sub + o.sub, self.dele + o.dele, self.ins + o.ins, self.ref_len + o.ref_len)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def edit_counts(ref: Sequence, hyp: Sequence) -> EditCounts:
|
| 33 |
+
"""Levenshtein alignment counts (substitutions, deletions, insertions)."""
|
| 34 |
+
n, m = len(ref), len(hyp)
|
| 35 |
+
if n == 0:
|
| 36 |
+
return EditCounts(0, 0, m, 0)
|
| 37 |
+
if m == 0:
|
| 38 |
+
return EditCounts(0, n, 0, n)
|
| 39 |
+
# Each cell holds (cost, sub, del, ins); ties prefer substitution, then deletion.
|
| 40 |
+
prev = [(j, 0, 0, j) for j in range(m + 1)]
|
| 41 |
+
for i in range(1, n + 1):
|
| 42 |
+
cur = [(i, 0, i, 0)]
|
| 43 |
+
ri = ref[i - 1]
|
| 44 |
+
for j in range(1, m + 1):
|
| 45 |
+
d = prev[j - 1]
|
| 46 |
+
if ri == hyp[j - 1]:
|
| 47 |
+
best = d
|
| 48 |
+
else:
|
| 49 |
+
best = (d[0] + 1, d[1] + 1, d[2], d[3])
|
| 50 |
+
u = prev[j]
|
| 51 |
+
if u[0] + 1 < best[0]:
|
| 52 |
+
best = (u[0] + 1, u[1], u[2] + 1, u[3])
|
| 53 |
+
left = cur[j - 1]
|
| 54 |
+
if left[0] + 1 < best[0]:
|
| 55 |
+
best = (left[0] + 1, left[1], left[2], left[3] + 1)
|
| 56 |
+
cur.append(best)
|
| 57 |
+
prev = cur
|
| 58 |
+
c = prev[m]
|
| 59 |
+
return EditCounts(c[1], c[2], c[3], n)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def corpus_error_rate(counts: Sequence[EditCounts]) -> float:
|
| 63 |
+
total = EditCounts()
|
| 64 |
+
for c in counts:
|
| 65 |
+
total = total + c
|
| 66 |
+
return total.errors / total.ref_len if total.ref_len else 0.0
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def score_utterances(items: Sequence[dict]) -> list[dict]:
|
| 70 |
+
"""``items``: dicts with language, speaker_id, ref (eval_norm), hyp (eval_norm)."""
|
| 71 |
+
out = []
|
| 72 |
+
for it in items:
|
| 73 |
+
w = edit_counts(it["ref"].split(), it["hyp"].split())
|
| 74 |
+
c = edit_counts(list(it["ref"]), list(it["hyp"]))
|
| 75 |
+
out.append({**it, "word": w, "char": c})
|
| 76 |
+
return out
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _bootstrap(scored: list[dict], unit: str, n_boot: int, seed: int) -> tuple[float, float] | None:
|
| 80 |
+
by_spk: dict[str, list[EditCounts]] = defaultdict(list)
|
| 81 |
+
for s in scored:
|
| 82 |
+
by_spk[s["speaker_id"]].append(s[unit])
|
| 83 |
+
speakers = list(by_spk)
|
| 84 |
+
if len(speakers) < 2:
|
| 85 |
+
return None
|
| 86 |
+
totals = {k: sum(v, EditCounts()) for k, v in by_spk.items()}
|
| 87 |
+
rng = random.Random(seed)
|
| 88 |
+
vals = []
|
| 89 |
+
for _ in range(n_boot):
|
| 90 |
+
e = r = 0
|
| 91 |
+
for _k in range(len(speakers)):
|
| 92 |
+
t = totals[speakers[rng.randrange(len(speakers))]]
|
| 93 |
+
e += t.errors
|
| 94 |
+
r += t.ref_len
|
| 95 |
+
vals.append(e / r if r else 0.0)
|
| 96 |
+
vals.sort()
|
| 97 |
+
return vals[int(0.025 * (n_boot - 1))], vals[int(0.975 * (n_boot - 1))]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def aggregate(scored: list[dict], n_boot: int = 1000, seed: int = 0, worst_k: int = 5) -> dict:
|
| 101 |
+
per_lang: dict[str, list[dict]] = defaultdict(list)
|
| 102 |
+
for s in scored:
|
| 103 |
+
per_lang[s["language"]].append(s)
|
| 104 |
+
langs = {}
|
| 105 |
+
for lang, rows in sorted(per_lang.items()):
|
| 106 |
+
w = sum((r["word"] for r in rows), EditCounts())
|
| 107 |
+
c = sum((r["char"] for r in rows), EditCounts())
|
| 108 |
+
entry = {
|
| 109 |
+
"utterances": len(rows),
|
| 110 |
+
"speakers": len({r["speaker_id"] for r in rows}),
|
| 111 |
+
"ref_words": w.ref_len,
|
| 112 |
+
"ref_chars": c.ref_len,
|
| 113 |
+
"wer": w.errors / w.ref_len if w.ref_len else 0.0,
|
| 114 |
+
"cer": c.errors / c.ref_len if c.ref_len else 0.0,
|
| 115 |
+
"word_edits": {"sub": w.sub, "del": w.dele, "ins": w.ins},
|
| 116 |
+
"char_edits": {"sub": c.sub, "del": c.dele, "ins": c.ins},
|
| 117 |
+
}
|
| 118 |
+
if n_boot:
|
| 119 |
+
entry["wer_ci95"] = _bootstrap(rows, "word", n_boot, seed)
|
| 120 |
+
entry["cer_ci95"] = _bootstrap(rows, "char", n_boot, seed)
|
| 121 |
+
entry["insufficient_evidence"] = entry["speakers"] < 5
|
| 122 |
+
langs[lang] = entry
|
| 123 |
+
all_w = sum((s["word"] for s in scored), EditCounts())
|
| 124 |
+
all_c = sum((s["char"] for s in scored), EditCounts())
|
| 125 |
+
macro_cer = sum(v["cer"] for v in langs.values()) / len(langs) if langs else 0.0
|
| 126 |
+
macro_wer = sum(v["wer"] for v in langs.values()) / len(langs) if langs else 0.0
|
| 127 |
+
worst = sorted(langs.items(), key=lambda kv: -kv[1]["cer"])[:worst_k]
|
| 128 |
+
return {
|
| 129 |
+
"languages": langs,
|
| 130 |
+
"macro": {"cer": macro_cer, "wer": macro_wer, "n_languages": len(langs)},
|
| 131 |
+
"weighted": {"cer": all_c.errors / all_c.ref_len if all_c.ref_len else 0.0,
|
| 132 |
+
"wer": all_w.errors / all_w.ref_len if all_w.ref_len else 0.0},
|
| 133 |
+
f"worst_{worst_k}_by_cer": [{"language": k, "cer": v["cer"], "wer": v["wer"]} for k, v in worst],
|
| 134 |
+
"bootstrap": {"unit": "speaker", "n": n_boot, "seed": seed} if n_boot else None,
|
| 135 |
+
}
|
canopy/evaluation/runner.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run a model over manifest rows and score the output."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
from contextlib import nullcontext
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
from torch.utils.data import DataLoader
|
| 11 |
+
|
| 12 |
+
from canopy.data.dataset import ASRDataset, collate
|
| 13 |
+
from canopy.data.sampler import DurationBatchSampler
|
| 14 |
+
from canopy.decoding.beam import prefix_beam_search
|
| 15 |
+
from canopy.decoding.greedy import greedy_decode, language_mask_bias
|
| 16 |
+
from canopy.evaluation.metrics import aggregate, score_utterances
|
| 17 |
+
from canopy.text.normalize import get_normalizer
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@torch.no_grad()
|
| 21 |
+
def transcribe_rows(model, tokenizer, rows: list[dict], *, lang2id: dict[str, int], device: torch.device,
|
| 22 |
+
max_batch_seconds: float = 300.0, num_workers: int = 0, use_language: bool = True,
|
| 23 |
+
use_masks: bool = True, decoder: str = "greedy", beam: int = 10, lms: dict | None = None,
|
| 24 |
+
alpha: float = 0.5, beta: float = 1.0, amp_dtype: torch.dtype | None = None) -> list[dict]:
|
| 25 |
+
"""Returns one dict per row with id, language, speaker_id, hyp_train (model output text).
|
| 26 |
+
|
| 27 |
+
``use_language``: pass the reference language to the model (conditioning)
|
| 28 |
+
and to the output mask. With ``use_language=False`` the model runs
|
| 29 |
+
language-agnostic, as for ``language=None`` at inference.
|
| 30 |
+
"""
|
| 31 |
+
model.eval()
|
| 32 |
+
ds = ASRDataset(rows, tokenizer, {**lang2id, **{r["language"]: 0 for r in rows if r["language"] not in lang2id}},
|
| 33 |
+
filter_targets=False)
|
| 34 |
+
sampler = DurationBatchSampler([r["duration"] for r in ds.rows], max_batch_seconds)
|
| 35 |
+
dl = DataLoader(ds, batch_sampler=sampler, collate_fn=collate, num_workers=num_workers)
|
| 36 |
+
token_strings = None
|
| 37 |
+
if decoder == "beam":
|
| 38 |
+
labels = tokenizer.labels_for_pyctcdecode()
|
| 39 |
+
token_strings = [""] + labels[1:]
|
| 40 |
+
out = []
|
| 41 |
+
for batch in dl:
|
| 42 |
+
wav = batch["wav"].to(device)
|
| 43 |
+
wl = batch["wav_lengths"].to(device)
|
| 44 |
+
lids = batch["lang_ids"].to(device) if use_language else None
|
| 45 |
+
ctx = torch.autocast(device.type, dtype=amp_dtype) if amp_dtype is not None else nullcontext()
|
| 46 |
+
with ctx:
|
| 47 |
+
res = model(wav, wl, lids)
|
| 48 |
+
lp = res["log_probs"]
|
| 49 |
+
langs = batch["languages"] if (use_language and use_masks) else [None] * len(batch["ids"])
|
| 50 |
+
bias = language_mask_bias(tokenizer, langs, lp.shape[-1], device=lp.device)
|
| 51 |
+
if decoder == "greedy":
|
| 52 |
+
seqs = greedy_decode(lp, res["lengths"], bias)
|
| 53 |
+
elif decoder == "beam":
|
| 54 |
+
seqs = []
|
| 55 |
+
lpn = lp.float().cpu().numpy()
|
| 56 |
+
for b in range(lp.shape[0]):
|
| 57 |
+
allowed = None
|
| 58 |
+
if bias is not None:
|
| 59 |
+
allowed = np.isfinite(bias[b].cpu().numpy())
|
| 60 |
+
lm = (lms or {}).get(batch["languages"][b]) if use_language else (lms or {}).get("*")
|
| 61 |
+
seqs.append(prefix_beam_search(lpn[b, : int(res["lengths"][b])], token_strings, beam=beam, lm=lm,
|
| 62 |
+
alpha=alpha, beta=beta, allowed=allowed))
|
| 63 |
+
else:
|
| 64 |
+
raise ValueError(f"unknown decoder {decoder!r}")
|
| 65 |
+
blank_rate = (lp.argmax(-1) == 0).float()
|
| 66 |
+
for b, seq in enumerate(seqs):
|
| 67 |
+
L = int(res["lengths"][b])
|
| 68 |
+
out.append({
|
| 69 |
+
"id": batch["ids"][b],
|
| 70 |
+
"language": batch["languages"][b],
|
| 71 |
+
"speaker_id": batch["speaker_ids"][b],
|
| 72 |
+
"hyp_train": tokenizer.decode(seq),
|
| 73 |
+
"frames": L,
|
| 74 |
+
"blank_rate": float(blank_rate[b, :L].mean()),
|
| 75 |
+
})
|
| 76 |
+
return out
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def evaluate_rows(model, tokenizer, rows: list[dict], *, n_boot: int = 1000, **kw) -> tuple[dict, list[dict]]:
|
| 80 |
+
by_id = {r["id"]: r for r in rows}
|
| 81 |
+
t0 = time.perf_counter()
|
| 82 |
+
hyps = transcribe_rows(model, tokenizer, rows, **kw)
|
| 83 |
+
elapsed = time.perf_counter() - t0
|
| 84 |
+
items = []
|
| 85 |
+
for h in hyps:
|
| 86 |
+
r = by_id[h["id"]]
|
| 87 |
+
norm = get_normalizer(r["language"])
|
| 88 |
+
items.append({"id": h["id"], "language": r["language"], "speaker_id": r["speaker_id"],
|
| 89 |
+
"ref": r["text_eval"], "hyp": norm.eval_norm(h["hyp_train"])})
|
| 90 |
+
scored = score_utterances(items)
|
| 91 |
+
metrics = aggregate(scored, n_boot=n_boot)
|
| 92 |
+
audio_s = sum(r["duration"] for r in rows)
|
| 93 |
+
metrics["decode"] = {"seconds": elapsed, "audio_seconds": audio_s, "rtf": elapsed / max(audio_s, 1e-9),
|
| 94 |
+
"mean_blank_rate": float(np.mean([h["blank_rate"] for h in hyps])) if hyps else None}
|
| 95 |
+
for h, it in zip(hyps, items):
|
| 96 |
+
h["ref_eval"] = it["ref"]
|
| 97 |
+
h["hyp_eval"] = it["hyp"]
|
| 98 |
+
return metrics, hyps
|
canopy/evaluation/status.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Language support status from evidence (RESEARCH_PLAN.md section 12.3).
|
| 2 |
+
|
| 3 |
+
Statuses are computed, never assigned by hand. Thresholds are the proposed
|
| 4 |
+
values and must be ratified before release 0.2.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass(frozen=True)
|
| 13 |
+
class Thresholds:
|
| 14 |
+
prod_train_hours: float = 50.0
|
| 15 |
+
prod_test_speakers: int = 20
|
| 16 |
+
prod_test_hours: float = 2.0
|
| 17 |
+
prod_cer: float = 0.10
|
| 18 |
+
prod_wer: float = 0.25
|
| 19 |
+
prod_min_buckets: int = 2
|
| 20 |
+
prod_ci_halfwidth: float = 0.02
|
| 21 |
+
prod_int8_shift: float = 0.01
|
| 22 |
+
sup_train_hours: float = 10.0
|
| 23 |
+
sup_test_speakers: int = 5
|
| 24 |
+
sup_test_hours: float = 0.5
|
| 25 |
+
sup_cer: float = 0.15
|
| 26 |
+
sup_wer: float = 0.40
|
| 27 |
+
limited_train_hours: float = 5.0
|
| 28 |
+
limited_speakers: int = 10
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def compute_status(*, has_clean_data: bool, evaluated: bool, train_hours: float, total_speakers: int,
|
| 32 |
+
test_speakers: int = 0, test_hours: float = 0.0, cer: float | None = None, wer: float | None = None,
|
| 33 |
+
cer_ci95: tuple[float, float] | None = None, buckets_passing: int = 0,
|
| 34 |
+
int8_cer_shift: float | None = None, native_review_done: bool = False,
|
| 35 |
+
second_seed_within_ci: bool = False, auxiliary: bool = False, th: Thresholds = Thresholds()) -> dict:
|
| 36 |
+
reasons: list[str] = []
|
| 37 |
+
if auxiliary:
|
| 38 |
+
return {"status": "auxiliary", "reasons": ["auxiliary training language"]}
|
| 39 |
+
if not has_clean_data:
|
| 40 |
+
return {"status": "not-currently-supported", "reasons": ["no licence-clean transcribed data"]}
|
| 41 |
+
if train_hours < th.limited_train_hours or total_speakers < th.limited_speakers:
|
| 42 |
+
return {"status": "data-limited",
|
| 43 |
+
"reasons": [f"train_hours={train_hours:.2f} (<{th.limited_train_hours}) or "
|
| 44 |
+
f"speakers={total_speakers} (<{th.limited_speakers})"]}
|
| 45 |
+
if not evaluated or cer is None or wer is None:
|
| 46 |
+
return {"status": "evaluation-pending", "reasons": ["not yet evaluated under the Canopy protocol"]}
|
| 47 |
+
|
| 48 |
+
ci_half = (cer_ci95[1] - cer_ci95[0]) / 2 if cer_ci95 else None
|
| 49 |
+
prod_checks = {
|
| 50 |
+
"train_hours": train_hours >= th.prod_train_hours,
|
| 51 |
+
"test_speakers": test_speakers >= th.prod_test_speakers,
|
| 52 |
+
"test_hours": test_hours >= th.prod_test_hours,
|
| 53 |
+
"cer": cer <= th.prod_cer,
|
| 54 |
+
"wer": wer <= th.prod_wer,
|
| 55 |
+
"buckets": buckets_passing >= th.prod_min_buckets,
|
| 56 |
+
"ci": ci_half is not None and ci_half <= th.prod_ci_halfwidth,
|
| 57 |
+
"int8": int8_cer_shift is not None and int8_cer_shift <= th.prod_int8_shift,
|
| 58 |
+
"native_review": native_review_done,
|
| 59 |
+
"second_seed": second_seed_within_ci,
|
| 60 |
+
}
|
| 61 |
+
if all(prod_checks.values()):
|
| 62 |
+
return {"status": "production", "reasons": ["all production criteria met"], "checks": prod_checks}
|
| 63 |
+
sup_checks = {
|
| 64 |
+
"train_hours": train_hours >= th.sup_train_hours,
|
| 65 |
+
"test_speakers": test_speakers >= th.sup_test_speakers,
|
| 66 |
+
"test_hours": test_hours >= th.sup_test_hours,
|
| 67 |
+
"cer": cer <= th.sup_cer,
|
| 68 |
+
"wer": wer <= th.sup_wer,
|
| 69 |
+
"ci_reported": cer_ci95 is not None,
|
| 70 |
+
"second_seed": second_seed_within_ci,
|
| 71 |
+
}
|
| 72 |
+
if all(sup_checks.values()):
|
| 73 |
+
failed = [k for k, v in prod_checks.items() if not v]
|
| 74 |
+
return {"status": "supported", "reasons": [f"production criteria not met: {failed}"], "checks": sup_checks}
|
| 75 |
+
failed = [k for k, v in sup_checks.items() if not v]
|
| 76 |
+
reasons.append(f"supported criteria not met: {failed}")
|
| 77 |
+
if test_speakers < th.sup_test_speakers:
|
| 78 |
+
reasons.append("insufficient evidence: fewer than 5 test speakers")
|
| 79 |
+
return {"status": "experimental", "reasons": reasons, "checks": sup_checks}
|
canopy/frontend/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from canopy.frontend.audio import load_audio, resample
|
| 2 |
+
from canopy.frontend.augment import SpecAugment, speed_perturb
|
| 3 |
+
from canopy.frontend.features import LogMelFrontend, feature_lengths, mel_filterbank
|
| 4 |
+
|
| 5 |
+
__all__ = ["LogMelFrontend", "SpecAugment", "feature_lengths", "load_audio", "mel_filterbank", "resample", "speed_perturb"]
|
canopy/frontend/audio.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audio loading and resampling. All model inputs are 16 kHz mono float32."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
|
| 11 |
+
from canopy import SAMPLE_RATE
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class AudioInfo:
|
| 16 |
+
sample_rate: int
|
| 17 |
+
channels: int
|
| 18 |
+
frames: int
|
| 19 |
+
|
| 20 |
+
@property
|
| 21 |
+
def duration(self) -> float:
|
| 22 |
+
return self.frames / self.sample_rate if self.sample_rate else 0.0
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def audio_info(path: str | os.PathLike) -> AudioInfo:
|
| 26 |
+
import soundfile as sf
|
| 27 |
+
|
| 28 |
+
i = sf.info(str(path))
|
| 29 |
+
return AudioInfo(sample_rate=int(i.samplerate), channels=int(i.channels), frames=int(i.frames))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _read(path: str) -> tuple[np.ndarray, int]:
|
| 33 |
+
try:
|
| 34 |
+
import soundfile as sf
|
| 35 |
+
|
| 36 |
+
data, sr = sf.read(path, dtype="float32", always_2d=True)
|
| 37 |
+
return data, int(sr)
|
| 38 |
+
except Exception as sf_err:
|
| 39 |
+
try:
|
| 40 |
+
import torchaudio
|
| 41 |
+
|
| 42 |
+
wav, sr = torchaudio.load(path)
|
| 43 |
+
return wav.numpy().T.astype(np.float32), int(sr)
|
| 44 |
+
except Exception as ta_err:
|
| 45 |
+
raise RuntimeError(
|
| 46 |
+
f"could not decode {path!r} (soundfile: {sf_err}; torchaudio: {ta_err}). "
|
| 47 |
+
"For MP3 input use libsndfile >= 1.1 (soundfile >= 0.12) or convert with scripts/prepare_data.py --convert."
|
| 48 |
+
) from ta_err
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def resample(wav: torch.Tensor, orig_sr: int, target_sr: int = SAMPLE_RATE) -> torch.Tensor:
|
| 52 |
+
if orig_sr == target_sr:
|
| 53 |
+
return wav
|
| 54 |
+
import torchaudio.functional as taf
|
| 55 |
+
|
| 56 |
+
return taf.resample(wav, orig_sr, target_sr)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def load_audio(path: str | os.PathLike, target_sr: int = SAMPLE_RATE) -> torch.Tensor:
|
| 60 |
+
"""Load an audio file as a 1-D float32 tensor at ``target_sr``, downmixed to mono."""
|
| 61 |
+
data, sr = _read(str(path))
|
| 62 |
+
wav = torch.from_numpy(np.ascontiguousarray(data.mean(axis=1)))
|
| 63 |
+
return resample(wav, sr, target_sr)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def save_audio(path: str | os.PathLike, wav: torch.Tensor, sr: int = SAMPLE_RATE) -> None:
|
| 67 |
+
import soundfile as sf
|
| 68 |
+
|
| 69 |
+
sf.write(str(path), wav.detach().cpu().numpy().astype(np.float32), sr)
|
canopy/frontend/augment.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import random
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from torch import nn
|
| 7 |
+
|
| 8 |
+
from canopy import SAMPLE_RATE
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def speed_perturb(wav: torch.Tensor, factor: float, sample_rate: int = SAMPLE_RATE) -> torch.Tensor:
|
| 12 |
+
"""SoX-style speed change (tempo and pitch together) by resampling."""
|
| 13 |
+
if factor == 1.0:
|
| 14 |
+
return wav
|
| 15 |
+
import torchaudio.functional as taf
|
| 16 |
+
|
| 17 |
+
src = int(round(sample_rate * factor))
|
| 18 |
+
return taf.resample(wav, src, sample_rate)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class SpecAugment(nn.Module):
|
| 22 |
+
"""Batched SpecAugment on (B, T, F) features.
|
| 23 |
+
|
| 24 |
+
Time-mask width is adaptive: at most ``time_mask_ratio`` of each
|
| 25 |
+
utterance's valid length, because Canopy clips average ~4 s (400 frames at
|
| 26 |
+
100 fps), where a fixed 50-100 frame mask would erase whole words.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
def __init__(self, freq_masks: int = 2, freq_width: int = 27, time_masks: int = 2,
|
| 30 |
+
time_mask_ratio: float = 0.05, min_time_width: int = 1):
|
| 31 |
+
super().__init__()
|
| 32 |
+
self.freq_masks = freq_masks
|
| 33 |
+
self.freq_width = freq_width
|
| 34 |
+
self.time_masks = time_masks
|
| 35 |
+
self.time_mask_ratio = time_mask_ratio
|
| 36 |
+
self.min_time_width = min_time_width
|
| 37 |
+
|
| 38 |
+
def forward(self, feats: torch.Tensor, lengths: torch.Tensor) -> torch.Tensor:
|
| 39 |
+
if not self.training:
|
| 40 |
+
return feats
|
| 41 |
+
B, T, F = feats.shape
|
| 42 |
+
mask = torch.zeros(B, T, F, dtype=torch.bool, device=feats.device)
|
| 43 |
+
for b in range(B):
|
| 44 |
+
L = int(lengths[b])
|
| 45 |
+
for _ in range(self.freq_masks):
|
| 46 |
+
w = random.randint(0, min(self.freq_width, F))
|
| 47 |
+
f0 = random.randint(0, F - w)
|
| 48 |
+
mask[b, :, f0 : f0 + w] = True
|
| 49 |
+
max_w = max(self.min_time_width, int(L * self.time_mask_ratio))
|
| 50 |
+
for _ in range(self.time_masks):
|
| 51 |
+
w = random.randint(0, max_w)
|
| 52 |
+
if L - w <= 0:
|
| 53 |
+
continue
|
| 54 |
+
t0 = random.randint(0, L - w)
|
| 55 |
+
mask[b, t0 : t0 + w, :] = True
|
| 56 |
+
return feats.masked_fill(mask, 0.0)
|
canopy/frontend/features.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Log-mel frontend implemented in plain PyTorch (ONNX-exportable, no torchaudio needed).
|
| 2 |
+
|
| 3 |
+
Defaults: 25 ms Hann window, 10 ms hop, 512-point FFT, 80 Slaney mel bins, 100 fps.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import math
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
from torch import nn
|
| 12 |
+
|
| 13 |
+
from canopy import SAMPLE_RATE
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _hz_to_mel_slaney(f: torch.Tensor) -> torch.Tensor:
|
| 17 |
+
f_sp = 200.0 / 3
|
| 18 |
+
min_log_hz = 1000.0
|
| 19 |
+
min_log_mel = min_log_hz / f_sp
|
| 20 |
+
logstep = math.log(6.4) / 27.0
|
| 21 |
+
mel = f / f_sp
|
| 22 |
+
return torch.where(f >= min_log_hz, min_log_mel + torch.log(f.clamp(min=1e-10) / min_log_hz) / logstep, mel)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _mel_to_hz_slaney(m: torch.Tensor) -> torch.Tensor:
|
| 26 |
+
f_sp = 200.0 / 3
|
| 27 |
+
min_log_hz = 1000.0
|
| 28 |
+
min_log_mel = min_log_hz / f_sp
|
| 29 |
+
logstep = math.log(6.4) / 27.0
|
| 30 |
+
return torch.where(m >= min_log_mel, min_log_hz * torch.exp(logstep * (m - min_log_mel)), f_sp * m)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def mel_filterbank(n_freqs: int, n_mels: int, sample_rate: int, f_min: float = 0.0, f_max: float | None = None) -> torch.Tensor:
|
| 34 |
+
"""Slaney-scale, Slaney-normalised triangular filterbank of shape (n_freqs, n_mels)."""
|
| 35 |
+
f_max = f_max if f_max is not None else sample_rate / 2
|
| 36 |
+
all_freqs = torch.linspace(0, sample_rate // 2, n_freqs, dtype=torch.float64)
|
| 37 |
+
m_pts = torch.linspace(_hz_to_mel_slaney(torch.tensor(f_min, dtype=torch.float64)).item(),
|
| 38 |
+
_hz_to_mel_slaney(torch.tensor(f_max, dtype=torch.float64)).item(), n_mels + 2,
|
| 39 |
+
dtype=torch.float64)
|
| 40 |
+
f_pts = _mel_to_hz_slaney(m_pts)
|
| 41 |
+
f_diff = f_pts[1:] - f_pts[:-1]
|
| 42 |
+
slopes = f_pts.unsqueeze(0) - all_freqs.unsqueeze(1)
|
| 43 |
+
down = -slopes[:, :-2] / f_diff[:-1]
|
| 44 |
+
up = slopes[:, 2:] / f_diff[1:]
|
| 45 |
+
fb = torch.clamp(torch.minimum(down, up), min=0.0)
|
| 46 |
+
enorm = 2.0 / (f_pts[2 : n_mels + 2] - f_pts[:n_mels])
|
| 47 |
+
return (fb * enorm.unsqueeze(0)).float()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def feature_lengths(num_samples: torch.Tensor, hop_length: int = 160) -> torch.Tensor:
|
| 51 |
+
"""Frame count for a centred STFT."""
|
| 52 |
+
return torch.div(num_samples, hop_length, rounding_mode="floor") + 1
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class LogMelFrontend(nn.Module):
|
| 56 |
+
def __init__(
|
| 57 |
+
self,
|
| 58 |
+
sample_rate: int = SAMPLE_RATE,
|
| 59 |
+
n_fft: int = 512,
|
| 60 |
+
win_length: int = 400,
|
| 61 |
+
hop_length: int = 160,
|
| 62 |
+
n_mels: int = 80,
|
| 63 |
+
normalize: str = "utterance",
|
| 64 |
+
eps: float = 1e-10,
|
| 65 |
+
):
|
| 66 |
+
super().__init__()
|
| 67 |
+
if normalize not in ("utterance", "per_bin", "none"):
|
| 68 |
+
raise ValueError(f"normalize must be utterance|per_bin|none, got {normalize!r}")
|
| 69 |
+
self.sample_rate = sample_rate
|
| 70 |
+
self.n_fft = n_fft
|
| 71 |
+
self.win_length = win_length
|
| 72 |
+
self.hop_length = hop_length
|
| 73 |
+
self.n_mels = n_mels
|
| 74 |
+
self.normalize = normalize
|
| 75 |
+
self.eps = eps
|
| 76 |
+
self.register_buffer("window", torch.hann_window(win_length), persistent=False)
|
| 77 |
+
self.register_buffer("fbank", mel_filterbank(n_fft // 2 + 1, n_mels, sample_rate), persistent=False)
|
| 78 |
+
|
| 79 |
+
@torch.no_grad()
|
| 80 |
+
def forward(self, wav: torch.Tensor, lengths: torch.Tensor | None = None) -> tuple[torch.Tensor, torch.Tensor]:
|
| 81 |
+
"""wav (B, S) float -> features (B, T, n_mels), lengths (B,)."""
|
| 82 |
+
with torch.autocast(device_type=wav.device.type, enabled=False):
|
| 83 |
+
return self._forward(wav, lengths)
|
| 84 |
+
|
| 85 |
+
def _forward(self, wav: torch.Tensor, lengths: torch.Tensor | None) -> tuple[torch.Tensor, torch.Tensor]:
|
| 86 |
+
if wav.dim() == 1:
|
| 87 |
+
wav = wav.unsqueeze(0)
|
| 88 |
+
wav = wav.float()
|
| 89 |
+
B, S = wav.shape
|
| 90 |
+
if lengths is None:
|
| 91 |
+
lengths = torch.full((B,), S, dtype=torch.long, device=wav.device)
|
| 92 |
+
spec = torch.stft(wav, n_fft=self.n_fft, hop_length=self.hop_length, win_length=self.win_length,
|
| 93 |
+
window=self.window, center=True, pad_mode="reflect", return_complex=True)
|
| 94 |
+
power = spec.real.pow(2) + spec.imag.pow(2) # (B, F, T)
|
| 95 |
+
mel = torch.matmul(power.transpose(1, 2), self.fbank) # (B, T, M)
|
| 96 |
+
logmel = torch.log(mel.clamp(min=self.eps))
|
| 97 |
+
feat_len = feature_lengths(lengths, self.hop_length).clamp(max=logmel.shape[1])
|
| 98 |
+
mask = torch.arange(logmel.shape[1], device=wav.device).unsqueeze(0) < feat_len.unsqueeze(1)
|
| 99 |
+
m = mask.unsqueeze(-1).float()
|
| 100 |
+
if self.normalize == "utterance":
|
| 101 |
+
n = (feat_len.float() * self.n_mels).view(B, 1, 1)
|
| 102 |
+
mean = (logmel * m).sum(dim=(1, 2), keepdim=True) / n
|
| 103 |
+
var = (((logmel - mean) * m) ** 2).sum(dim=(1, 2), keepdim=True) / n
|
| 104 |
+
logmel = (logmel - mean) / (var.sqrt() + 1e-6)
|
| 105 |
+
elif self.normalize == "per_bin":
|
| 106 |
+
n = feat_len.float().view(B, 1, 1)
|
| 107 |
+
mean = (logmel * m).sum(dim=1, keepdim=True) / n
|
| 108 |
+
var = (((logmel - mean) * m) ** 2).sum(dim=1, keepdim=True) / n
|
| 109 |
+
logmel = (logmel - mean) / (var.sqrt() + 1e-6)
|
| 110 |
+
logmel = logmel * m
|
| 111 |
+
return logmel, feat_len
|
canopy/inference/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from canopy.inference.transcriber import Transcriber
|
| 2 |
+
|
| 3 |
+
__all__ = ["Transcriber"]
|
canopy/inference/benchmark.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CPU real-time-factor protocol (docs/BENCHMARK_PLAN.md).
|
| 2 |
+
|
| 3 |
+
Fixed durations, fixed thread count, warm-up runs excluded, median and p90
|
| 4 |
+
over repeats. Synthetic noise input is used for latency only (never for
|
| 5 |
+
accuracy), so the benchmark needs no dataset.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import platform
|
| 12 |
+
import statistics
|
| 13 |
+
import time
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
|
| 17 |
+
from canopy import SAMPLE_RATE
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _rss_mb() -> float | None:
|
| 21 |
+
try:
|
| 22 |
+
import psutil
|
| 23 |
+
|
| 24 |
+
return psutil.Process(os.getpid()).memory_info().rss / 2**20
|
| 25 |
+
except Exception:
|
| 26 |
+
return None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@torch.no_grad()
|
| 30 |
+
def measure_rtf(model, durations=(1.0, 5.0, 10.0, 30.0), threads: int = 4, warmup: int = 3, repeats: int = 15,
|
| 31 |
+
device: str = "cpu", seed: int = 0) -> dict:
|
| 32 |
+
torch.set_num_threads(threads)
|
| 33 |
+
model = model.to(device).eval()
|
| 34 |
+
g = torch.Generator().manual_seed(seed)
|
| 35 |
+
results = {}
|
| 36 |
+
for d in durations:
|
| 37 |
+
n = int(d * SAMPLE_RATE)
|
| 38 |
+
wav = (0.05 * torch.randn(1, n, generator=g)).to(device)
|
| 39 |
+
lens = torch.tensor([n], device=device)
|
| 40 |
+
for _ in range(warmup):
|
| 41 |
+
model(wav, lens)
|
| 42 |
+
times = []
|
| 43 |
+
for _ in range(repeats):
|
| 44 |
+
t0 = time.perf_counter()
|
| 45 |
+
model(wav, lens)
|
| 46 |
+
if device.startswith("cuda"):
|
| 47 |
+
torch.cuda.synchronize()
|
| 48 |
+
times.append(time.perf_counter() - t0)
|
| 49 |
+
times.sort()
|
| 50 |
+
med = statistics.median(times)
|
| 51 |
+
results[f"{d:g}s"] = {"median_s": med, "p90_s": times[int(0.9 * (len(times) - 1))], "rtf_median": med / d,
|
| 52 |
+
"rtf_p90": times[int(0.9 * (len(times) - 1))] / d}
|
| 53 |
+
return {
|
| 54 |
+
"device": device,
|
| 55 |
+
"threads": threads,
|
| 56 |
+
"warmup": warmup,
|
| 57 |
+
"repeats": repeats,
|
| 58 |
+
"torch": torch.__version__,
|
| 59 |
+
"cpu": platform.processor() or platform.machine(),
|
| 60 |
+
"platform": platform.platform(),
|
| 61 |
+
"rss_mb": _rss_mb(),
|
| 62 |
+
"parameters": sum(p.numel() for p in model.parameters()),
|
| 63 |
+
"results": results,
|
| 64 |
+
}
|
canopy/inference/quantize.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dynamic INT8 quantization of Linear layers for CPU inference.
|
| 2 |
+
|
| 3 |
+
INT8 must pass the per-language regression gate (docs/BENCHMARK_PLAN.md)
|
| 4 |
+
before any INT8 artifact is released: evaluate FP32 and INT8 on the same dev
|
| 5 |
+
set and compare per-language CER.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import copy
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
from torch import nn
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def quantize_dynamic_int8(model: nn.Module) -> nn.Module:
|
| 17 |
+
m = copy.deepcopy(model).cpu().eval()
|
| 18 |
+
try:
|
| 19 |
+
from torch.ao.quantization import quantize_dynamic
|
| 20 |
+
except ImportError: # pragma: no cover
|
| 21 |
+
from torch.quantization import quantize_dynamic
|
| 22 |
+
return quantize_dynamic(m, {nn.Linear}, dtype=torch.qint8)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def int8_regression(fp32_metrics: dict, int8_metrics: dict, max_abs_cer_shift: float = 0.01) -> dict:
|
| 26 |
+
rows = {}
|
| 27 |
+
worst = 0.0
|
| 28 |
+
for lang, m in fp32_metrics["languages"].items():
|
| 29 |
+
q = int8_metrics["languages"].get(lang)
|
| 30 |
+
if q is None:
|
| 31 |
+
continue
|
| 32 |
+
shift = q["cer"] - m["cer"]
|
| 33 |
+
worst = max(worst, shift)
|
| 34 |
+
rows[lang] = {"fp32_cer": m["cer"], "int8_cer": q["cer"], "shift": shift, "pass": shift <= max_abs_cer_shift}
|
| 35 |
+
return {"max_abs_cer_shift": max_abs_cer_shift, "worst_shift": worst,
|
| 36 |
+
"passed": all(r["pass"] for r in rows.values()), "languages": rows}
|
canopy/inference/transcriber.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import tempfile
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
from canopy import SAMPLE_RATE
|
| 11 |
+
from canopy.decoding.beam import prefix_beam_search
|
| 12 |
+
from canopy.decoding.greedy import greedy_decode, language_mask_bias
|
| 13 |
+
from canopy.frontend.audio import load_audio
|
| 14 |
+
from canopy.tokenizer.base import load_tokenizer
|
| 15 |
+
from canopy.training.checkpoint import load_checkpoint, model_from_checkpoint
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _tokenizer_from_checkpoint(ck: dict, fallback_dir: str | None = None):
|
| 19 |
+
if "tokenizer_payload" in ck:
|
| 20 |
+
tmp = Path(tempfile.mkdtemp(prefix="canopy_tok_"))
|
| 21 |
+
(tmp / "tokenizer.json").write_text(json.dumps(ck["tokenizer_payload"], ensure_ascii=False), encoding="utf-8")
|
| 22 |
+
if ck.get("spm_model"):
|
| 23 |
+
(tmp / "spm.model").write_bytes(ck["spm_model"])
|
| 24 |
+
return load_tokenizer(tmp)
|
| 25 |
+
return load_tokenizer(fallback_dir or ck["tokenizer_dir"])
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class Transcriber:
|
| 29 |
+
"""Load a checkpoint and transcribe audio.
|
| 30 |
+
|
| 31 |
+
``language``: ISO 639-3 code. When given, the model is conditioned on it
|
| 32 |
+
(if trained with a language embedding), the output is restricted to that
|
| 33 |
+
language's tokens, and that language's LM is used. ``None`` runs
|
| 34 |
+
language-agnostic decoding.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
def __init__(self, model, tokenizer, device: str = "cpu", max_chunk_seconds: float = 30.0):
|
| 38 |
+
self.model = model.to(device).eval()
|
| 39 |
+
self.tokenizer = tokenizer
|
| 40 |
+
self.device = torch.device(device)
|
| 41 |
+
self.languages = list(model.cfg.languages)
|
| 42 |
+
self.lang2id = {lang: i + 1 for i, lang in enumerate(self.languages)}
|
| 43 |
+
self.max_chunk = int(max_chunk_seconds * SAMPLE_RATE)
|
| 44 |
+
self.lms: dict[str, object] = {}
|
| 45 |
+
|
| 46 |
+
@classmethod
|
| 47 |
+
def from_checkpoint(cls, path: str | Path, device: str = "cpu", tokenizer_dir: str | None = None, **kw) -> Transcriber:
|
| 48 |
+
ck = load_checkpoint(path)
|
| 49 |
+
model = model_from_checkpoint(ck)
|
| 50 |
+
return cls(model, _tokenizer_from_checkpoint(ck, tokenizer_dir), device=device, **kw)
|
| 51 |
+
|
| 52 |
+
@classmethod
|
| 53 |
+
def from_pretrained(cls, repo_id: str = "ProximaAI/Canopy-M", *, filename: str = "canopy-m.pt",
|
| 54 |
+
device: str = "cpu", revision: str | None = None, token: str | None = None, **kw) -> Transcriber:
|
| 55 |
+
"""Download an inference checkpoint from the Hugging Face Hub and load it."""
|
| 56 |
+
try:
|
| 57 |
+
from huggingface_hub import hf_hub_download
|
| 58 |
+
except ImportError as exc:
|
| 59 |
+
raise ImportError("pip install huggingface_hub") from exc
|
| 60 |
+
path = hf_hub_download(repo_id=repo_id, filename=filename, revision=revision, token=token)
|
| 61 |
+
return cls.from_checkpoint(path, device=device, **kw)
|
| 62 |
+
|
| 63 |
+
def attach_lm(self, language: str, lm) -> None:
|
| 64 |
+
"""``lm``: ArpaLM or KenLMScorer. Use language "*" for a language-agnostic LM."""
|
| 65 |
+
self.lms[language] = lm
|
| 66 |
+
|
| 67 |
+
@torch.no_grad()
|
| 68 |
+
def log_probs(self, wav: torch.Tensor, language: str | None = None) -> torch.Tensor:
|
| 69 |
+
lid = None
|
| 70 |
+
if language is not None and self.model.lang_embed is not None:
|
| 71 |
+
lid = torch.tensor([self.lang2id.get(language, 0)], device=self.device)
|
| 72 |
+
chunks = [wav[i : i + self.max_chunk] for i in range(0, max(1, wav.numel()), self.max_chunk)]
|
| 73 |
+
outs = []
|
| 74 |
+
for c in chunks:
|
| 75 |
+
if c.numel() < 400:
|
| 76 |
+
continue
|
| 77 |
+
x = c.unsqueeze(0).to(self.device)
|
| 78 |
+
res = self.model(x, torch.tensor([c.numel()], device=self.device), lid)
|
| 79 |
+
outs.append(res["log_probs"][0, : int(res["lengths"][0])])
|
| 80 |
+
if not outs:
|
| 81 |
+
return torch.zeros(0, self.tokenizer.vocab_size)
|
| 82 |
+
return torch.cat(outs, dim=0)
|
| 83 |
+
|
| 84 |
+
def transcribe(self, audio, language: str | None = None, decoder: str = "greedy", beam: int = 10,
|
| 85 |
+
alpha: float = 0.5, beta: float = 1.0) -> str:
|
| 86 |
+
wav = load_audio(audio) if isinstance(audio, (str, Path)) else torch.as_tensor(audio, dtype=torch.float32)
|
| 87 |
+
lp = self.log_probs(wav, language)
|
| 88 |
+
if lp.shape[0] == 0:
|
| 89 |
+
return ""
|
| 90 |
+
bias = language_mask_bias(self.tokenizer, [language], lp.shape[-1], device=lp.device) if language else None
|
| 91 |
+
if decoder == "greedy":
|
| 92 |
+
ids = greedy_decode(lp.unsqueeze(0), torch.tensor([lp.shape[0]]), bias)[0]
|
| 93 |
+
elif decoder == "beam":
|
| 94 |
+
labels = self.tokenizer.labels_for_pyctcdecode()
|
| 95 |
+
allowed = np.isfinite(bias[0].cpu().numpy()) if bias is not None else None
|
| 96 |
+
lm = self.lms.get(language or "*") or self.lms.get("*")
|
| 97 |
+
ids = prefix_beam_search(lp.float().cpu().numpy(), [""] + labels[1:], beam=beam, lm=lm, alpha=alpha,
|
| 98 |
+
beta=beta, allowed=allowed)
|
| 99 |
+
else:
|
| 100 |
+
raise ValueError(f"unknown decoder {decoder!r}")
|
| 101 |
+
return self.tokenizer.decode(ids)
|
canopy/language_model/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from canopy.language_model.arpa import ArpaLM, build_arpa, write_arpa
|
| 2 |
+
from canopy.language_model.text import filter_heldout, lm_sentences
|
| 3 |
+
|
| 4 |
+
__all__ = ["ArpaLM", "build_arpa", "filter_heldout", "lm_sentences", "write_arpa"]
|
canopy/language_model/arpa.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Word n-gram LMs in ARPA format: a pure-Python reader/scorer and a small
|
| 2 |
+
builder (interpolated absolute discounting, written as a back-off ARPA).
|
| 3 |
+
|
| 4 |
+
For large corpora build with KenLM's ``lmplz`` (scripts/train_lm.py
|
| 5 |
+
--backend kenlm); the pure-Python builder is intended for the small
|
| 6 |
+
per-language CV-Pakistan corpora and for platforms without KenLM binaries.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import gzip
|
| 12 |
+
import math
|
| 13 |
+
from collections import Counter, defaultdict
|
| 14 |
+
from collections.abc import Iterable
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
LN10 = math.log(10.0)
|
| 18 |
+
BOS, EOS, UNK = "<s>", "</s>", "<unk>"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class ArpaLM:
|
| 22 |
+
def __init__(self, order: int, probs: dict[tuple[str, ...], float], backoffs: dict[tuple[str, ...], float],
|
| 23 |
+
unk_logp10: float = -7.0):
|
| 24 |
+
self.order = order
|
| 25 |
+
self.probs = probs
|
| 26 |
+
self.backoffs = backoffs
|
| 27 |
+
self.unk_logp10 = probs.get((UNK,), unk_logp10)
|
| 28 |
+
|
| 29 |
+
@classmethod
|
| 30 |
+
def load(cls, path: str | Path) -> ArpaLM:
|
| 31 |
+
path = Path(path)
|
| 32 |
+
opener = gzip.open if path.suffix == ".gz" else open
|
| 33 |
+
probs: dict[tuple[str, ...], float] = {}
|
| 34 |
+
backoffs: dict[tuple[str, ...], float] = {}
|
| 35 |
+
order = 0
|
| 36 |
+
section = None
|
| 37 |
+
with opener(path, "rt", encoding="utf-8") as f:
|
| 38 |
+
for line in f:
|
| 39 |
+
line = line.strip()
|
| 40 |
+
if not line:
|
| 41 |
+
continue
|
| 42 |
+
if line == "\\data\\":
|
| 43 |
+
section = "data"
|
| 44 |
+
continue
|
| 45 |
+
if line == "\\end\\":
|
| 46 |
+
break
|
| 47 |
+
if line.startswith("\\") and line.endswith("-grams:"):
|
| 48 |
+
section = int(line[1:].split("-")[0])
|
| 49 |
+
order = max(order, section)
|
| 50 |
+
continue
|
| 51 |
+
if section == "data" or section is None:
|
| 52 |
+
continue
|
| 53 |
+
parts = line.split("\t")
|
| 54 |
+
if len(parts) == 1:
|
| 55 |
+
parts = line.split()
|
| 56 |
+
n = section
|
| 57 |
+
logp = float(parts[0])
|
| 58 |
+
words = tuple(parts[1 : 1 + n])
|
| 59 |
+
bo = float(parts[1 + n]) if len(parts) > 1 + n else None
|
| 60 |
+
else:
|
| 61 |
+
logp = float(parts[0])
|
| 62 |
+
words = tuple(parts[1].split())
|
| 63 |
+
bo = float(parts[2]) if len(parts) > 2 else None
|
| 64 |
+
probs[words] = logp
|
| 65 |
+
if bo is not None:
|
| 66 |
+
backoffs[words] = bo
|
| 67 |
+
return cls(order, probs, backoffs)
|
| 68 |
+
|
| 69 |
+
def _logp10(self, context: tuple[str, ...], word: str) -> float:
|
| 70 |
+
context = context[-(self.order - 1):] if self.order > 1 else ()
|
| 71 |
+
bo_total = 0.0
|
| 72 |
+
while True:
|
| 73 |
+
key = context + (word,)
|
| 74 |
+
if key in self.probs:
|
| 75 |
+
return bo_total + self.probs[key]
|
| 76 |
+
if not context:
|
| 77 |
+
return bo_total + self.unk_logp10
|
| 78 |
+
bo_total += self.backoffs.get(context, 0.0)
|
| 79 |
+
context = context[1:]
|
| 80 |
+
|
| 81 |
+
def score(self, history: tuple[str, ...], word: str) -> float:
|
| 82 |
+
"""Natural-log P(word | <s> history)."""
|
| 83 |
+
return self._logp10((BOS,) + tuple(history), word) * LN10
|
| 84 |
+
|
| 85 |
+
def score_end(self, history: tuple[str, ...]) -> float:
|
| 86 |
+
return self._logp10((BOS,) + tuple(history), EOS) * LN10
|
| 87 |
+
|
| 88 |
+
def sentence_logprob(self, words: list[str]) -> float:
|
| 89 |
+
h: list[str] = []
|
| 90 |
+
total = 0.0
|
| 91 |
+
for w in words:
|
| 92 |
+
total += self.score(tuple(h), w)
|
| 93 |
+
h.append(w)
|
| 94 |
+
return total + self.score_end(tuple(h))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def build_arpa(sentences: Iterable[str], order: int = 3, discount: float = 0.75, unk_mass: float = 1e-3) -> tuple[dict, dict, dict]:
|
| 98 |
+
"""Interpolated absolute-discounting n-gram model.
|
| 99 |
+
|
| 100 |
+
Returns (probs_log10, backoffs_log10, counts_per_order) for ``write_arpa``.
|
| 101 |
+
"""
|
| 102 |
+
counts: list[Counter] = [Counter() for _ in range(order + 1)]
|
| 103 |
+
for s in sentences:
|
| 104 |
+
toks = [BOS] + s.split() + [EOS]
|
| 105 |
+
for n in range(1, order + 1):
|
| 106 |
+
for i in range(len(toks) - n + 1):
|
| 107 |
+
ng = tuple(toks[i : i + n])
|
| 108 |
+
if n == 1 and ng == (BOS,):
|
| 109 |
+
continue
|
| 110 |
+
counts[n][ng] += 1
|
| 111 |
+
|
| 112 |
+
# Unigrams: maximum likelihood, with a fixed mass reserved for <unk>.
|
| 113 |
+
total1 = sum(counts[1].values())
|
| 114 |
+
p: dict[tuple[str, ...], float] = {}
|
| 115 |
+
for ng, c in counts[1].items():
|
| 116 |
+
p[ng] = (1 - unk_mass) * c / total1
|
| 117 |
+
p[(UNK,)] = unk_mass
|
| 118 |
+
p[(BOS,)] = 0.0
|
| 119 |
+
|
| 120 |
+
ctx_counts: list[dict] = [dict() for _ in range(order + 1)]
|
| 121 |
+
followers: list[dict] = [defaultdict(int) for _ in range(order + 1)]
|
| 122 |
+
for n in range(2, order + 1):
|
| 123 |
+
cc: dict[tuple[str, ...], int] = defaultdict(int)
|
| 124 |
+
for ng, c in counts[n].items():
|
| 125 |
+
cc[ng[:-1]] += c
|
| 126 |
+
followers[n][ng[:-1]] += 1
|
| 127 |
+
ctx_counts[n] = cc
|
| 128 |
+
|
| 129 |
+
def lower(ng: tuple[str, ...]) -> float:
|
| 130 |
+
if len(ng) == 1:
|
| 131 |
+
return p.get(ng, p[(UNK,)])
|
| 132 |
+
return p.get(ng) if ng in p else lower_interp(ng)
|
| 133 |
+
|
| 134 |
+
def lower_interp(ng: tuple[str, ...]) -> float:
|
| 135 |
+
# Probability of an unseen n-gram via back-off to its suffix.
|
| 136 |
+
ctx = ng[:-1]
|
| 137 |
+
n = len(ng)
|
| 138 |
+
if ctx not in ctx_counts[n]:
|
| 139 |
+
return lower(ng[1:])
|
| 140 |
+
lam = discount * followers[n][ctx] / ctx_counts[n][ctx]
|
| 141 |
+
return lam * lower(ng[1:])
|
| 142 |
+
|
| 143 |
+
for n in range(2, order + 1):
|
| 144 |
+
for ng, c in counts[n].items():
|
| 145 |
+
ctx = ng[:-1]
|
| 146 |
+
cc = ctx_counts[n][ctx]
|
| 147 |
+
lam = discount * followers[n][ctx] / cc
|
| 148 |
+
p[ng] = max(c - discount, 0.0) / cc + lam * lower(ng[1:])
|
| 149 |
+
|
| 150 |
+
# Back-off weights so that unseen continuations of each context sum correctly.
|
| 151 |
+
bows: dict[tuple[str, ...], float] = {}
|
| 152 |
+
for n in range(2, order + 1):
|
| 153 |
+
seen_by_ctx: dict[tuple[str, ...], list[tuple[str, ...]]] = defaultdict(list)
|
| 154 |
+
for ng in counts[n]:
|
| 155 |
+
seen_by_ctx[ng[:-1]].append(ng)
|
| 156 |
+
for ctx, ngs in seen_by_ctx.items():
|
| 157 |
+
num = 1.0 - sum(p[ng] for ng in ngs)
|
| 158 |
+
den = 1.0 - sum(lower(ng[1:]) for ng in ngs)
|
| 159 |
+
if num <= 0 or den <= 0:
|
| 160 |
+
bows[ctx] = 0.0
|
| 161 |
+
else:
|
| 162 |
+
bows[ctx] = math.log10(num / den)
|
| 163 |
+
|
| 164 |
+
probs10 = {ng: (math.log10(v) if v > 0 else -99.0) for ng, v in p.items()}
|
| 165 |
+
return probs10, bows, {n: len(counts[n]) + (2 if n == 1 else 0) for n in range(1, order + 1)}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def write_arpa(path: str | Path, probs10: dict, bows: dict, order: int) -> None:
|
| 169 |
+
by_n: dict[int, list[tuple[str, ...]]] = defaultdict(list)
|
| 170 |
+
for ng in probs10:
|
| 171 |
+
by_n[len(ng)].append(ng)
|
| 172 |
+
path = Path(path)
|
| 173 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 174 |
+
opener = gzip.open if path.suffix == ".gz" else open
|
| 175 |
+
with opener(path, "wt", encoding="utf-8") as f:
|
| 176 |
+
f.write("\\data\\\n")
|
| 177 |
+
for n in range(1, order + 1):
|
| 178 |
+
f.write(f"ngram {n}={len(by_n[n])}\n")
|
| 179 |
+
for n in range(1, order + 1):
|
| 180 |
+
f.write(f"\n\\{n}-grams:\n")
|
| 181 |
+
for ng in sorted(by_n[n]):
|
| 182 |
+
line = f"{probs10[ng]:.6f}\t{' '.join(ng)}"
|
| 183 |
+
if n < order and ng in bows:
|
| 184 |
+
line += f"\t{bows[ng]:.6f}"
|
| 185 |
+
elif n < order and ng == (BOS,):
|
| 186 |
+
line += "\t0.0"
|
| 187 |
+
f.write(line + "\n")
|
| 188 |
+
f.write("\n\\end\\\n")
|
canopy/language_model/kenlm_backend.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Optional KenLM integration: building with ``lmplz``/``build_binary`` and
|
| 2 |
+
scoring through the ``kenlm`` Python module (both LGPL-2.1, not vendored)."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import shutil
|
| 7 |
+
import subprocess
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
LN10 = 2.302585092994046
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def build_with_lmplz(text_file: str | Path, out_arpa: str | Path, order: int = 4, prune: list[int] | None = None,
|
| 14 |
+
memory: str = "40%", lmplz: str | None = None, discount_fallback: bool = True) -> Path:
|
| 15 |
+
exe = lmplz or shutil.which("lmplz")
|
| 16 |
+
if not exe:
|
| 17 |
+
raise FileNotFoundError("KenLM `lmplz` not found on PATH; build KenLM (docs/TRAINING.md) or use --backend python")
|
| 18 |
+
cmd = [exe, "-o", str(order), "-S", memory, "--text", str(text_file), "--arpa", str(out_arpa)]
|
| 19 |
+
if prune:
|
| 20 |
+
cmd += ["--prune", *map(str, prune)]
|
| 21 |
+
if discount_fallback:
|
| 22 |
+
cmd.append("--discount_fallback")
|
| 23 |
+
subprocess.run(cmd, check=True)
|
| 24 |
+
return Path(out_arpa)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def build_binary(arpa: str | Path, out_bin: str | Path, exe: str | None = None) -> Path:
|
| 28 |
+
exe = exe or shutil.which("build_binary")
|
| 29 |
+
if not exe:
|
| 30 |
+
raise FileNotFoundError("KenLM `build_binary` not found on PATH")
|
| 31 |
+
subprocess.run([exe, str(arpa), str(out_bin)], check=True)
|
| 32 |
+
return Path(out_bin)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class KenLMScorer:
|
| 36 |
+
"""Same interface as ArpaLM (natural-log scores) on top of ``kenlm.Model``."""
|
| 37 |
+
|
| 38 |
+
def __init__(self, path: str | Path):
|
| 39 |
+
import kenlm
|
| 40 |
+
|
| 41 |
+
self.model = kenlm.Model(str(path))
|
| 42 |
+
self.order = self.model.order
|
| 43 |
+
self._kenlm = kenlm
|
| 44 |
+
|
| 45 |
+
def _state_for(self, history: tuple[str, ...]):
|
| 46 |
+
st = self._kenlm.State()
|
| 47 |
+
self.model.BeginSentenceWrite(st)
|
| 48 |
+
for w in history[-(self.order - 1):]:
|
| 49 |
+
out = self._kenlm.State()
|
| 50 |
+
self.model.BaseScore(st, w, out)
|
| 51 |
+
st = out
|
| 52 |
+
return st
|
| 53 |
+
|
| 54 |
+
def score(self, history: tuple[str, ...], word: str) -> float:
|
| 55 |
+
st = self._state_for(history)
|
| 56 |
+
out = self._kenlm.State()
|
| 57 |
+
return self.model.BaseScore(st, word, out) * LN10
|
| 58 |
+
|
| 59 |
+
def score_end(self, history: tuple[str, ...]) -> float:
|
| 60 |
+
st = self._state_for(history)
|
| 61 |
+
out = self._kenlm.State()
|
| 62 |
+
return self.model.BaseScore(st, "</s>", out) * LN10
|
canopy/language_model/text.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LM training text preparation with held-out filtering.
|
| 2 |
+
|
| 3 |
+
LM text is in ``text_train`` space (what the acoustic model emits). Any
|
| 4 |
+
sentence whose ``sentence_key`` occurs in a dev or test manifest is removed,
|
| 5 |
+
because CV-Pakistan sentences are re-recorded by several speakers and would
|
| 6 |
+
otherwise leak test transcripts into the LM.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from collections.abc import Iterable
|
| 12 |
+
|
| 13 |
+
from canopy.data.manifest import sentence_key
|
| 14 |
+
from canopy.text.normalize import get_normalizer
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def filter_heldout(sentences: Iterable[str], language: str, heldout_keys: set[str]) -> tuple[list[str], int]:
|
| 18 |
+
norm = get_normalizer(language)
|
| 19 |
+
kept, dropped = [], 0
|
| 20 |
+
for s in sentences:
|
| 21 |
+
if sentence_key(language, norm.eval_norm(s)) in heldout_keys:
|
| 22 |
+
dropped += 1
|
| 23 |
+
continue
|
| 24 |
+
t = norm.train_norm(s)
|
| 25 |
+
if t:
|
| 26 |
+
kept.append(t)
|
| 27 |
+
return kept, dropped
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def lm_sentences(train_rows: Iterable[dict], language: str, heldout_rows: Iterable[dict], extra_text: Iterable[str] = (),
|
| 31 |
+
dedupe: bool = True) -> tuple[list[str], dict]:
|
| 32 |
+
heldout = {r["sentence_key"] for r in heldout_rows if r["language"] == language}
|
| 33 |
+
base = [r["text_raw"] for r in train_rows if r["language"] == language]
|
| 34 |
+
kept, dropped = filter_heldout(list(base) + list(extra_text), language, heldout)
|
| 35 |
+
if dedupe:
|
| 36 |
+
kept = list(dict.fromkeys(kept))
|
| 37 |
+
return kept, {"language": language, "sentences": len(kept), "dropped_heldout_matches": dropped,
|
| 38 |
+
"heldout_sentence_keys": len(heldout)}
|
canopy/languages.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Access to the master language registry (configs/languages.yaml)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from functools import lru_cache
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from canopy.utils.io import load_yaml
|
| 10 |
+
|
| 11 |
+
DEFAULT_REGISTRY = Path(__file__).resolve().parents[1] / "configs" / "languages.yaml"
|
| 12 |
+
|
| 13 |
+
STATUSES = (
|
| 14 |
+
"production",
|
| 15 |
+
"supported",
|
| 16 |
+
"experimental",
|
| 17 |
+
"data-limited",
|
| 18 |
+
"evaluation-pending",
|
| 19 |
+
"not-currently-supported",
|
| 20 |
+
"auxiliary",
|
| 21 |
+
)
|
| 22 |
+
# Statuses whose data may be used for training. `not-currently-supported`
|
| 23 |
+
# means there is no licence-clean transcribed data at all.
|
| 24 |
+
TRAINABLE_STATUSES = frozenset(STATUSES) - {"not-currently-supported"}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass(frozen=True)
|
| 28 |
+
class Language:
|
| 29 |
+
code: str
|
| 30 |
+
name: str
|
| 31 |
+
status: str
|
| 32 |
+
primary_script: str
|
| 33 |
+
alternate_scripts: tuple[str, ...] = ()
|
| 34 |
+
family: str | None = None
|
| 35 |
+
omnilingual_seen: bool | None = None
|
| 36 |
+
raw: dict = field(default_factory=dict, compare=False, repr=False)
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def trainable(self) -> bool:
|
| 40 |
+
return self.status in TRAINABLE_STATUSES
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class LanguageRegistry:
|
| 44 |
+
def __init__(self, path: str | Path = DEFAULT_REGISTRY):
|
| 45 |
+
self.path = Path(path)
|
| 46 |
+
data = load_yaml(self.path)
|
| 47 |
+
self.schema_version = data.get("schema_version")
|
| 48 |
+
self._langs: dict[str, Language] = {}
|
| 49 |
+
for entry in data["languages"]:
|
| 50 |
+
scripts = entry.get("scripts") or {}
|
| 51 |
+
status = entry.get("status")
|
| 52 |
+
if status not in STATUSES:
|
| 53 |
+
raise ValueError(f"{self.path}: language {entry.get('code')!r} has unknown status {status!r}")
|
| 54 |
+
lang = Language(
|
| 55 |
+
code=entry["code"],
|
| 56 |
+
name=entry["name"],
|
| 57 |
+
status=status,
|
| 58 |
+
primary_script=scripts.get("primary", "Arab"),
|
| 59 |
+
alternate_scripts=tuple(scripts.get("alternates") or ()),
|
| 60 |
+
family=entry.get("family"),
|
| 61 |
+
omnilingual_seen=entry.get("omnilingual_seen"),
|
| 62 |
+
raw=entry,
|
| 63 |
+
)
|
| 64 |
+
if lang.code in self._langs:
|
| 65 |
+
raise ValueError(f"{self.path}: duplicate language code {lang.code!r}")
|
| 66 |
+
self._langs[lang.code] = lang
|
| 67 |
+
|
| 68 |
+
def __contains__(self, code: str) -> bool:
|
| 69 |
+
return code in self._langs
|
| 70 |
+
|
| 71 |
+
def __getitem__(self, code: str) -> Language:
|
| 72 |
+
try:
|
| 73 |
+
return self._langs[code]
|
| 74 |
+
except KeyError:
|
| 75 |
+
raise KeyError(f"unknown language code {code!r}; add it to {self.path} first") from None
|
| 76 |
+
|
| 77 |
+
def __iter__(self):
|
| 78 |
+
return iter(self._langs.values())
|
| 79 |
+
|
| 80 |
+
def __len__(self) -> int:
|
| 81 |
+
return len(self._langs)
|
| 82 |
+
|
| 83 |
+
def codes(self, trainable_only: bool = False) -> list[str]:
|
| 84 |
+
return [c for c, lang in self._langs.items() if lang.trainable or not trainable_only]
|
| 85 |
+
|
| 86 |
+
def by_status(self, status: str) -> list[Language]:
|
| 87 |
+
return [lang for lang in self._langs.values() if lang.status == status]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@lru_cache(maxsize=4)
|
| 91 |
+
def get_registry(path: str | None = None) -> LanguageRegistry:
|
| 92 |
+
return LanguageRegistry(path or DEFAULT_REGISTRY)
|
canopy/models/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from canopy.models.canopy_ctc import CanopyCTC
|
| 2 |
+
from canopy.models.config import PRESETS, ModelConfig
|
| 3 |
+
|
| 4 |
+
__all__ = ["PRESETS", "CanopyCTC", "ModelConfig"]
|
canopy/models/canopy_ctc.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn.functional as F
|
| 5 |
+
from torch import nn
|
| 6 |
+
from torch.utils.checkpoint import checkpoint
|
| 7 |
+
|
| 8 |
+
from canopy.frontend.features import LogMelFrontend
|
| 9 |
+
from canopy.models.config import ModelConfig
|
| 10 |
+
from canopy.models.modules import ConvSubsampling4x, MacaronBlock
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def lengths_to_mask(lengths: torch.Tensor, T: int) -> torch.Tensor:
|
| 14 |
+
return torch.arange(T, device=lengths.device).unsqueeze(0) < lengths.unsqueeze(1)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class CanopyCTC(nn.Module):
|
| 18 |
+
"""Conformer-style CTC encoder with intermediate CTC, optional language
|
| 19 |
+
conditioning, self-conditioning and a language-ID head."""
|
| 20 |
+
|
| 21 |
+
def __init__(self, cfg: ModelConfig):
|
| 22 |
+
super().__init__()
|
| 23 |
+
if cfg.vocab_size <= 1:
|
| 24 |
+
raise ValueError("cfg.vocab_size must be set from the tokenizer")
|
| 25 |
+
for k in cfg.aux_ctc_layers:
|
| 26 |
+
if not 1 <= k < cfg.n_layers:
|
| 27 |
+
raise ValueError(f"aux_ctc_layers entries must be in [1, n_layers), got {k}")
|
| 28 |
+
self.cfg = cfg
|
| 29 |
+
d = cfg.d_model
|
| 30 |
+
self.frontend = LogMelFrontend(n_mels=cfg.n_mels, normalize=cfg.feature_normalize)
|
| 31 |
+
self.subsampling = ConvSubsampling4x(cfg.n_mels, d, cfg.dropout)
|
| 32 |
+
self.lang_embed = nn.Embedding(cfg.num_languages + 1, d) if cfg.language_embedding else None
|
| 33 |
+
if self.lang_embed is not None:
|
| 34 |
+
nn.init.zeros_(self.lang_embed.weight)
|
| 35 |
+
n = cfg.n_layers
|
| 36 |
+
self.blocks = nn.ModuleList([
|
| 37 |
+
MacaronBlock(d, cfg.n_heads, cfg.ff_multiplier, cfg.conv_kernel, cfg.conv_expansion, cfg.se_ratio,
|
| 38 |
+
cfg.dropout, cfg.stochastic_depth * i / max(n - 1, 1), cfg.conv_norm)
|
| 39 |
+
for i in range(n)
|
| 40 |
+
])
|
| 41 |
+
self.ctc_head = nn.Linear(d, cfg.vocab_size)
|
| 42 |
+
self.aux_heads = nn.ModuleDict({str(k): nn.Linear(d, cfg.vocab_size) for k in cfg.aux_ctc_layers})
|
| 43 |
+
self.self_cond = (nn.ModuleDict({str(k): nn.Linear(cfg.vocab_size, d, bias=False) for k in cfg.aux_ctc_layers})
|
| 44 |
+
if cfg.self_conditioning else None)
|
| 45 |
+
self.lid_head = nn.Linear(d, cfg.num_languages + 1) if cfg.lid_head else None
|
| 46 |
+
with torch.no_grad():
|
| 47 |
+
for head in [self.ctc_head, *self.aux_heads.values()]:
|
| 48 |
+
head.bias[0] = cfg.blank_bias_init
|
| 49 |
+
|
| 50 |
+
# ------------------------------------------------------------------
|
| 51 |
+
def encode(self, feats: torch.Tensor, feat_lengths: torch.Tensor, lang_ids: torch.Tensor | None = None) -> dict:
|
| 52 |
+
x, lengths = self.subsampling(feats, feat_lengths)
|
| 53 |
+
mask = lengths_to_mask(lengths, x.shape[1])
|
| 54 |
+
if self.lang_embed is not None:
|
| 55 |
+
if lang_ids is None:
|
| 56 |
+
lang_ids = torch.zeros(x.shape[0], dtype=torch.long, device=x.device)
|
| 57 |
+
x = x + self.lang_embed(lang_ids).unsqueeze(1)
|
| 58 |
+
aux_logits: dict[int, torch.Tensor] = {}
|
| 59 |
+
for i, block in enumerate(self.blocks, start=1):
|
| 60 |
+
if self.cfg.gradient_checkpointing and self.training:
|
| 61 |
+
x = checkpoint(block, x, mask, use_reentrant=False)
|
| 62 |
+
else:
|
| 63 |
+
x = block(x, mask)
|
| 64 |
+
key = str(i)
|
| 65 |
+
if key in self.aux_heads:
|
| 66 |
+
logits = self.aux_heads[key](x)
|
| 67 |
+
aux_logits[i] = logits
|
| 68 |
+
if self.self_cond is not None:
|
| 69 |
+
x = x + self.self_cond[key](logits.float().softmax(-1).to(x.dtype))
|
| 70 |
+
out = {"hidden": x, "lengths": lengths, "mask": mask, "logits": self.ctc_head(x), "aux_logits": aux_logits}
|
| 71 |
+
if self.lid_head is not None:
|
| 72 |
+
m = mask.unsqueeze(-1).to(x.dtype)
|
| 73 |
+
pooled = (x * m).sum(1) / m.sum(1).clamp(min=1.0)
|
| 74 |
+
out["lid_logits"] = self.lid_head(pooled)
|
| 75 |
+
return out
|
| 76 |
+
|
| 77 |
+
def forward(self, wav: torch.Tensor, wav_lengths: torch.Tensor, lang_ids: torch.Tensor | None = None,
|
| 78 |
+
spec_augment: nn.Module | None = None) -> dict:
|
| 79 |
+
feats, feat_lengths = self.frontend(wav, wav_lengths)
|
| 80 |
+
if spec_augment is not None and self.training:
|
| 81 |
+
feats = spec_augment(feats, feat_lengths)
|
| 82 |
+
out = self.encode(feats, feat_lengths, lang_ids)
|
| 83 |
+
out["log_probs"] = F.log_softmax(out["logits"].float(), dim=-1)
|
| 84 |
+
out["aux_log_probs"] = {k: F.log_softmax(v.float(), dim=-1) for k, v in out["aux_logits"].items()}
|
| 85 |
+
return out
|
| 86 |
+
|
| 87 |
+
# ------------------------------------------------------------------
|
| 88 |
+
def num_parameters(self, include_aux: bool = True) -> int:
|
| 89 |
+
n = sum(p.numel() for p in self.parameters())
|
| 90 |
+
if not include_aux:
|
| 91 |
+
n -= sum(p.numel() for p in self.aux_heads.parameters())
|
| 92 |
+
return n
|
| 93 |
+
|
| 94 |
+
def parameter_breakdown(self) -> dict[str, int]:
|
| 95 |
+
def count(m):
|
| 96 |
+
return sum(p.numel() for p in m.parameters()) if m is not None else 0
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
"subsampling": count(self.subsampling),
|
| 100 |
+
"blocks": count(self.blocks),
|
| 101 |
+
"ctc_head": count(self.ctc_head),
|
| 102 |
+
"aux_heads": count(self.aux_heads),
|
| 103 |
+
"self_conditioning": count(self.self_cond),
|
| 104 |
+
"language_embedding": count(self.lang_embed),
|
| 105 |
+
"lid_head": count(self.lid_head),
|
| 106 |
+
"total": count(self),
|
| 107 |
+
}
|
canopy/models/config.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import asdict, dataclass, field, fields
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass
|
| 7 |
+
class ModelConfig:
|
| 8 |
+
# Frontend
|
| 9 |
+
n_mels: int = 80
|
| 10 |
+
feature_normalize: str = "utterance" # utterance | per_bin | none
|
| 11 |
+
# Encoder
|
| 12 |
+
d_model: int = 384
|
| 13 |
+
n_layers: int = 12
|
| 14 |
+
n_heads: int = 6
|
| 15 |
+
ff_multiplier: float = 3.0
|
| 16 |
+
conv_kernel: int = 31
|
| 17 |
+
conv_expansion: int = 2
|
| 18 |
+
conv_norm: str = "batch" # batch | layer
|
| 19 |
+
se_ratio: int = 4
|
| 20 |
+
dropout: float = 0.1
|
| 21 |
+
stochastic_depth: float = 0.1
|
| 22 |
+
# Output
|
| 23 |
+
vocab_size: int = 0
|
| 24 |
+
blank_bias_init: float = 0.0
|
| 25 |
+
# Intermediate CTC heads after these blocks (1-indexed: 6 = output of the 6th block).
|
| 26 |
+
aux_ctc_layers: list[int] = field(default_factory=lambda: [6])
|
| 27 |
+
self_conditioning: bool = False
|
| 28 |
+
# Language conditioning. `languages` fixes the id order; id 0 means "no language".
|
| 29 |
+
languages: list[str] = field(default_factory=list)
|
| 30 |
+
language_embedding: bool = False
|
| 31 |
+
lid_head: bool = False
|
| 32 |
+
# Memory
|
| 33 |
+
gradient_checkpointing: bool = False
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
def num_languages(self) -> int:
|
| 37 |
+
return len(self.languages)
|
| 38 |
+
|
| 39 |
+
def to_dict(self) -> dict:
|
| 40 |
+
return asdict(self)
|
| 41 |
+
|
| 42 |
+
@classmethod
|
| 43 |
+
def from_dict(cls, d: dict) -> ModelConfig:
|
| 44 |
+
known = {f.name for f in fields(cls)}
|
| 45 |
+
unknown = set(d) - known
|
| 46 |
+
if unknown:
|
| 47 |
+
raise ValueError(f"unknown model config keys: {sorted(unknown)}")
|
| 48 |
+
return cls(**d)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
PRESETS: dict[str, dict] = {
|
| 52 |
+
"canopy-s": {"d_model": 320, "n_layers": 12, "n_heads": 5},
|
| 53 |
+
"canopy-m": {"d_model": 384, "n_layers": 12, "n_heads": 6},
|
| 54 |
+
"canopy-m16": {"d_model": 384, "n_layers": 16, "n_heads": 6},
|
| 55 |
+
"canopy-l": {"d_model": 512, "n_layers": 16, "n_heads": 8, "aux_ctc_layers": [8]},
|
| 56 |
+
# For tests and smoke runs only.
|
| 57 |
+
"canopy-tiny": {"d_model": 64, "n_layers": 2, "n_heads": 2, "aux_ctc_layers": [1], "conv_kernel": 7},
|
| 58 |
+
}
|
canopy/models/modules.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Encoder building blocks: RMSNorm, rotary MHSA, SwiGLU, SE convolution, Macaron block."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
from torch import nn
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class RMSNorm(nn.Module):
|
| 11 |
+
def __init__(self, dim: int, eps: float = 1e-6):
|
| 12 |
+
super().__init__()
|
| 13 |
+
self.eps = eps
|
| 14 |
+
self.weight = nn.Parameter(torch.ones(dim))
|
| 15 |
+
|
| 16 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 17 |
+
xf = x.float()
|
| 18 |
+
xf = xf * torch.rsqrt(xf.pow(2).mean(-1, keepdim=True) + self.eps)
|
| 19 |
+
return xf.type_as(x) * self.weight
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class Rotary(nn.Module):
|
| 23 |
+
def __init__(self, head_dim: int, base: float = 10000.0):
|
| 24 |
+
super().__init__()
|
| 25 |
+
inv = 1.0 / (base ** (torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim))
|
| 26 |
+
self.register_buffer("inv_freq", inv, persistent=False)
|
| 27 |
+
self._len = 0
|
| 28 |
+
self._cos: torch.Tensor | None = None
|
| 29 |
+
self._sin: torch.Tensor | None = None
|
| 30 |
+
|
| 31 |
+
def forward(self, T: int, device, dtype) -> tuple[torch.Tensor, torch.Tensor]:
|
| 32 |
+
if self._cos is None or T > self._len or self._cos.device != device:
|
| 33 |
+
n = max(T, 2 * self._len, 1024)
|
| 34 |
+
t = torch.arange(n, device=device, dtype=torch.float32)
|
| 35 |
+
freqs = torch.outer(t, self.inv_freq.to(device))
|
| 36 |
+
emb = torch.cat([freqs, freqs], dim=-1)
|
| 37 |
+
self._cos, self._sin, self._len = emb.cos(), emb.sin(), n
|
| 38 |
+
return self._cos[:T].to(dtype), self._sin[:T].to(dtype)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _rotate_half(x: torch.Tensor) -> torch.Tensor:
|
| 42 |
+
a, b = x.chunk(2, dim=-1)
|
| 43 |
+
return torch.cat([-b, a], dim=-1)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class RotaryMHSA(nn.Module):
|
| 47 |
+
def __init__(self, d_model: int, n_heads: int, dropout: float):
|
| 48 |
+
super().__init__()
|
| 49 |
+
if d_model % n_heads:
|
| 50 |
+
raise ValueError("d_model must be divisible by n_heads")
|
| 51 |
+
self.h = n_heads
|
| 52 |
+
self.dh = d_model // n_heads
|
| 53 |
+
self.q = nn.Linear(d_model, d_model, bias=False)
|
| 54 |
+
self.k = nn.Linear(d_model, d_model, bias=False)
|
| 55 |
+
self.v = nn.Linear(d_model, d_model, bias=False)
|
| 56 |
+
self.o = nn.Linear(d_model, d_model, bias=False)
|
| 57 |
+
self.rotary = Rotary(self.dh)
|
| 58 |
+
self.p = dropout
|
| 59 |
+
|
| 60 |
+
def forward(self, x: torch.Tensor, key_mask: torch.Tensor | None) -> torch.Tensor:
|
| 61 |
+
B, T, D = x.shape
|
| 62 |
+
q = self.q(x).view(B, T, self.h, self.dh).transpose(1, 2)
|
| 63 |
+
k = self.k(x).view(B, T, self.h, self.dh).transpose(1, 2)
|
| 64 |
+
v = self.v(x).view(B, T, self.h, self.dh).transpose(1, 2)
|
| 65 |
+
cos, sin = self.rotary(T, x.device, q.dtype)
|
| 66 |
+
q = q * cos + _rotate_half(q) * sin
|
| 67 |
+
k = k * cos + _rotate_half(k) * sin
|
| 68 |
+
attn_mask = key_mask[:, None, None, :] if key_mask is not None else None
|
| 69 |
+
out = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask, dropout_p=self.p if self.training else 0.0)
|
| 70 |
+
return self.o(out.transpose(1, 2).reshape(B, T, D))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class SwiGLUFFN(nn.Module):
|
| 74 |
+
def __init__(self, d_model: int, multiplier: float, dropout: float):
|
| 75 |
+
super().__init__()
|
| 76 |
+
inner = int(d_model * multiplier)
|
| 77 |
+
self.norm = RMSNorm(d_model)
|
| 78 |
+
self.gate = nn.Linear(d_model, inner, bias=False)
|
| 79 |
+
self.up = nn.Linear(d_model, inner, bias=False)
|
| 80 |
+
self.down = nn.Linear(inner, d_model, bias=False)
|
| 81 |
+
self.drop = nn.Dropout(dropout)
|
| 82 |
+
|
| 83 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 84 |
+
h = self.norm(x)
|
| 85 |
+
h = self.down(self.drop(F.silu(self.gate(h)) * self.up(h)))
|
| 86 |
+
return self.drop(h)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class SqueezeExcite(nn.Module):
|
| 90 |
+
def __init__(self, channels: int, ratio: int):
|
| 91 |
+
super().__init__()
|
| 92 |
+
mid = max(1, channels // ratio)
|
| 93 |
+
self.fc1 = nn.Linear(channels, mid)
|
| 94 |
+
self.fc2 = nn.Linear(mid, channels)
|
| 95 |
+
|
| 96 |
+
def forward(self, x: torch.Tensor, mask: torch.Tensor | None) -> torch.Tensor:
|
| 97 |
+
# x: (B, T, C). Mean over valid frames only.
|
| 98 |
+
if mask is None:
|
| 99 |
+
s = x.mean(dim=1, keepdim=True)
|
| 100 |
+
else:
|
| 101 |
+
m = mask.unsqueeze(-1).to(x.dtype)
|
| 102 |
+
s = (x * m).sum(dim=1, keepdim=True) / m.sum(dim=1, keepdim=True).clamp(min=1.0)
|
| 103 |
+
return x * torch.sigmoid(self.fc2(F.silu(self.fc1(s))))
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class ConvModule(nn.Module):
|
| 107 |
+
def __init__(self, d_model: int, kernel: int, expansion: int, se_ratio: int, dropout: float, norm: str = "batch"):
|
| 108 |
+
super().__init__()
|
| 109 |
+
inner = d_model * expansion
|
| 110 |
+
self.norm = RMSNorm(d_model)
|
| 111 |
+
self.pw1 = nn.Linear(d_model, 2 * inner)
|
| 112 |
+
self.dw = nn.Conv1d(inner, inner, kernel, padding=kernel // 2, groups=inner)
|
| 113 |
+
if norm == "batch":
|
| 114 |
+
self.conv_norm: nn.Module = nn.BatchNorm1d(inner)
|
| 115 |
+
elif norm == "layer":
|
| 116 |
+
self.conv_norm = nn.GroupNorm(1, inner)
|
| 117 |
+
else:
|
| 118 |
+
raise ValueError(f"conv_norm must be batch|layer, got {norm!r}")
|
| 119 |
+
self.se = SqueezeExcite(inner, se_ratio)
|
| 120 |
+
self.pw2 = nn.Linear(inner, d_model)
|
| 121 |
+
self.drop = nn.Dropout(dropout)
|
| 122 |
+
|
| 123 |
+
def forward(self, x: torch.Tensor, mask: torch.Tensor | None) -> torch.Tensor:
|
| 124 |
+
h = F.glu(self.pw1(self.norm(x)), dim=-1)
|
| 125 |
+
if mask is not None:
|
| 126 |
+
h = h.masked_fill(~mask.unsqueeze(-1), 0.0)
|
| 127 |
+
h = self.dw(h.transpose(1, 2))
|
| 128 |
+
h = F.silu(self.conv_norm(h)).transpose(1, 2)
|
| 129 |
+
h = self.se(h, mask)
|
| 130 |
+
return self.drop(self.pw2(h))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class MacaronBlock(nn.Module):
|
| 134 |
+
"""½FFN → MHSA → Conv → ½FFN → RMSNorm, each with a residual connection."""
|
| 135 |
+
|
| 136 |
+
def __init__(self, d_model: int, n_heads: int, ff_multiplier: float, conv_kernel: int, conv_expansion: int,
|
| 137 |
+
se_ratio: int, dropout: float, drop_rate: float, conv_norm: str = "batch"):
|
| 138 |
+
super().__init__()
|
| 139 |
+
self.ff1 = SwiGLUFFN(d_model, ff_multiplier, dropout)
|
| 140 |
+
self.attn_norm = RMSNorm(d_model)
|
| 141 |
+
self.attn = RotaryMHSA(d_model, n_heads, dropout)
|
| 142 |
+
self.attn_drop = nn.Dropout(dropout)
|
| 143 |
+
self.conv = ConvModule(d_model, conv_kernel, conv_expansion, se_ratio, dropout, conv_norm)
|
| 144 |
+
self.ff2 = SwiGLUFFN(d_model, ff_multiplier, dropout)
|
| 145 |
+
self.final_norm = RMSNorm(d_model)
|
| 146 |
+
self.drop_rate = drop_rate
|
| 147 |
+
|
| 148 |
+
def forward(self, x: torch.Tensor, mask: torch.Tensor | None) -> torch.Tensor:
|
| 149 |
+
if self.training and self.drop_rate > 0 and torch.rand(()) < self.drop_rate:
|
| 150 |
+
return x
|
| 151 |
+
x = x + 0.5 * self.ff1(x)
|
| 152 |
+
x = x + self.attn_drop(self.attn(self.attn_norm(x), mask))
|
| 153 |
+
x = x + self.conv(x, mask)
|
| 154 |
+
x = x + 0.5 * self.ff2(x)
|
| 155 |
+
return self.final_norm(x)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class ConvSubsampling4x(nn.Module):
|
| 159 |
+
"""Two 3×3 stride-2 Conv2d layers (4× in time and frequency) → Linear → RMSNorm."""
|
| 160 |
+
|
| 161 |
+
def __init__(self, n_mels: int, d_model: int, dropout: float):
|
| 162 |
+
super().__init__()
|
| 163 |
+
self.conv1 = nn.Conv2d(1, d_model, 3, stride=2, padding=1)
|
| 164 |
+
self.conv2 = nn.Conv2d(d_model, d_model, 3, stride=2, padding=1)
|
| 165 |
+
f = (n_mels + 1) // 2
|
| 166 |
+
f = (f + 1) // 2
|
| 167 |
+
self.linear = nn.Linear(d_model * f, d_model)
|
| 168 |
+
self.norm = RMSNorm(d_model)
|
| 169 |
+
self.drop = nn.Dropout(dropout)
|
| 170 |
+
|
| 171 |
+
@staticmethod
|
| 172 |
+
def output_lengths(lengths: torch.Tensor) -> torch.Tensor:
|
| 173 |
+
lengths = torch.div(lengths - 1, 2, rounding_mode="floor") + 1
|
| 174 |
+
lengths = torch.div(lengths - 1, 2, rounding_mode="floor") + 1
|
| 175 |
+
return lengths.clamp(min=1)
|
| 176 |
+
|
| 177 |
+
def forward(self, x: torch.Tensor, lengths: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
| 178 |
+
x = F.silu(self.conv1(x.unsqueeze(1)))
|
| 179 |
+
x = F.silu(self.conv2(x))
|
| 180 |
+
B, C, T, Fq = x.shape
|
| 181 |
+
x = x.permute(0, 2, 1, 3).reshape(B, T, C * Fq)
|
| 182 |
+
x = self.drop(self.norm(self.linear(x)))
|
| 183 |
+
return x, self.output_lengths(lengths).clamp(max=T)
|
canopy/text/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from canopy.text.normalize import Normalizer, get_normalizer
|
| 2 |
+
|
| 3 |
+
__all__ = ["Normalizer", "get_normalizer"]
|
canopy/text/normalize.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Text normalization with three separate views per utterance.
|
| 2 |
+
|
| 3 |
+
``raw`` the transcript exactly as distributed (never modified)
|
| 4 |
+
``train_norm`` the tokenizer/model target
|
| 5 |
+
``eval_norm`` the scoring view used for WER/CER
|
| 6 |
+
|
| 7 |
+
Both derived views are deterministic functions of ``raw`` and the language code.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import re
|
| 13 |
+
import unicodedata
|
| 14 |
+
from collections import Counter
|
| 15 |
+
from collections.abc import Iterable
|
| 16 |
+
from functools import lru_cache
|
| 17 |
+
|
| 18 |
+
from canopy.text.rules import HARAKAT, LanguageRules, rules_for
|
| 19 |
+
|
| 20 |
+
ZWNJ = "\u200c"
|
| 21 |
+
TATWEEL = "\u0640"
|
| 22 |
+
# Invisible format characters that never carry orthographic meaning here.
|
| 23 |
+
_INVISIBLE = frozenset(
|
| 24 |
+
["\u200b", "\u200d", "\u200e", "\u200f", "\u2060", "\ufeff", "\u00ad"]
|
| 25 |
+
+ [chr(c) for c in range(0x202A, 0x202F)]
|
| 26 |
+
+ [chr(c) for c in range(0x2066, 0x206A)]
|
| 27 |
+
)
|
| 28 |
+
_WS = re.compile(r"\s+")
|
| 29 |
+
|
| 30 |
+
_EASTERN_DIGITS = {chr(0x0660 + i): str(i) for i in range(10)}
|
| 31 |
+
_EASTERN_DIGITS.update({chr(0x06F0 + i): str(i) for i in range(10)})
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _is_presentation_form(ch: str) -> bool:
|
| 35 |
+
o = ord(ch)
|
| 36 |
+
return 0xFB50 <= o <= 0xFDFF or 0xFE70 <= o <= 0xFEFE
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class Normalizer:
|
| 40 |
+
def __init__(self, rules: LanguageRules):
|
| 41 |
+
self.rules = rules
|
| 42 |
+
self.code = rules.code
|
| 43 |
+
|
| 44 |
+
def _base(self, text: str, keep_zwnj: bool) -> str:
|
| 45 |
+
text = unicodedata.normalize("NFC", text)
|
| 46 |
+
out = []
|
| 47 |
+
for ch in text:
|
| 48 |
+
if _is_presentation_form(ch):
|
| 49 |
+
out.append(unicodedata.normalize("NFKC", ch))
|
| 50 |
+
elif ch in _INVISIBLE or ch == TATWEEL:
|
| 51 |
+
continue
|
| 52 |
+
elif ch == ZWNJ:
|
| 53 |
+
if keep_zwnj:
|
| 54 |
+
out.append(ch)
|
| 55 |
+
else:
|
| 56 |
+
out.append(ch)
|
| 57 |
+
text = "".join(out)
|
| 58 |
+
if self.rules.char_map:
|
| 59 |
+
text = "".join(self.rules.char_map.get(ch, ch) for ch in text)
|
| 60 |
+
buf = []
|
| 61 |
+
for ch in text:
|
| 62 |
+
cat = unicodedata.category(ch)
|
| 63 |
+
if ch in self.rules.keep_chars or ch == ZWNJ:
|
| 64 |
+
buf.append(ch)
|
| 65 |
+
elif cat[0] in ("P", "S") or cat in ("Cc", "Cf"):
|
| 66 |
+
buf.append(" ")
|
| 67 |
+
else:
|
| 68 |
+
buf.append(ch)
|
| 69 |
+
text = "".join(buf)
|
| 70 |
+
if self.rules.lowercase:
|
| 71 |
+
text = text.lower()
|
| 72 |
+
text = unicodedata.normalize("NFC", text)
|
| 73 |
+
return _WS.sub(" ", text).strip()
|
| 74 |
+
|
| 75 |
+
def train_norm(self, raw: str) -> str:
|
| 76 |
+
return self._base(raw, keep_zwnj=self.rules.keep_zwnj_train)
|
| 77 |
+
|
| 78 |
+
def eval_norm(self, raw: str) -> str:
|
| 79 |
+
text = self._base(raw, keep_zwnj=False)
|
| 80 |
+
text = "".join(_EASTERN_DIGITS.get(ch, ch) for ch in text)
|
| 81 |
+
if self.rules.eval_char_map:
|
| 82 |
+
text = "".join(self.rules.eval_char_map.get(ch, ch) for ch in text)
|
| 83 |
+
if self.rules.strip_harakat_eval:
|
| 84 |
+
text = "".join(ch for ch in text if ch not in HARAKAT)
|
| 85 |
+
return _WS.sub(" ", text).strip()
|
| 86 |
+
|
| 87 |
+
def eval_norm_hypothesis(self, hyp: str) -> str:
|
| 88 |
+
"""Hypotheses are already in train_norm space; apply the same scoring view."""
|
| 89 |
+
return self.eval_norm(hyp)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@lru_cache(maxsize=128)
|
| 93 |
+
def get_normalizer(code: str, script: str | None = None) -> Normalizer:
|
| 94 |
+
return Normalizer(rules_for(code, script))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def char_inventory(texts: Iterable[str]) -> Counter:
|
| 98 |
+
c: Counter = Counter()
|
| 99 |
+
for t in texts:
|
| 100 |
+
c.update(t)
|
| 101 |
+
return c
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def describe_char(ch: str) -> dict:
|
| 105 |
+
return {
|
| 106 |
+
"char": ch,
|
| 107 |
+
"codepoint": f"U+{ord(ch):04X}",
|
| 108 |
+
"name": unicodedata.name(ch, "<unnamed>"),
|
| 109 |
+
"category": unicodedata.category(ch),
|
| 110 |
+
}
|
canopy/text/rules.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-language normalization rules.
|
| 2 |
+
|
| 3 |
+
Policy (docs/LANGUAGE_MATRIX.md, section 4):
|
| 4 |
+
|
| 5 |
+
* ``raw_reference`` is never modified.
|
| 6 |
+
* A letter-level mapping applies ONLY to languages listed here with that
|
| 7 |
+
mapping. Nothing is inherited from Urdu (or any other language) by default.
|
| 8 |
+
* Every language not listed explicitly gets the script-generic rules only,
|
| 9 |
+
which are limited to changes that carry no orthographic meaning
|
| 10 |
+
(Unicode NFC, presentation forms, invisible format characters, tatweel,
|
| 11 |
+
punctuation, whitespace).
|
| 12 |
+
* ``reviewed`` stays False until a native-speaker reviewer signs off the
|
| 13 |
+
rule set; the evaluation report prints this flag next to each language.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
|
| 20 |
+
ARABIC_KAF = "\u0643" # ك
|
| 21 |
+
KEHEH = "\u06a9" # ک
|
| 22 |
+
ARABIC_YEH = "\u064a" # ي
|
| 23 |
+
ALEF_MAKSURA = "\u0649" # ى
|
| 24 |
+
FARSI_YEH = "\u06cc" # ی
|
| 25 |
+
ARABIC_HEH = "\u0647" # ه
|
| 26 |
+
HEH_GOAL = "\u06c1" # ہ
|
| 27 |
+
|
| 28 |
+
# Optional vowel signs (harakat), superscript alef and small high marks.
|
| 29 |
+
HARAKAT = frozenset(chr(c) for c in list(range(0x064B, 0x0653)) + [0x0670])
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass(frozen=True)
|
| 33 |
+
class LanguageRules:
|
| 34 |
+
code: str
|
| 35 |
+
script: str = "Arab"
|
| 36 |
+
# Applied in both train_norm and eval_norm.
|
| 37 |
+
char_map: dict[str, str] = field(default_factory=dict)
|
| 38 |
+
# Applied additionally in eval_norm only.
|
| 39 |
+
eval_char_map: dict[str, str] = field(default_factory=dict)
|
| 40 |
+
lowercase: bool = False
|
| 41 |
+
keep_zwnj_train: bool = False
|
| 42 |
+
strip_harakat_eval: bool = False
|
| 43 |
+
# Characters that must survive punctuation removal (e.g. apostrophes used as letters).
|
| 44 |
+
keep_chars: frozenset[str] = frozenset()
|
| 45 |
+
reviewed: bool = False
|
| 46 |
+
notes: str = ""
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
_LATIN_APOSTROPHES = frozenset({"'", "\u2019", "\u02bc"})
|
| 50 |
+
|
| 51 |
+
RULES: dict[str, LanguageRules] = {
|
| 52 |
+
"urd": LanguageRules(
|
| 53 |
+
code="urd",
|
| 54 |
+
char_map={ARABIC_KAF: KEHEH, ARABIC_YEH: FARSI_YEH, ALEF_MAKSURA: FARSI_YEH, ARABIC_HEH: HEH_GOAL},
|
| 55 |
+
keep_zwnj_train=True,
|
| 56 |
+
strip_harakat_eval=True,
|
| 57 |
+
notes="Yeh barree U+06D2 and do-chashmi heh U+06BE are kept distinct. Harakat stripped in eval_norm only.",
|
| 58 |
+
),
|
| 59 |
+
"pus": LanguageRules(
|
| 60 |
+
code="pus",
|
| 61 |
+
char_map={ARABIC_KAF: KEHEH},
|
| 62 |
+
notes="Pashto yeh forms (ی ي ې ۍ ئ) are distinct letters and heh U+0647 is standard; neither is mapped.",
|
| 63 |
+
),
|
| 64 |
+
"snd": LanguageRules(
|
| 65 |
+
code="snd",
|
| 66 |
+
notes="Sindhi uses swash kaf U+06AA for /k/ and Arabic yeh/heh; the Urdu mappings must not be applied.",
|
| 67 |
+
),
|
| 68 |
+
"kas": LanguageRules(
|
| 69 |
+
code="kas",
|
| 70 |
+
notes="Kashmiri vowel marks are orthographically required; harakat are never stripped.",
|
| 71 |
+
),
|
| 72 |
+
"eng": LanguageRules(code="eng", script="Latn", lowercase=True, keep_chars=_LATIN_APOSTROPHES),
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
for _code in ("bsk", "kls", "wbl"):
|
| 76 |
+
RULES[_code] = LanguageRules(
|
| 77 |
+
code=_code,
|
| 78 |
+
script="Latn",
|
| 79 |
+
lowercase=True,
|
| 80 |
+
keep_chars=_LATIN_APOSTROPHES,
|
| 81 |
+
notes="Latin-script community orthography. Lowercasing and apostrophe handling await reviewer confirmation.",
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def rules_for(code: str, script: str | None = None) -> LanguageRules:
|
| 86 |
+
if code in RULES:
|
| 87 |
+
return RULES[code]
|
| 88 |
+
return LanguageRules(code=code, script=script or "Arab", notes="script-generic rules only")
|
canopy/tokenizer/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from canopy.tokenizer.base import BLANK, UNK, CTCTokenizer, load_tokenizer
|
| 2 |
+
from canopy.tokenizer.char import CharTokenizer
|
| 3 |
+
from canopy.tokenizer.spm import SentencePieceTokenizer, balanced_sample
|
| 4 |
+
|
| 5 |
+
__all__ = ["BLANK", "UNK", "CTCTokenizer", "CharTokenizer", "SentencePieceTokenizer", "balanced_sample", "load_tokenizer"]
|
canopy/tokenizer/base.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Common CTC tokenizer interface. Id 0 is always the CTC blank, id 1 is <unk>."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from canopy.utils.io import sha256_file
|
| 10 |
+
|
| 11 |
+
BLANK = "<blank>"
|
| 12 |
+
UNK = "<unk>"
|
| 13 |
+
BLANK_ID = 0
|
| 14 |
+
UNK_ID = 1
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class CTCTokenizer:
|
| 18 |
+
type: str = "base"
|
| 19 |
+
|
| 20 |
+
def __init__(self, tokens: list[str], language_token_ids: dict[str, list[int]] | None = None, meta: dict | None = None):
|
| 21 |
+
if tokens[BLANK_ID] != BLANK or tokens[UNK_ID] != UNK:
|
| 22 |
+
raise ValueError("token 0 must be <blank> and token 1 must be <unk>")
|
| 23 |
+
self.tokens = tokens
|
| 24 |
+
self.token_to_id = {t: i for i, t in enumerate(tokens)}
|
| 25 |
+
self.language_token_ids = {k: sorted(set(v)) for k, v in (language_token_ids or {}).items()}
|
| 26 |
+
self.meta = meta or {}
|
| 27 |
+
self.version: str | None = None
|
| 28 |
+
|
| 29 |
+
@property
|
| 30 |
+
def vocab_size(self) -> int:
|
| 31 |
+
return len(self.tokens)
|
| 32 |
+
|
| 33 |
+
@property
|
| 34 |
+
def blank_id(self) -> int:
|
| 35 |
+
return BLANK_ID
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def unk_id(self) -> int:
|
| 39 |
+
return UNK_ID
|
| 40 |
+
|
| 41 |
+
def encode(self, text: str, language: str | None = None) -> list[int]:
|
| 42 |
+
raise NotImplementedError
|
| 43 |
+
|
| 44 |
+
def decode(self, ids: list[int]) -> str:
|
| 45 |
+
raise NotImplementedError
|
| 46 |
+
|
| 47 |
+
def always_allowed_ids(self) -> list[int]:
|
| 48 |
+
return [BLANK_ID]
|
| 49 |
+
|
| 50 |
+
def language_mask(self, language: str) -> list[bool] | None:
|
| 51 |
+
"""Allowed output ids for ``language`` (O2 output masking), or None if unknown."""
|
| 52 |
+
ids = self.language_token_ids.get(language)
|
| 53 |
+
if ids is None:
|
| 54 |
+
return None
|
| 55 |
+
mask = [False] * self.vocab_size
|
| 56 |
+
for i in ids:
|
| 57 |
+
mask[i] = True
|
| 58 |
+
for i in self.always_allowed_ids():
|
| 59 |
+
mask[i] = True
|
| 60 |
+
return mask
|
| 61 |
+
|
| 62 |
+
def labels_for_pyctcdecode(self) -> list[str]:
|
| 63 |
+
raise NotImplementedError
|
| 64 |
+
|
| 65 |
+
# ------------------------------------------------------------------ io
|
| 66 |
+
def _payload(self) -> dict:
|
| 67 |
+
return {"type": self.type, "tokens": self.tokens, "language_token_ids": self.language_token_ids, "meta": self.meta}
|
| 68 |
+
|
| 69 |
+
def save(self, directory: str | os.PathLike) -> Path:
|
| 70 |
+
d = Path(directory)
|
| 71 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 72 |
+
path = d / "tokenizer.json"
|
| 73 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 74 |
+
json.dump(self._payload(), f, ensure_ascii=False, indent=1)
|
| 75 |
+
self.version = sha256_file(path)[:16]
|
| 76 |
+
return path
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def load_tokenizer(directory: str | os.PathLike) -> CTCTokenizer:
|
| 80 |
+
d = Path(directory)
|
| 81 |
+
path = d / "tokenizer.json" if d.is_dir() else d
|
| 82 |
+
with open(path, encoding="utf-8") as f:
|
| 83 |
+
payload = json.load(f)
|
| 84 |
+
t = payload["type"]
|
| 85 |
+
if t == "char":
|
| 86 |
+
from canopy.tokenizer.char import CharTokenizer
|
| 87 |
+
|
| 88 |
+
tok = CharTokenizer.from_payload(payload)
|
| 89 |
+
elif t == "sentencepiece":
|
| 90 |
+
from canopy.tokenizer.spm import SentencePieceTokenizer
|
| 91 |
+
|
| 92 |
+
tok = SentencePieceTokenizer.from_payload(payload, path.parent)
|
| 93 |
+
else:
|
| 94 |
+
raise ValueError(f"unknown tokenizer type {t!r}")
|
| 95 |
+
tok.version = sha256_file(path)[:16]
|
| 96 |
+
return tok
|
canopy/tokenizer/char.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Character tokenizers.
|
| 2 |
+
|
| 3 |
+
``unit="codepoint"``: one token per Unicode code point (NFC).
|
| 4 |
+
``unit="grapheme"``: a base character plus its following combining marks form
|
| 5 |
+
one token, so vowel signs and other diacritics stay attached to their letter.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import unicodedata
|
| 11 |
+
from collections import Counter
|
| 12 |
+
from collections.abc import Iterable
|
| 13 |
+
|
| 14 |
+
from canopy.tokenizer.base import BLANK, UNK, UNK_ID, CTCTokenizer
|
| 15 |
+
|
| 16 |
+
SPACE = " "
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def split_units(text: str, unit: str) -> list[str]:
|
| 20 |
+
if unit == "codepoint":
|
| 21 |
+
return list(text)
|
| 22 |
+
if unit != "grapheme":
|
| 23 |
+
raise ValueError(f"unit must be codepoint|grapheme, got {unit!r}")
|
| 24 |
+
out: list[str] = []
|
| 25 |
+
for ch in text:
|
| 26 |
+
if out and out[-1] != SPACE and unicodedata.category(ch) in ("Mn", "Me", "Mc"):
|
| 27 |
+
out[-1] += ch
|
| 28 |
+
else:
|
| 29 |
+
out.append(ch)
|
| 30 |
+
return out
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class CharTokenizer(CTCTokenizer):
|
| 34 |
+
type = "char"
|
| 35 |
+
|
| 36 |
+
def __init__(self, tokens, language_token_ids=None, meta=None):
|
| 37 |
+
super().__init__(tokens, language_token_ids, meta)
|
| 38 |
+
self.unit = self.meta.get("unit", "codepoint")
|
| 39 |
+
|
| 40 |
+
@classmethod
|
| 41 |
+
def train(cls, texts_by_language: dict[str, Iterable[str]], unit: str = "codepoint", min_count: int = 1) -> CharTokenizer:
|
| 42 |
+
counts: Counter = Counter()
|
| 43 |
+
per_lang: dict[str, Counter] = {}
|
| 44 |
+
for lang, texts in texts_by_language.items():
|
| 45 |
+
c: Counter = Counter()
|
| 46 |
+
for t in texts:
|
| 47 |
+
c.update(split_units(t, unit))
|
| 48 |
+
per_lang[lang] = c
|
| 49 |
+
counts.update(c)
|
| 50 |
+
units = sorted(u for u, n in counts.items() if n >= min_count and u != SPACE)
|
| 51 |
+
tokens = [BLANK, UNK, SPACE, *units]
|
| 52 |
+
idx = {t: i for i, t in enumerate(tokens)}
|
| 53 |
+
lang_ids = {lang: [idx[u] for u in c if u in idx] for lang, c in per_lang.items()}
|
| 54 |
+
meta = {"unit": unit, "min_count": min_count, "unit_counts": {u: counts[u] for u in units}}
|
| 55 |
+
return cls(tokens, lang_ids, meta)
|
| 56 |
+
|
| 57 |
+
@classmethod
|
| 58 |
+
def from_payload(cls, payload: dict) -> CharTokenizer:
|
| 59 |
+
return cls(payload["tokens"], payload.get("language_token_ids"), payload.get("meta"))
|
| 60 |
+
|
| 61 |
+
def always_allowed_ids(self) -> list[int]:
|
| 62 |
+
return [0, self.token_to_id[SPACE]]
|
| 63 |
+
|
| 64 |
+
def encode(self, text: str, language: str | None = None) -> list[int]:
|
| 65 |
+
return [self.token_to_id.get(u, UNK_ID) for u in split_units(text, self.unit)]
|
| 66 |
+
|
| 67 |
+
def decode(self, ids: list[int]) -> str:
|
| 68 |
+
out = []
|
| 69 |
+
for i in ids:
|
| 70 |
+
if i <= 0:
|
| 71 |
+
continue
|
| 72 |
+
out.append("\u2047" if i == UNK_ID else self.tokens[i])
|
| 73 |
+
return " ".join("".join(out).split())
|
| 74 |
+
|
| 75 |
+
def labels_for_pyctcdecode(self) -> list[str]:
|
| 76 |
+
return ["", "\u2047", *self.tokens[2:]]
|
canopy/tokenizer/diagnostics.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Static tokenizer diagnostics (no acoustic training): RESEARCH_PLAN.md section 7.2."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections import Counter
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
from canopy.data.dataset import ctc_required_frames
|
| 10 |
+
from canopy.data.validation import char_script
|
| 11 |
+
from canopy.tokenizer.base import UNK_ID
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _pct(a, q):
|
| 15 |
+
return float(np.percentile(a, q)) if len(a) else 0.0
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def tokenization_report(tokenizer, rows: list[dict], *, fps: float = 25.0, d_model: int = 384,
|
| 19 |
+
tokenizer_id: str = "unnamed") -> dict:
|
| 20 |
+
"""Per-language diagnostics. ``rows`` are training-split manifest rows."""
|
| 21 |
+
by_lang: dict[str, list[dict]] = {}
|
| 22 |
+
for r in rows:
|
| 23 |
+
by_lang.setdefault(r["language"], []).append(r)
|
| 24 |
+
|
| 25 |
+
encoded = {lang: [tokenizer.encode(r["text_train"], language=lang) for r in lrows] for lang, lrows in by_lang.items()}
|
| 26 |
+
global_counts: Counter = Counter()
|
| 27 |
+
lang_types: dict[str, set] = {}
|
| 28 |
+
for lang, encs in encoded.items():
|
| 29 |
+
s = set()
|
| 30 |
+
for e in encs:
|
| 31 |
+
global_counts.update(e)
|
| 32 |
+
s.update(e)
|
| 33 |
+
lang_types[lang] = s
|
| 34 |
+
|
| 35 |
+
reports = {}
|
| 36 |
+
for lang, lrows in by_lang.items():
|
| 37 |
+
encs = encoded[lang]
|
| 38 |
+
n_tok = np.array([len(e) for e in encs], dtype=float)
|
| 39 |
+
n_chr = np.array([len(r["text_train"].replace(" ", "")) for r in lrows], dtype=float)
|
| 40 |
+
dur = np.array([r["duration"] for r in lrows], dtype=float)
|
| 41 |
+
frames = np.floor(dur * fps)
|
| 42 |
+
req = np.array([ctc_required_frames(e) for e in encs], dtype=float)
|
| 43 |
+
ratio = req / np.maximum(frames, 1)
|
| 44 |
+
repeats = req - n_tok
|
| 45 |
+
types = lang_types[lang]
|
| 46 |
+
others = set().union(*(v for k, v in lang_types.items() if k != lang)) if len(lang_types) > 1 else set()
|
| 47 |
+
n_types = max(1, len(types))
|
| 48 |
+
total_tokens = max(1.0, n_tok.sum())
|
| 49 |
+
chars = Counter(c for r in lrows for c in r["text_train"] if not c.isspace())
|
| 50 |
+
total_chars = max(1, sum(chars.values()))
|
| 51 |
+
script_cov: Counter = Counter()
|
| 52 |
+
for c, k in chars.items():
|
| 53 |
+
script_cov[char_script(c)] += k
|
| 54 |
+
cpt = n_chr / np.maximum(n_tok, 1)
|
| 55 |
+
tps = n_tok / np.maximum(dur, 1e-6)
|
| 56 |
+
reports[lang] = {
|
| 57 |
+
"tokenizer_id": tokenizer_id,
|
| 58 |
+
"language": lang,
|
| 59 |
+
"n_utterances": len(lrows),
|
| 60 |
+
"frames_per_second": fps,
|
| 61 |
+
"mean_chars_per_token": float(cpt.mean()) if len(cpt) else 0.0,
|
| 62 |
+
"p50_chars_per_token": _pct(cpt, 50),
|
| 63 |
+
"p95_chars_per_token": _pct(cpt, 95),
|
| 64 |
+
"tokens_per_second": {"mean": float(tps.mean()) if len(tps) else 0.0, "p95": _pct(tps, 95), "p99": _pct(tps, 99)},
|
| 65 |
+
"target_token_frame_ratio": {"mean": float(ratio.mean()) if len(ratio) else 0.0, "p95": _pct(ratio, 95),
|
| 66 |
+
"p99": _pct(ratio, 99), "max": float(ratio.max()) if len(ratio) else 0.0},
|
| 67 |
+
"required_blanks_for_repeats_per_utt": float(repeats.mean()) if len(repeats) else 0.0,
|
| 68 |
+
"frame_budget_violation_rate": float((ratio > 1.0).mean()) if len(ratio) else 0.0,
|
| 69 |
+
"estimated_blank_rate": float(1.0 - (n_tok.sum() / max(1.0, frames.sum()))),
|
| 70 |
+
"rare_token_percentage": {
|
| 71 |
+
f"lt{k}": 100.0 * sum(1 for t in types if global_counts[t] < k) / n_types for k in (10, 100, 1000)
|
| 72 |
+
},
|
| 73 |
+
"tokens_used_by_language": len(types),
|
| 74 |
+
"tokens_exclusive_to_language": len(types - others),
|
| 75 |
+
"oov_or_unk_rate": float(sum(e.count(UNK_ID) for e in encs) / total_tokens),
|
| 76 |
+
"byte_fallback_rate": 0.0,
|
| 77 |
+
"script_coverage": {k: v / total_chars for k, v in sorted(script_cov.items())},
|
| 78 |
+
"digit_handling": "split" if tokenizer.type == "sentencepiece" else "per-character",
|
| 79 |
+
"punctuation_handling": "removed_in_train_norm",
|
| 80 |
+
"ctc_head_params": (d_model + 1) * tokenizer.vocab_size,
|
| 81 |
+
}
|
| 82 |
+
return reports
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def prescreen(reports: dict, max_violation_rate: float = 0.005, max_rare_share: float = 50.0) -> dict:
|
| 86 |
+
"""Apply the section 7.2 pre-screen gate. Returns {passed, failures}."""
|
| 87 |
+
failures = []
|
| 88 |
+
for lang, r in reports.items():
|
| 89 |
+
if r["frame_budget_violation_rate"] > max_violation_rate:
|
| 90 |
+
failures.append({"language": lang, "reason": "frame_budget_violation_rate",
|
| 91 |
+
"value": r["frame_budget_violation_rate"]})
|
| 92 |
+
if r["rare_token_percentage"]["lt100"] > max_rare_share:
|
| 93 |
+
failures.append({"language": lang, "reason": "rare_token_share_lt100",
|
| 94 |
+
"value": r["rare_token_percentage"]["lt100"]})
|
| 95 |
+
return {"passed": not failures, "failures": failures}
|
canopy/tokenizer/spm.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SentencePiece (unigram / BPE) tokenizer with CTC blank at id 0.
|
| 2 |
+
|
| 3 |
+
SentencePiece's own ids are shifted by +1, so SentencePiece <unk> (0) becomes
|
| 4 |
+
Canopy id 1. Normalization is disabled inside SentencePiece
|
| 5 |
+
(``normalization_rule_name=identity``): Canopy's per-language normalizer has
|
| 6 |
+
already produced ``text_train``, and SentencePiece's default NFKC would fold
|
| 7 |
+
characters that are distinct in some Pakistani orthographies.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import io
|
| 13 |
+
import math
|
| 14 |
+
import random
|
| 15 |
+
from collections.abc import Iterable
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from canopy.tokenizer.base import BLANK, UNK, CTCTokenizer
|
| 19 |
+
|
| 20 |
+
SPM_SPACE = "\u2581"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def balanced_sample(texts_by_language: dict[str, list[str]], temperature: float, seed: int = 0,
|
| 24 |
+
total: int | None = None) -> tuple[list[str], dict[str, int]]:
|
| 25 |
+
"""Resample sentences so language ``l`` gets a share ∝ n_l ** (1/temperature)."""
|
| 26 |
+
rng = random.Random(seed)
|
| 27 |
+
n = {lang: len(t) for lang, t in texts_by_language.items() if t}
|
| 28 |
+
total = total or sum(n.values())
|
| 29 |
+
if math.isinf(temperature):
|
| 30 |
+
w = {lang: 1.0 for lang in n}
|
| 31 |
+
else:
|
| 32 |
+
w = {lang: c ** (1.0 / temperature) for lang, c in n.items()}
|
| 33 |
+
z = sum(w.values())
|
| 34 |
+
out, counts = [], {}
|
| 35 |
+
for lang, texts in texts_by_language.items():
|
| 36 |
+
if not texts:
|
| 37 |
+
continue
|
| 38 |
+
k = max(1, round(total * w[lang] / z))
|
| 39 |
+
if k <= len(texts):
|
| 40 |
+
picked = rng.sample(texts, k)
|
| 41 |
+
else:
|
| 42 |
+
picked = texts * (k // len(texts)) + rng.sample(texts, k % len(texts))
|
| 43 |
+
out.extend(picked)
|
| 44 |
+
counts[lang] = k
|
| 45 |
+
rng.shuffle(out)
|
| 46 |
+
return out, counts
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class SentencePieceTokenizer(CTCTokenizer):
|
| 50 |
+
type = "sentencepiece"
|
| 51 |
+
|
| 52 |
+
def __init__(self, tokens, language_token_ids=None, meta=None, model_proto: bytes | None = None):
|
| 53 |
+
super().__init__(tokens, language_token_ids, meta)
|
| 54 |
+
import sentencepiece as spm
|
| 55 |
+
|
| 56 |
+
self._proto = model_proto
|
| 57 |
+
self.sp = spm.SentencePieceProcessor(model_proto=model_proto)
|
| 58 |
+
|
| 59 |
+
@classmethod
|
| 60 |
+
def train(cls, texts_by_language: dict[str, list[str]], vocab_size: int, model_type: str = "unigram",
|
| 61 |
+
temperature: float = 5.0, seed: int = 0, character_coverage: float = 1.0,
|
| 62 |
+
max_sentences: int | None = 2_000_000) -> SentencePieceTokenizer:
|
| 63 |
+
import sentencepiece as spm
|
| 64 |
+
|
| 65 |
+
sample, counts = balanced_sample(texts_by_language, temperature, seed,
|
| 66 |
+
total=min(max_sentences or 10**12, sum(len(v) for v in texts_by_language.values())))
|
| 67 |
+
buf = io.BytesIO()
|
| 68 |
+
spm.SentencePieceTrainer.train(
|
| 69 |
+
sentence_iterator=iter(sample), model_writer=buf, vocab_size=vocab_size, model_type=model_type,
|
| 70 |
+
character_coverage=character_coverage, byte_fallback=False, split_digits=True,
|
| 71 |
+
normalization_rule_name="identity", add_dummy_prefix=True, remove_extra_whitespaces=True,
|
| 72 |
+
unk_id=0, bos_id=-1, eos_id=-1, pad_id=-1, hard_vocab_limit=False,
|
| 73 |
+
input_sentence_size=0, shuffle_input_sentence=False, num_threads=4, minloglevel=2,
|
| 74 |
+
)
|
| 75 |
+
proto = buf.getvalue()
|
| 76 |
+
sp = spm.SentencePieceProcessor(model_proto=proto)
|
| 77 |
+
tokens = [BLANK, UNK] + [sp.id_to_piece(i) for i in range(1, sp.get_piece_size())]
|
| 78 |
+
meta = {"model_type": model_type, "requested_vocab_size": vocab_size, "temperature": temperature,
|
| 79 |
+
"seed": seed, "character_coverage": character_coverage, "training_sentences_per_language": counts}
|
| 80 |
+
tok = cls(tokens, None, meta, proto)
|
| 81 |
+
tok.language_token_ids = {
|
| 82 |
+
lang: sorted({i for t in texts for i in tok.encode(t)}) for lang, texts in texts_by_language.items()
|
| 83 |
+
}
|
| 84 |
+
return tok
|
| 85 |
+
|
| 86 |
+
@classmethod
|
| 87 |
+
def from_payload(cls, payload: dict, directory: Path) -> SentencePieceTokenizer:
|
| 88 |
+
proto = (directory / "spm.model").read_bytes()
|
| 89 |
+
return cls(payload["tokens"], payload.get("language_token_ids"), payload.get("meta"), proto)
|
| 90 |
+
|
| 91 |
+
def save(self, directory):
|
| 92 |
+
d = Path(directory)
|
| 93 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 94 |
+
(d / "spm.model").write_bytes(self._proto)
|
| 95 |
+
return super().save(d)
|
| 96 |
+
|
| 97 |
+
def encode(self, text: str, language: str | None = None) -> list[int]:
|
| 98 |
+
return [i + 1 for i in self.sp.encode(text, out_type=int)]
|
| 99 |
+
|
| 100 |
+
def decode(self, ids: Iterable[int]) -> str:
|
| 101 |
+
return self.sp.decode([i - 1 for i in ids if i > 0])
|
| 102 |
+
|
| 103 |
+
def always_allowed_ids(self) -> list[int]:
|
| 104 |
+
ids = [0]
|
| 105 |
+
sp_space = self.token_to_id.get(SPM_SPACE)
|
| 106 |
+
if sp_space is not None:
|
| 107 |
+
ids.append(sp_space)
|
| 108 |
+
return ids
|
| 109 |
+
|
| 110 |
+
def labels_for_pyctcdecode(self) -> list[str]:
|
| 111 |
+
return ["", "\u2047", *[t.replace(SPM_SPACE, " ") if t.startswith(SPM_SPACE) else t for t in self.tokens[2:]]]
|
canopy/training/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from canopy.training.schedule import WarmupCosine
|
| 2 |
+
|
| 3 |
+
__all__ = ["WarmupCosine"]
|
canopy/training/checkpoint.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Checkpoint format.
|
| 2 |
+
|
| 3 |
+
Training checkpoints hold model, optimizer, scheduler, scaler and loop state.
|
| 4 |
+
Inference checkpoints (``export_inference_checkpoint``) hold only the model
|
| 5 |
+
config, weights, language list and the tokenizer payload, so one file is
|
| 6 |
+
enough to transcribe.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
|
| 16 |
+
from canopy.models.canopy_ctc import CanopyCTC
|
| 17 |
+
from canopy.models.config import ModelConfig
|
| 18 |
+
|
| 19 |
+
FORMAT_VERSION = 1
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def save_training_checkpoint(path: Path, *, model, optimizer, scheduler, scaler, state: dict, model_config: dict,
|
| 23 |
+
tokenizer_dir: str) -> None:
|
| 24 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 25 |
+
tmp = path.with_suffix(".tmp")
|
| 26 |
+
torch.save({
|
| 27 |
+
"format_version": FORMAT_VERSION,
|
| 28 |
+
"kind": "training",
|
| 29 |
+
"model_config": model_config,
|
| 30 |
+
"model_state": model.state_dict(),
|
| 31 |
+
"optimizer_state": optimizer.state_dict(),
|
| 32 |
+
"scheduler_state": scheduler.state_dict(),
|
| 33 |
+
"scaler_state": scaler.state_dict() if scaler is not None else None,
|
| 34 |
+
"loop_state": state,
|
| 35 |
+
"tokenizer_dir": tokenizer_dir,
|
| 36 |
+
"rng": {"torch": torch.get_rng_state(),
|
| 37 |
+
"cuda": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None},
|
| 38 |
+
}, tmp)
|
| 39 |
+
tmp.replace(path)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def load_checkpoint(path: str | Path, map_location="cpu") -> dict:
|
| 43 |
+
return torch.load(str(path), map_location=map_location, weights_only=False)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def export_inference_checkpoint(train_ckpt: str | Path, out: str | Path, tokenizer_dir: str | Path | None = None,
|
| 47 |
+
extra_meta: dict | None = None) -> Path:
|
| 48 |
+
ck = load_checkpoint(train_ckpt)
|
| 49 |
+
tok_dir = Path(tokenizer_dir or ck["tokenizer_dir"])
|
| 50 |
+
payload = json.loads((tok_dir / "tokenizer.json").read_text(encoding="utf-8"))
|
| 51 |
+
spm_model = (tok_dir / "spm.model").read_bytes() if (tok_dir / "spm.model").exists() else None
|
| 52 |
+
state = {k: v for k, v in ck["model_state"].items() if not k.startswith("aux_heads.")}
|
| 53 |
+
cfg = dict(ck["model_config"])
|
| 54 |
+
if not cfg.get("self_conditioning"):
|
| 55 |
+
cfg["aux_ctc_layers"] = []
|
| 56 |
+
out = Path(out)
|
| 57 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 58 |
+
torch.save({
|
| 59 |
+
"format_version": FORMAT_VERSION,
|
| 60 |
+
"kind": "inference",
|
| 61 |
+
"model_config": cfg,
|
| 62 |
+
"model_state": state,
|
| 63 |
+
"tokenizer_payload": payload,
|
| 64 |
+
"spm_model": spm_model,
|
| 65 |
+
"meta": {"source_checkpoint": str(train_ckpt), "loop_state": ck.get("loop_state"), **(extra_meta or {})},
|
| 66 |
+
}, out)
|
| 67 |
+
return out
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def model_from_checkpoint(ck: dict) -> CanopyCTC:
|
| 71 |
+
cfg = ModelConfig.from_dict(ck["model_config"])
|
| 72 |
+
model = CanopyCTC(cfg)
|
| 73 |
+
missing, unexpected = model.load_state_dict(ck["model_state"], strict=False)
|
| 74 |
+
unexpected = [k for k in unexpected if not k.startswith("aux_heads.")]
|
| 75 |
+
if missing or unexpected:
|
| 76 |
+
raise RuntimeError(f"checkpoint mismatch: missing={missing} unexpected={unexpected}")
|
| 77 |
+
return model
|
canopy/training/losses.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn.functional as F
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def ctc_loss(log_probs: torch.Tensor, lengths: torch.Tensor, targets: torch.Tensor, target_lengths: torch.Tensor,
|
| 8 |
+
blank: int = 0) -> torch.Tensor:
|
| 9 |
+
"""Mean over utterances of per-utterance CTC loss divided by target length."""
|
| 10 |
+
return F.ctc_loss(log_probs.transpose(0, 1).float(), targets, lengths, target_lengths, blank=blank,
|
| 11 |
+
reduction="mean", zero_infinity=True)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def canopy_loss(out: dict, batch: dict, *, aux_weight: float = 0.3, lid_weight: float = 0.0,
|
| 15 |
+
lid_targets: torch.Tensor | None = None) -> tuple[torch.Tensor, dict]:
|
| 16 |
+
"""InterCTC-style objective: (1 - w) * CTC_final + w * mean(CTC_aux) [+ lid_weight * CE_lid]."""
|
| 17 |
+
main = ctc_loss(out["log_probs"], out["lengths"], batch["targets"], batch["target_lengths"])
|
| 18 |
+
parts = {"ctc": main.detach()}
|
| 19 |
+
loss = main
|
| 20 |
+
aux = out.get("aux_log_probs") or {}
|
| 21 |
+
if aux and aux_weight > 0:
|
| 22 |
+
aux_losses = [ctc_loss(lp, out["lengths"], batch["targets"], batch["target_lengths"]) for lp in aux.values()]
|
| 23 |
+
aux_mean = torch.stack(aux_losses).mean()
|
| 24 |
+
loss = (1 - aux_weight) * main + aux_weight * aux_mean
|
| 25 |
+
parts["aux_ctc"] = aux_mean.detach()
|
| 26 |
+
if lid_weight > 0 and "lid_logits" in out and lid_targets is not None:
|
| 27 |
+
lid = F.cross_entropy(out["lid_logits"].float(), lid_targets)
|
| 28 |
+
loss = loss + lid_weight * lid
|
| 29 |
+
parts["lid"] = lid.detach()
|
| 30 |
+
return loss, parts
|
canopy/training/run.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run directories and reproducibility artifacts.
|
| 2 |
+
|
| 3 |
+
runs/canopy_exp_<id>_<slug>_<YYYYmmdd-HHMMSS>/
|
| 4 |
+
config.yaml git_commit.txt environment.txt environment.json
|
| 5 |
+
dataset_manifest.json dataset_hashes.json tokenizer_version tokenizer/
|
| 6 |
+
model_config.json training_log.jsonl eval_history.jsonl sampler_audit.jsonl
|
| 7 |
+
training_metrics.json evaluation_metrics.json checkpoints/
|
| 8 |
+
Existing run directories are never overwritten.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import time
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
from canopy.utils.env import environment_info, environment_text, git_commit
|
| 18 |
+
from canopy.utils.io import save_json, save_yaml, sha256_file
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def create_run_dir(output_root: str | Path, exp_id: str, slug: str) -> Path:
|
| 22 |
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
| 23 |
+
d = Path(output_root) / f"canopy_exp_{exp_id}_{slug}_{stamp}"
|
| 24 |
+
if d.exists():
|
| 25 |
+
raise FileExistsError(f"{d} already exists; runs are never overwritten")
|
| 26 |
+
(d / "checkpoints").mkdir(parents=True)
|
| 27 |
+
return d
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def write_static_artifacts(run_dir: Path, *, config: dict, manifests: list[str], dataset_summary: dict,
|
| 31 |
+
tokenizer, model_config: dict, parameter_breakdown: dict) -> None:
|
| 32 |
+
save_yaml(config, run_dir / "config.yaml")
|
| 33 |
+
g = git_commit(Path(__file__).resolve().parents[2])
|
| 34 |
+
(run_dir / "git_commit.txt").write_text(
|
| 35 |
+
f"{g['commit']}\nbranch: {g['branch']}\ndirty: {g['dirty']}\n", encoding="utf-8")
|
| 36 |
+
info = environment_info()
|
| 37 |
+
(run_dir / "environment.txt").write_text(environment_text(info), encoding="utf-8")
|
| 38 |
+
save_json(info, run_dir / "environment.json")
|
| 39 |
+
save_json({"manifests": manifests, "summary": dataset_summary}, run_dir / "dataset_manifest.json")
|
| 40 |
+
save_json({m: sha256_file(m) for m in dict.fromkeys(manifests)}, run_dir / "dataset_hashes.json")
|
| 41 |
+
tok_path = tokenizer.save(run_dir / "tokenizer")
|
| 42 |
+
(run_dir / "tokenizer_version").write_text(f"{tokenizer.type} {tokenizer.version} {tok_path.name}\n", encoding="utf-8")
|
| 43 |
+
save_json({"model": model_config, "parameters": parameter_breakdown}, run_dir / "model_config.json")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def append_jsonl(path: Path, obj: dict) -> None:
|
| 47 |
+
with open(path, "a", encoding="utf-8") as f:
|
| 48 |
+
f.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
canopy/training/schedule.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class WarmupCosine:
|
| 7 |
+
"""Linear warmup from ``init_ratio * lr`` to ``lr``, then cosine decay to ``final_ratio * lr``."""
|
| 8 |
+
|
| 9 |
+
def __init__(self, optimizer, *, total_steps: int, warmup_steps: int, init_ratio: float = 0.02,
|
| 10 |
+
final_ratio: float = 0.02):
|
| 11 |
+
self.opt = optimizer
|
| 12 |
+
self.total = max(1, total_steps)
|
| 13 |
+
self.warmup = max(0, warmup_steps)
|
| 14 |
+
self.init_ratio = init_ratio
|
| 15 |
+
self.final_ratio = final_ratio
|
| 16 |
+
self.base = [g["lr"] for g in optimizer.param_groups]
|
| 17 |
+
self.step_num = 0
|
| 18 |
+
self._apply()
|
| 19 |
+
|
| 20 |
+
def factor(self, step: int) -> float:
|
| 21 |
+
if self.warmup and step < self.warmup:
|
| 22 |
+
return self.init_ratio + (1 - self.init_ratio) * step / self.warmup
|
| 23 |
+
progress = min(1.0, (step - self.warmup) / max(1, self.total - self.warmup))
|
| 24 |
+
return self.final_ratio + (1 - self.final_ratio) * 0.5 * (1 + math.cos(math.pi * progress))
|
| 25 |
+
|
| 26 |
+
def _apply(self):
|
| 27 |
+
f = self.factor(self.step_num)
|
| 28 |
+
for g, b in zip(self.opt.param_groups, self.base):
|
| 29 |
+
g["lr"] = b * f
|
| 30 |
+
|
| 31 |
+
def step(self):
|
| 32 |
+
self.step_num += 1
|
| 33 |
+
self._apply()
|
| 34 |
+
|
| 35 |
+
def state_dict(self) -> dict:
|
| 36 |
+
return {"step_num": self.step_num, "base": self.base}
|
| 37 |
+
|
| 38 |
+
def load_state_dict(self, d: dict):
|
| 39 |
+
self.step_num = d["step_num"]
|
| 40 |
+
self.base = d["base"]
|
| 41 |
+
self._apply()
|
canopy/training/trainer.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Single-GPU trainer for CanopyCTC.
|
| 2 |
+
|
| 3 |
+
Model selection uses the dev split only. The test split is never loaded here.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import random
|
| 9 |
+
import shutil
|
| 10 |
+
import time
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
from torch.utils.data import DataLoader
|
| 15 |
+
|
| 16 |
+
from canopy.data.dataset import ASRDataset, collate
|
| 17 |
+
from canopy.data.manifest import load_manifest, summarize
|
| 18 |
+
from canopy.data.sampler import BalancedBatchSampler
|
| 19 |
+
from canopy.evaluation.runner import evaluate_rows
|
| 20 |
+
from canopy.frontend.augment import SpecAugment
|
| 21 |
+
from canopy.models.canopy_ctc import CanopyCTC
|
| 22 |
+
from canopy.models.config import PRESETS, ModelConfig
|
| 23 |
+
from canopy.tokenizer.base import load_tokenizer
|
| 24 |
+
from canopy.training.checkpoint import load_checkpoint, save_training_checkpoint
|
| 25 |
+
from canopy.training.losses import canopy_loss
|
| 26 |
+
from canopy.training.run import append_jsonl, create_run_dir, write_static_artifacts
|
| 27 |
+
from canopy.training.schedule import WarmupCosine
|
| 28 |
+
from canopy.utils.io import save_json
|
| 29 |
+
from canopy.utils.seed import seed_everything
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def resolve_device(name: str) -> torch.device:
|
| 33 |
+
if name == "auto":
|
| 34 |
+
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 35 |
+
return torch.device(name)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def resolve_precision(name: str, device: torch.device) -> str:
|
| 39 |
+
if name != "auto":
|
| 40 |
+
if name == "bf16" and device.type == "cuda" and not torch.cuda.is_bf16_supported():
|
| 41 |
+
raise RuntimeError("bf16 requested but this GPU does not support it; use precision=fp16")
|
| 42 |
+
return name
|
| 43 |
+
if device.type != "cuda":
|
| 44 |
+
return "fp32"
|
| 45 |
+
return "bf16" if torch.cuda.is_bf16_supported() else "fp16"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
AMP_DTYPES = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": None}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def build_model_config(cfg: dict, vocab_size: int, languages: list[str]) -> ModelConfig:
|
| 52 |
+
mcfg = cfg["model"]
|
| 53 |
+
base = dict(PRESETS[mcfg.get("preset", "canopy-m")])
|
| 54 |
+
base.update(mcfg.get("overrides") or {})
|
| 55 |
+
hw = cfg.get("hardware", {})
|
| 56 |
+
if "gradient_checkpointing" in hw:
|
| 57 |
+
base["gradient_checkpointing"] = bool(hw["gradient_checkpointing"])
|
| 58 |
+
base["vocab_size"] = vocab_size
|
| 59 |
+
base["languages"] = languages
|
| 60 |
+
return ModelConfig.from_dict(base)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def param_groups(model: torch.nn.Module, weight_decay: float) -> list[dict]:
|
| 64 |
+
decay, no_decay = [], []
|
| 65 |
+
for name, p in model.named_parameters():
|
| 66 |
+
if not p.requires_grad:
|
| 67 |
+
continue
|
| 68 |
+
(no_decay if p.ndim < 2 or "lang_embed" in name else decay).append(p)
|
| 69 |
+
return [{"params": decay, "weight_decay": weight_decay}, {"params": no_decay, "weight_decay": 0.0}]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _subsample_dev(rows: list[dict], per_lang: int | None, seed: int) -> list[dict]:
|
| 73 |
+
if not per_lang:
|
| 74 |
+
return rows
|
| 75 |
+
by: dict[str, list[dict]] = {}
|
| 76 |
+
for r in rows:
|
| 77 |
+
by.setdefault(r["language"], []).append(r)
|
| 78 |
+
rng = random.Random(seed)
|
| 79 |
+
out = []
|
| 80 |
+
for _lang, rs in sorted(by.items()):
|
| 81 |
+
rs = sorted(rs, key=lambda r: r["id"])
|
| 82 |
+
out.extend(rs if len(rs) <= per_lang else rng.sample(rs, per_lang))
|
| 83 |
+
return out
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class Trainer:
|
| 87 |
+
@staticmethod
|
| 88 |
+
def _log_resume(run_dir: Path, cfg: dict, state: dict) -> None:
|
| 89 |
+
"""Append to resume_log.jsonl; warn when the effective batch differs from the original run."""
|
| 90 |
+
import yaml
|
| 91 |
+
|
| 92 |
+
from canopy.utils.env import environment_info
|
| 93 |
+
|
| 94 |
+
original = {}
|
| 95 |
+
if (run_dir / "config.yaml").exists():
|
| 96 |
+
original = yaml.safe_load((run_dir / "config.yaml").read_text(encoding="utf-8")) or {}
|
| 97 |
+
|
| 98 |
+
def batch_key(c: dict) -> tuple:
|
| 99 |
+
h, t = c.get("hardware", {}), c.get("train", {})
|
| 100 |
+
return h.get("max_batch_seconds"), h.get("max_batch_size"), t.get("grad_accum")
|
| 101 |
+
|
| 102 |
+
changed = bool(original) and batch_key(original) != batch_key(cfg)
|
| 103 |
+
if changed:
|
| 104 |
+
print(f"[canopy] WARNING: batch settings changed on resume {batch_key(original)} -> {batch_key(cfg)} "
|
| 105 |
+
"(max_batch_seconds, max_batch_size, grad_accum); the remainder of the run is not comparable "
|
| 106 |
+
"to an uninterrupted run and the sampler position within the epoch is approximate")
|
| 107 |
+
append_jsonl(run_dir / "resume_log.jsonl", {
|
| 108 |
+
"time": time.strftime("%Y-%m-%dT%H:%M:%S"), "step": state["step"], "epoch": state["epoch"],
|
| 109 |
+
"sources": cfg.get("_sources"), "hardware": cfg.get("hardware"), "grad_accum": cfg.get("train", {}).get("grad_accum"),
|
| 110 |
+
"batch_settings_changed": changed, "environment": environment_info()})
|
| 111 |
+
|
| 112 |
+
def __init__(self, cfg: dict, resume: str | None = None):
|
| 113 |
+
self.cfg = cfg
|
| 114 |
+
self.resume = resume
|
| 115 |
+
|
| 116 |
+
# ------------------------------------------------------------------ setup
|
| 117 |
+
def _load_data(self):
|
| 118 |
+
d = self.cfg["data"]
|
| 119 |
+
prov = ["human"] + (["pseudo"] if d.get("include_pseudo") else []) + (["synthetic"] if d.get("include_synthetic") else [])
|
| 120 |
+
train_manifests = list(d["train_manifests"])
|
| 121 |
+
dev_manifests = list(d.get("dev_manifests") or train_manifests)
|
| 122 |
+
train = load_manifest(train_manifests, languages=d.get("languages"), splits=[d.get("train_split", "train")],
|
| 123 |
+
provenances=prov, min_duration=d.get("min_duration", 0.3),
|
| 124 |
+
max_duration=d.get("max_duration", 20.0))
|
| 125 |
+
dev_prov = ["human"] + (["synthetic"] if d.get("include_synthetic") else [])
|
| 126 |
+
dev = load_manifest(dev_manifests, languages=d.get("languages"), splits=[d.get("dev_split", "dev")],
|
| 127 |
+
provenances=dev_prov, max_duration=d.get("max_duration_eval", 60.0))
|
| 128 |
+
if not train:
|
| 129 |
+
raise RuntimeError("no training rows after filtering; check data.train_manifests / languages / splits")
|
| 130 |
+
if not dev:
|
| 131 |
+
raise RuntimeError("no dev rows; model selection needs a dev split (never the test split)")
|
| 132 |
+
dev = _subsample_dev(dev, d.get("max_dev_utts_per_language"), self.cfg.get("seed", 0))
|
| 133 |
+
return train, dev, train_manifests + [m for m in dev_manifests if m not in train_manifests]
|
| 134 |
+
|
| 135 |
+
def run(self) -> Path:
|
| 136 |
+
cfg = self.cfg
|
| 137 |
+
seed = int(cfg.get("seed", 1234))
|
| 138 |
+
seed_everything(seed, deterministic=bool(cfg.get("deterministic", False)))
|
| 139 |
+
hw = cfg.get("hardware", {})
|
| 140 |
+
device = resolve_device(hw.get("device", "auto"))
|
| 141 |
+
precision = resolve_precision(hw.get("precision", "auto"), device)
|
| 142 |
+
amp_dtype = AMP_DTYPES[precision]
|
| 143 |
+
if device.type == "cuda":
|
| 144 |
+
torch.backends.cuda.matmul.allow_tf32 = True
|
| 145 |
+
torch.backends.cudnn.allow_tf32 = True
|
| 146 |
+
|
| 147 |
+
train_rows, dev_rows, manifests = self._load_data()
|
| 148 |
+
tokenizer = load_tokenizer(cfg["tokenizer"]["path"])
|
| 149 |
+
languages = sorted({r["language"] for r in train_rows})
|
| 150 |
+
lang2id = {lang: i + 1 for i, lang in enumerate(languages)}
|
| 151 |
+
mcfg = build_model_config(cfg, tokenizer.vocab_size, languages)
|
| 152 |
+
model = CanopyCTC(mcfg).to(device)
|
| 153 |
+
|
| 154 |
+
aug = cfg.get("augment", {})
|
| 155 |
+
train_ds = ASRDataset(train_rows, tokenizer, lang2id, encoder_fps=25.0,
|
| 156 |
+
speed_factors=tuple(aug.get("speed_factors", [1.0])), train=True)
|
| 157 |
+
samp = cfg.get("sampling", {})
|
| 158 |
+
sampler = BalancedBatchSampler(
|
| 159 |
+
[r["language"] for r in train_ds.rows], [r["duration"] for r in train_ds.rows],
|
| 160 |
+
max_batch_seconds=hw.get("max_batch_seconds", 200), max_batch_size=hw.get("max_batch_size", 64),
|
| 161 |
+
temperature=float(samp.get("temperature", 5.0)), caps_hours=samp.get("caps_hours"),
|
| 162 |
+
default_cap_hours=samp.get("default_cap_hours"), epoch_hours=samp.get("epoch_hours"), seed=seed)
|
| 163 |
+
nw = int(hw.get("num_workers", 4))
|
| 164 |
+
loader = DataLoader(train_ds, batch_sampler=sampler, collate_fn=collate, num_workers=nw,
|
| 165 |
+
pin_memory=device.type == "cuda", persistent_workers=nw > 0,
|
| 166 |
+
prefetch_factor=hw.get("prefetch_factor", 4) if nw > 0 else None)
|
| 167 |
+
sa = aug.get("specaugment", {}) or {}
|
| 168 |
+
specaug = SpecAugment(**sa) if aug.get("specaugment_enabled", True) else None
|
| 169 |
+
|
| 170 |
+
oc = cfg["optim"]
|
| 171 |
+
tc = cfg["train"]
|
| 172 |
+
optimizer = torch.optim.AdamW(param_groups(model, oc.get("weight_decay", 0.01)), lr=float(oc["lr"]),
|
| 173 |
+
betas=tuple(oc.get("betas", [0.9, 0.98])), eps=float(oc.get("eps", 1e-8)),
|
| 174 |
+
fused=device.type == "cuda")
|
| 175 |
+
max_steps = int(tc["max_steps"])
|
| 176 |
+
scheduler = WarmupCosine(optimizer, total_steps=max_steps, warmup_steps=int(oc.get("warmup_steps", 5000)),
|
| 177 |
+
init_ratio=float(oc.get("warmup_init_ratio", 0.02)),
|
| 178 |
+
final_ratio=float(oc.get("final_lr_ratio", 0.02)))
|
| 179 |
+
scaler = torch.amp.GradScaler("cuda", enabled=precision == "fp16")
|
| 180 |
+
|
| 181 |
+
state = {"step": 0, "micro": 0, "epoch": 0, "batch_in_epoch": 0, "audio_seconds": 0.0, "best_macro_cer": None,
|
| 182 |
+
"best_step": None, "evals_since_best": 0, "oom_skips": 0, "nan_skips": 0, "elapsed_s": 0.0}
|
| 183 |
+
if self.resume:
|
| 184 |
+
ck = load_checkpoint(self.resume, map_location=device)
|
| 185 |
+
run_dir = Path(self.resume).resolve().parent.parent
|
| 186 |
+
model.load_state_dict(ck["model_state"])
|
| 187 |
+
optimizer.load_state_dict(ck["optimizer_state"])
|
| 188 |
+
scheduler.load_state_dict(ck["scheduler_state"])
|
| 189 |
+
if ck.get("scaler_state"):
|
| 190 |
+
scaler.load_state_dict(ck["scaler_state"])
|
| 191 |
+
state.update(ck["loop_state"])
|
| 192 |
+
print(f"[canopy] resumed from {self.resume} at step {state['step']}")
|
| 193 |
+
self._log_resume(run_dir, cfg, state)
|
| 194 |
+
else:
|
| 195 |
+
ex = cfg.get("experiment", {})
|
| 196 |
+
run_dir = create_run_dir(ex.get("output_root", "runs"), str(ex.get("id", "000")), ex.get("slug", "run"))
|
| 197 |
+
write_static_artifacts(
|
| 198 |
+
run_dir, config=cfg, manifests=manifests,
|
| 199 |
+
dataset_summary={"train": summarize(train_ds.rows), "dev": summarize(dev_rows),
|
| 200 |
+
"train_filter": train_ds.stats.as_dict(), "languages": languages},
|
| 201 |
+
tokenizer=tokenizer, model_config=mcfg.to_dict(), parameter_breakdown=model.parameter_breakdown())
|
| 202 |
+
tok_dir = str(run_dir / "tokenizer")
|
| 203 |
+
if hw.get("compile"):
|
| 204 |
+
model = torch.compile(model) # type: ignore[assignment]
|
| 205 |
+
core = getattr(model, "_orig_mod", model)
|
| 206 |
+
|
| 207 |
+
print(f"[canopy] run dir: {run_dir}")
|
| 208 |
+
print(f"[canopy] device={device} precision={precision} params={core.num_parameters():,} "
|
| 209 |
+
f"languages={len(languages)} train_utts={len(train_ds)} dev_utts={len(dev_rows)}")
|
| 210 |
+
print(f"[canopy] filtered: {train_ds.stats.as_dict()}")
|
| 211 |
+
|
| 212 |
+
accum = int(tc.get("grad_accum", 1))
|
| 213 |
+
lc = cfg.get("loss", {})
|
| 214 |
+
aux_w = float(lc.get("aux_ctc_weight", 0.3))
|
| 215 |
+
lid_w = float(lc.get("lid_weight", 0.0))
|
| 216 |
+
cond_drop = float(lc.get("lang_condition_dropout", 0.5))
|
| 217 |
+
clip = float(oc.get("grad_clip", 1.0))
|
| 218 |
+
log_every, eval_every = int(tc.get("log_every", 50)), int(tc.get("eval_every", 2000))
|
| 219 |
+
save_every = int(tc.get("save_every", eval_every))
|
| 220 |
+
max_hours = tc.get("max_audio_hours")
|
| 221 |
+
patience = tc.get("early_stop_patience")
|
| 222 |
+
max_nan = int(tc.get("max_consecutive_nan", 50))
|
| 223 |
+
max_oom = int(tc.get("max_oom_skips", 200))
|
| 224 |
+
ev = cfg.get("eval", {})
|
| 225 |
+
|
| 226 |
+
def save(name: str):
|
| 227 |
+
state["elapsed_s"] = elapsed_before + (time.time() - t_start)
|
| 228 |
+
save_training_checkpoint(run_dir / "checkpoints" / name, model=core, optimizer=optimizer, scheduler=scheduler,
|
| 229 |
+
scaler=scaler, state=dict(state), model_config=mcfg.to_dict(), tokenizer_dir=tok_dir)
|
| 230 |
+
|
| 231 |
+
def evaluate(n_boot: int = 0) -> dict:
|
| 232 |
+
metrics, _ = evaluate_rows(core, tokenizer, dev_rows, lang2id=lang2id, device=device,
|
| 233 |
+
max_batch_seconds=hw.get("eval_max_batch_seconds", 2 * hw.get("max_batch_seconds", 200)),
|
| 234 |
+
use_language=ev.get("use_language", True), use_masks=ev.get("use_masks", True),
|
| 235 |
+
amp_dtype=amp_dtype, n_boot=n_boot)
|
| 236 |
+
core.train()
|
| 237 |
+
return metrics
|
| 238 |
+
|
| 239 |
+
def do_eval() -> bool:
|
| 240 |
+
m = evaluate()
|
| 241 |
+
rec = {"step": state["step"], "audio_hours": state["audio_seconds"] / 3600, "macro": m["macro"],
|
| 242 |
+
"weighted": m["weighted"], "languages": {k: {"cer": v["cer"], "wer": v["wer"]} for k, v in m["languages"].items()},
|
| 243 |
+
"mean_blank_rate": m["decode"]["mean_blank_rate"]}
|
| 244 |
+
append_jsonl(run_dir / "eval_history.jsonl", rec)
|
| 245 |
+
cer = m["macro"]["cer"]
|
| 246 |
+
print(f"[canopy] eval step {state['step']}: dev macro CER {cer:.4f} WER {m['macro']['wer']:.4f} "
|
| 247 |
+
f"(weighted CER {m['weighted']['cer']:.4f})")
|
| 248 |
+
if state["best_macro_cer"] is None or cer < state["best_macro_cer"]:
|
| 249 |
+
state.update(best_macro_cer=cer, best_step=state["step"], evals_since_best=0)
|
| 250 |
+
save("best.pt")
|
| 251 |
+
else:
|
| 252 |
+
state["evals_since_best"] += 1
|
| 253 |
+
return bool(patience) and state["evals_since_best"] >= int(patience)
|
| 254 |
+
|
| 255 |
+
def rotate():
|
| 256 |
+
keep = int(tc.get("keep_last", 3))
|
| 257 |
+
ckpts = sorted((run_dir / "checkpoints").glob("step_*.pt"), key=lambda p: int(p.stem.split("_")[1]))
|
| 258 |
+
for p in ckpts[:-keep] if keep > 0 else []:
|
| 259 |
+
p.unlink(missing_ok=True)
|
| 260 |
+
|
| 261 |
+
model.train()
|
| 262 |
+
optimizer.zero_grad(set_to_none=True)
|
| 263 |
+
t_start = time.time()
|
| 264 |
+
elapsed_before = float(state.get("elapsed_s", 0.0))
|
| 265 |
+
window = {"loss": 0.0, "ctc": 0.0, "aux": 0.0, "n": 0, "audio": 0.0, "t": time.time()}
|
| 266 |
+
consecutive_nan = 0
|
| 267 |
+
stop = False
|
| 268 |
+
if device.type == "cuda":
|
| 269 |
+
torch.cuda.reset_peak_memory_stats(device)
|
| 270 |
+
|
| 271 |
+
if len(sampler) == 0:
|
| 272 |
+
raise RuntimeError("sampler produced no batches")
|
| 273 |
+
while not stop and state["step"] < max_steps:
|
| 274 |
+
sampler.set_epoch(state["epoch"])
|
| 275 |
+
if state["batch_in_epoch"] == 0:
|
| 276 |
+
append_jsonl(run_dir / "sampler_audit.jsonl", sampler.audit())
|
| 277 |
+
sampler.start_batch = state["batch_in_epoch"]
|
| 278 |
+
for batch in loader:
|
| 279 |
+
state["batch_in_epoch"] += 1
|
| 280 |
+
wav = batch["wav"].to(device, non_blocking=True)
|
| 281 |
+
wl = batch["wav_lengths"].to(device, non_blocking=True)
|
| 282 |
+
b = {"targets": batch["targets"].to(device, non_blocking=True),
|
| 283 |
+
"target_lengths": batch["target_lengths"].to(device, non_blocking=True)}
|
| 284 |
+
true_lids = batch["lang_ids"].to(device, non_blocking=True)
|
| 285 |
+
lids = true_lids
|
| 286 |
+
if core.lang_embed is not None and cond_drop > 0:
|
| 287 |
+
lids = torch.where(torch.rand(lids.shape, device=device) < cond_drop, torch.zeros_like(lids), lids)
|
| 288 |
+
try:
|
| 289 |
+
with torch.autocast(device.type, dtype=amp_dtype, enabled=amp_dtype is not None):
|
| 290 |
+
out = model(wav, wl, lids, spec_augment=specaug)
|
| 291 |
+
loss, parts = canopy_loss(out, b, aux_weight=aux_w, lid_weight=lid_w, lid_targets=true_lids)
|
| 292 |
+
if not torch.isfinite(loss):
|
| 293 |
+
consecutive_nan += 1
|
| 294 |
+
state["nan_skips"] += 1
|
| 295 |
+
optimizer.zero_grad(set_to_none=True)
|
| 296 |
+
if consecutive_nan >= max_nan:
|
| 297 |
+
raise RuntimeError(f"{consecutive_nan} consecutive non-finite losses; aborting")
|
| 298 |
+
continue
|
| 299 |
+
consecutive_nan = 0
|
| 300 |
+
scaler.scale(loss / accum).backward()
|
| 301 |
+
except torch.cuda.OutOfMemoryError:
|
| 302 |
+
state["oom_skips"] += 1
|
| 303 |
+
del wav, wl, b
|
| 304 |
+
optimizer.zero_grad(set_to_none=True)
|
| 305 |
+
torch.cuda.empty_cache()
|
| 306 |
+
print(f"[canopy] OOM on a batch (skipped {state['oom_skips']}); lower hardware.max_batch_seconds if frequent")
|
| 307 |
+
if state["oom_skips"] > max_oom:
|
| 308 |
+
raise
|
| 309 |
+
continue
|
| 310 |
+
|
| 311 |
+
batch_audio = float(batch["wav_lengths"].sum()) / 16000
|
| 312 |
+
state["audio_seconds"] += batch_audio
|
| 313 |
+
window["audio"] += batch_audio
|
| 314 |
+
window["loss"] += float(loss.detach())
|
| 315 |
+
window["ctc"] += float(parts["ctc"])
|
| 316 |
+
window["aux"] += float(parts.get("aux_ctc", 0.0))
|
| 317 |
+
window["n"] += 1
|
| 318 |
+
state["micro"] += 1
|
| 319 |
+
if state["micro"] % accum:
|
| 320 |
+
continue
|
| 321 |
+
|
| 322 |
+
scaler.unscale_(optimizer)
|
| 323 |
+
gnorm = torch.nn.utils.clip_grad_norm_(core.parameters(), clip)
|
| 324 |
+
scaler.step(optimizer)
|
| 325 |
+
scaler.update()
|
| 326 |
+
optimizer.zero_grad(set_to_none=True)
|
| 327 |
+
scheduler.step()
|
| 328 |
+
state["step"] += 1
|
| 329 |
+
step = state["step"]
|
| 330 |
+
|
| 331 |
+
if step % log_every == 0:
|
| 332 |
+
dt = time.time() - window["t"]
|
| 333 |
+
n = max(1, window["n"])
|
| 334 |
+
rec = {"step": step, "epoch": state["epoch"], "loss": window["loss"] / n, "ctc": window["ctc"] / n,
|
| 335 |
+
"aux_ctc": window["aux"] / n, "lr": optimizer.param_groups[0]["lr"], "grad_norm": float(gnorm),
|
| 336 |
+
"audio_hours_seen": state["audio_seconds"] / 3600, "x_realtime": window["audio"] / max(dt, 1e-9),
|
| 337 |
+
"oom_skips": state["oom_skips"], "nan_skips": state["nan_skips"]}
|
| 338 |
+
if device.type == "cuda":
|
| 339 |
+
rec["gpu_peak_mem_gb"] = torch.cuda.max_memory_allocated(device) / 2**30
|
| 340 |
+
if device.type == "cuda":
|
| 341 |
+
rec["max_mem_gb"] = torch.cuda.max_memory_allocated(device) / 2**30
|
| 342 |
+
append_jsonl(run_dir / "training_log.jsonl", rec)
|
| 343 |
+
print(f"[canopy] step {step} loss {rec['loss']:.3f} ctc {rec['ctc']:.3f} lr {rec['lr']:.2e} "
|
| 344 |
+
f"gnorm {rec['grad_norm']:.2f} {rec['x_realtime']:.0f}xRT seen {rec['audio_hours_seen']:.1f}h"
|
| 345 |
+
+ (f" mem {rec['max_mem_gb']:.1f}GB" if "max_mem_gb" in rec else ""))
|
| 346 |
+
window = {"loss": 0.0, "ctc": 0.0, "aux": 0.0, "n": 0, "audio": 0.0, "t": time.time()}
|
| 347 |
+
|
| 348 |
+
if step % eval_every == 0:
|
| 349 |
+
stop = do_eval()
|
| 350 |
+
if step % save_every == 0:
|
| 351 |
+
save(f"step_{step}.pt")
|
| 352 |
+
save("last.pt")
|
| 353 |
+
rotate()
|
| 354 |
+
if stop or step >= max_steps or (max_hours and state["audio_seconds"] / 3600 >= float(max_hours)):
|
| 355 |
+
stop = True
|
| 356 |
+
break
|
| 357 |
+
else:
|
| 358 |
+
state["epoch"] += 1
|
| 359 |
+
state["batch_in_epoch"] = 0
|
| 360 |
+
|
| 361 |
+
if state["best_step"] != state["step"]:
|
| 362 |
+
do_eval()
|
| 363 |
+
save("last.pt")
|
| 364 |
+
elapsed = elapsed_before + (time.time() - t_start)
|
| 365 |
+
|
| 366 |
+
best = load_checkpoint(run_dir / "checkpoints" / "best.pt", map_location=device)
|
| 367 |
+
core.load_state_dict(best["model_state"])
|
| 368 |
+
final = evaluate(n_boot=int(ev.get("final_bootstrap", 1000)))
|
| 369 |
+
final["checkpoint"] = "best.pt"
|
| 370 |
+
final["step"] = best["loop_state"]["step"]
|
| 371 |
+
final["split"] = "dev"
|
| 372 |
+
save_json(final, run_dir / "evaluation_metrics.json")
|
| 373 |
+
save_json({
|
| 374 |
+
"steps": state["step"], "micro_batches": state["micro"], "epochs_completed": state["epoch"],
|
| 375 |
+
"audio_hours_seen": state["audio_seconds"] / 3600, "elapsed_hours": elapsed / 3600,
|
| 376 |
+
"x_realtime_overall": state["audio_seconds"] / max(elapsed, 1e-9), "precision": precision,
|
| 377 |
+
"device": str(device), "best_step": state["best_step"], "best_dev_macro_cer": state["best_macro_cer"],
|
| 378 |
+
"oom_skips": state["oom_skips"], "nan_skips": state["nan_skips"], "train_filter": train_ds.stats.as_dict(),
|
| 379 |
+
"sampler_language_probabilities": sampler.probs, "train_hours_by_language": sampler.hours,
|
| 380 |
+
"max_memory_gb": torch.cuda.max_memory_allocated(device) / 2**30 if device.type == "cuda" else None,
|
| 381 |
+
}, run_dir / "training_metrics.json")
|
| 382 |
+
if tc.get("copy_best_to"):
|
| 383 |
+
shutil.copy2(run_dir / "checkpoints" / "best.pt", tc["copy_best_to"])
|
| 384 |
+
print(f"[canopy] done. best dev macro CER {state['best_macro_cer']:.4f} at step {state['best_step']}; run dir {run_dir}")
|
| 385 |
+
return run_dir
|
canopy/utils/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from canopy.utils.io import load_yaml, read_jsonl, save_json, save_yaml, sha256_file, sha256_text, write_jsonl
|
| 2 |
+
from canopy.utils.seed import seed_everything
|
| 3 |
+
|
| 4 |
+
__all__ = [
|
| 5 |
+
"load_yaml",
|
| 6 |
+
"read_jsonl",
|
| 7 |
+
"save_json",
|
| 8 |
+
"save_yaml",
|
| 9 |
+
"seed_everything",
|
| 10 |
+
"sha256_file",
|
| 11 |
+
"sha256_text",
|
| 12 |
+
"write_jsonl",
|
| 13 |
+
]
|
canopy/utils/env.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Capture the software/hardware environment for reproducibility records."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import importlib
|
| 6 |
+
import platform
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
|
| 13 |
+
_PACKAGES = ["numpy", "torch", "torchaudio", "sentencepiece", "soundfile", "yaml", "kenlm", "pyctcdecode",
|
| 14 |
+
"transformers", "onnx", "onnxruntime"]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _version(name: str) -> str | None:
|
| 18 |
+
try:
|
| 19 |
+
mod = importlib.import_module(name)
|
| 20 |
+
except Exception:
|
| 21 |
+
return None
|
| 22 |
+
return getattr(mod, "__version__", "installed (no __version__)")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def git_commit(repo: str | Path = ".") -> dict:
|
| 26 |
+
def run(*args: str) -> str | None:
|
| 27 |
+
try:
|
| 28 |
+
return subprocess.check_output(["git", *args], cwd=repo, stderr=subprocess.DEVNULL, text=True).strip()
|
| 29 |
+
except Exception:
|
| 30 |
+
return None
|
| 31 |
+
|
| 32 |
+
commit = run("rev-parse", "HEAD")
|
| 33 |
+
status = run("status", "--porcelain")
|
| 34 |
+
return {"commit": commit, "dirty": bool(status) if status is not None else None, "branch": run("rev-parse", "--abbrev-ref", "HEAD")}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def environment_info() -> dict:
|
| 38 |
+
info = {
|
| 39 |
+
"python": sys.version.split()[0],
|
| 40 |
+
"platform": platform.platform(),
|
| 41 |
+
"processor": platform.processor(),
|
| 42 |
+
"packages": {p: _version(p) for p in _PACKAGES},
|
| 43 |
+
"torch_cuda": torch.version.cuda,
|
| 44 |
+
"cudnn": torch.backends.cudnn.version() if torch.backends.cudnn.is_available() else None,
|
| 45 |
+
"cuda_available": torch.cuda.is_available(),
|
| 46 |
+
"gpus": [],
|
| 47 |
+
}
|
| 48 |
+
if torch.cuda.is_available():
|
| 49 |
+
for i in range(torch.cuda.device_count()):
|
| 50 |
+
p = torch.cuda.get_device_properties(i)
|
| 51 |
+
info["gpus"].append({"name": p.name, "total_memory_gb": round(p.total_memory / 2**30, 2),
|
| 52 |
+
"capability": f"{p.major}.{p.minor}"})
|
| 53 |
+
return info
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def environment_text(info: dict | None = None) -> str:
|
| 57 |
+
info = info or environment_info()
|
| 58 |
+
lines = [f"python: {info['python']}", f"platform: {info['platform']}", f"torch_cuda: {info['torch_cuda']}",
|
| 59 |
+
f"cudnn: {info['cudnn']}", f"cuda_available: {info['cuda_available']}"]
|
| 60 |
+
for g in info["gpus"]:
|
| 61 |
+
lines.append(f"gpu: {g['name']} ({g['total_memory_gb']} GB, sm_{g['capability']})")
|
| 62 |
+
for k, v in info["packages"].items():
|
| 63 |
+
lines.append(f"{k}: {v}")
|
| 64 |
+
return "\n".join(lines) + "\n"
|