Remove the vendored canopy package
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +0 -9
- canopy/__init__.py +0 -6
- canopy/data/__init__.py +0 -3
- canopy/data/commonvoice.py +0 -113
- canopy/data/dataset.py +0 -104
- canopy/data/generic.py +0 -75
- canopy/data/manifest.py +0 -124
- canopy/data/sampler.py +0 -128
- canopy/data/splits.py +0 -188
- canopy/data/validation.py +0 -193
- canopy/decoding/__init__.py +0 -4
- canopy/decoding/beam.py +0 -132
- canopy/decoding/greedy.py +0 -43
- canopy/evaluation/__init__.py +0 -3
- canopy/evaluation/metrics.py +0 -135
- canopy/evaluation/runner.py +0 -98
- canopy/evaluation/status.py +0 -79
- canopy/frontend/__init__.py +0 -5
- canopy/frontend/audio.py +0 -69
- canopy/frontend/augment.py +0 -56
- canopy/frontend/features.py +0 -111
- canopy/inference/__init__.py +0 -4
- canopy/inference/__main__.py +0 -3
- canopy/inference/api.py +0 -54
- canopy/inference/benchmark.py +0 -64
- canopy/inference/cli.py +0 -36
- canopy/inference/hub.py +0 -54
- canopy/inference/quantize.py +0 -36
- canopy/inference/transcriber.py +0 -109
- canopy/language_model/__init__.py +0 -4
- canopy/language_model/arpa.py +0 -188
- canopy/language_model/kenlm_backend.py +0 -62
- canopy/language_model/text.py +0 -38
- canopy/languages.py +0 -92
- canopy/models/__init__.py +0 -4
- canopy/models/canopy_ctc.py +0 -107
- canopy/models/config.py +0 -58
- canopy/models/modules.py +0 -183
- canopy/text/__init__.py +0 -3
- canopy/text/normalize.py +0 -110
- canopy/text/rules.py +0 -88
- canopy/tokenizer/__init__.py +0 -5
- canopy/tokenizer/base.py +0 -96
- canopy/tokenizer/char.py +0 -76
- canopy/tokenizer/diagnostics.py +0 -95
- canopy/tokenizer/spm.py +0 -111
- canopy/training/__init__.py +0 -3
- canopy/training/checkpoint.py +0 -77
- canopy/training/losses.py +0 -30
- canopy/training/run.py +0 -48
README.md
CHANGED
|
@@ -58,15 +58,6 @@ print(asr.transcribe("clip.wav", language="urd"))
|
|
| 58 |
print(asr.transcribe("clip.wav", language="snd"))
|
| 59 |
```
|
| 60 |
|
| 61 |
-
The snapshot in this repository also runs on its own:
|
| 62 |
-
|
| 63 |
-
```bash
|
| 64 |
-
pip install torch torchaudio soundfile numpy pyyaml huggingface_hub
|
| 65 |
-
hf auth login
|
| 66 |
-
hf download ProximaAI/Canopy-M --local-dir Canopy-M
|
| 67 |
-
python Canopy-M/canopy_m.py clip.wav --model canopy-m --language urd
|
| 68 |
-
```
|
| 69 |
-
|
| 70 |
`language` accepts `urd`, `ur`, `urdu`, `snd`, `sd`, and `sindhi`. Decoding in the table above is greedy CTC. Audio at any sample rate is resampled to 16 kHz mono. Files longer than 30 seconds are transcribed in chunks. If a repository ships more than one `.pt` file, pass `filename=`.
|
| 71 |
|
| 72 |
## Model
|
|
|
|
| 58 |
print(asr.transcribe("clip.wav", language="snd"))
|
| 59 |
```
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
`language` accepts `urd`, `ur`, `urdu`, `snd`, `sd`, and `sindhi`. Decoding in the table above is greedy CTC. Audio at any sample rate is resampled to 16 kHz mono. Files longer than 30 seconds are transcribed in chunks. If a repository ships more than one `.pt` file, pass `filename=`.
|
| 62 |
|
| 63 |
## Model
|
canopy/__init__.py
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,113 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,104 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,75 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,124 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,128 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,188 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,193 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,4 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,132 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,43 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,135 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,98 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,79 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,5 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,69 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,56 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,111 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,4 +0,0 @@
|
|
| 1 |
-
from canopy.inference.api import Canopy
|
| 2 |
-
from canopy.inference.transcriber import Transcriber
|
| 3 |
-
|
| 4 |
-
__all__ = ["Canopy", "Transcriber"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
canopy/inference/__main__.py
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
from canopy.inference.cli import main
|
| 2 |
-
|
| 3 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
canopy/inference/api.py
DELETED
|
@@ -1,54 +0,0 @@
|
|
| 1 |
-
"""User-facing loader for every Canopy checkpoint."""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
from pathlib import Path
|
| 6 |
-
|
| 7 |
-
import torch
|
| 8 |
-
|
| 9 |
-
from canopy.inference.hub import resolve_repo
|
| 10 |
-
from canopy.inference.transcriber import Transcriber
|
| 11 |
-
|
| 12 |
-
LANGUAGE_ALIASES = {"ur": "urd", "urdu": "urd", "sd": "snd", "sindhi": "snd"}
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
def resolve_language(language: str | None) -> str | None:
|
| 16 |
-
if language is None:
|
| 17 |
-
return None
|
| 18 |
-
return LANGUAGE_ALIASES.get(language.strip().lower(), language.strip().lower())
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
class Canopy:
|
| 22 |
-
"""One interface for every Canopy variant.
|
| 23 |
-
|
| 24 |
-
``model`` is a short name (``canopy-m``) or a Hub id (``ProximaAI/Canopy-M``).
|
| 25 |
-
A path to a local ``.pt`` file loads that file and does not contact the Hub.
|
| 26 |
-
"""
|
| 27 |
-
|
| 28 |
-
def __init__(self, transcriber: Transcriber, model: str):
|
| 29 |
-
self.transcriber = transcriber
|
| 30 |
-
self.model = model
|
| 31 |
-
self.languages = list(transcriber.languages)
|
| 32 |
-
|
| 33 |
-
@classmethod
|
| 34 |
-
def from_pretrained(cls, model: str = "canopy-m", *, filename: str | None = None,
|
| 35 |
-
device: str | None = None, token: str | None = None,
|
| 36 |
-
revision: str | None = None) -> Canopy:
|
| 37 |
-
if device is None:
|
| 38 |
-
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 39 |
-
path = Path(model)
|
| 40 |
-
if path.is_file():
|
| 41 |
-
asr = Transcriber.from_checkpoint(path, device=device)
|
| 42 |
-
return cls(asr, str(path))
|
| 43 |
-
repo_id = resolve_repo(model)
|
| 44 |
-
asr = Transcriber.from_pretrained(
|
| 45 |
-
repo_id, filename=filename, device=device, token=token, revision=revision,
|
| 46 |
-
)
|
| 47 |
-
return cls(asr, repo_id)
|
| 48 |
-
|
| 49 |
-
def transcribe(self, audio, language: str | None = "urd", decoder: str = "greedy", **kw) -> str:
|
| 50 |
-
code = resolve_language(language)
|
| 51 |
-
if code is not None and self.languages and code not in self.languages:
|
| 52 |
-
known = ", ".join(self.languages)
|
| 53 |
-
raise ValueError(f"{self.model} was trained on {known}, not {language!r}")
|
| 54 |
-
return self.transcriber.transcribe(audio, language=code, decoder=decoder, **kw)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
canopy/inference/benchmark.py
DELETED
|
@@ -1,64 +0,0 @@
|
|
| 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/cli.py
DELETED
|
@@ -1,36 +0,0 @@
|
|
| 1 |
-
"""Transcribe with any published Canopy checkpoint.
|
| 2 |
-
|
| 3 |
-
GitHub install (one package, every variant):
|
| 4 |
-
|
| 5 |
-
pip install torch torchaudio soundfile numpy pyyaml huggingface_hub
|
| 6 |
-
pip install "git+https://github.com/mwzkhalil/canopy.git@danish#subdirectory=canopy"
|
| 7 |
-
hf auth login
|
| 8 |
-
canopy-transcribe clip.wav --model canopy-m --language urd
|
| 9 |
-
canopy-transcribe clip.wav --model ProximaAI/Canopy-M --language snd
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
from __future__ import annotations
|
| 13 |
-
|
| 14 |
-
import argparse
|
| 15 |
-
|
| 16 |
-
from canopy.inference.api import LANGUAGE_ALIASES, Canopy
|
| 17 |
-
from canopy.inference.hub import VARIANTS
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def main() -> None:
|
| 21 |
-
names = ", ".join(sorted(VARIANTS))
|
| 22 |
-
ap = argparse.ArgumentParser(description="Transcribe with a Canopy checkpoint")
|
| 23 |
-
ap.add_argument("audio", nargs="+", help="wav, flac, or mp3 paths")
|
| 24 |
-
ap.add_argument("--model", default="canopy-m", help=f"variant name ({names}) or org/repo id")
|
| 25 |
-
ap.add_argument("--filename", default=None, help="checkpoint filename when a repo has several")
|
| 26 |
-
ap.add_argument("--language", required=True, help="ISO code, for example urd or snd")
|
| 27 |
-
ap.add_argument("--device", default=None, help="cpu, cuda, or omit for auto")
|
| 28 |
-
ap.add_argument("--decoder", choices=["greedy", "beam"], default="greedy")
|
| 29 |
-
args = ap.parse_args()
|
| 30 |
-
asr = Canopy.from_pretrained(args.model, filename=args.filename, device=args.device)
|
| 31 |
-
for path in args.audio:
|
| 32 |
-
text = asr.transcribe(path, language=args.language, decoder=args.decoder)
|
| 33 |
-
print(f"{path}\t{text}")
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
__all__ = ["LANGUAGE_ALIASES", "main"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
canopy/inference/hub.py
DELETED
|
@@ -1,54 +0,0 @@
|
|
| 1 |
-
"""Resolve a Canopy variant to a Hugging Face repo and checkpoint file.
|
| 2 |
-
|
| 3 |
-
A variant is either a short name from ``VARIANTS`` or a full ``org/repo`` id.
|
| 4 |
-
Add a line to ``VARIANTS`` when a new checkpoint is published. Passing
|
| 5 |
-
``org/repo`` directly works before that line exists.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from __future__ import annotations
|
| 9 |
-
|
| 10 |
-
from pathlib import Path
|
| 11 |
-
|
| 12 |
-
# Short name -> Hub repo. Weights stay on the Hub; this table is only the address.
|
| 13 |
-
VARIANTS: dict[str, str] = {
|
| 14 |
-
"canopy-m": "ProximaAI/Canopy-M",
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def resolve_repo(model: str) -> str:
|
| 19 |
-
"""Return an ``org/repo`` id. Local checkpoint paths are not repos."""
|
| 20 |
-
key = model.strip()
|
| 21 |
-
if not key:
|
| 22 |
-
raise ValueError("model name is empty")
|
| 23 |
-
if key.lower() in VARIANTS:
|
| 24 |
-
return VARIANTS[key.lower()]
|
| 25 |
-
if "/" in key and not Path(key).exists():
|
| 26 |
-
return key
|
| 27 |
-
known = ", ".join(sorted(VARIANTS))
|
| 28 |
-
raise ValueError(
|
| 29 |
-
f"unknown Canopy variant {model!r}. Use one of: {known}. "
|
| 30 |
-
"Or pass a Hub id such as ProximaAI/Canopy-M."
|
| 31 |
-
)
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def checkpoint_name(repo_id: str) -> str:
|
| 35 |
-
"""Default weight file: the repo name, lowercased, with a .pt suffix."""
|
| 36 |
-
name = repo_id.split("/")[-1].strip().lower()
|
| 37 |
-
return name if name.endswith(".pt") else f"{name}.pt"
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def pick_checkpoint(repo_id: str, files: list[str], filename: str | None = None) -> str:
|
| 41 |
-
"""Choose one ``.pt`` at the repo root. ``filename`` wins when it is present."""
|
| 42 |
-
root = [f for f in files if "/" not in f and f.endswith(".pt")]
|
| 43 |
-
if filename:
|
| 44 |
-
if filename not in files and filename not in root:
|
| 45 |
-
raise FileNotFoundError(f"{repo_id} has no file {filename!r}. Root checkpoints: {root or 'none'}")
|
| 46 |
-
return filename
|
| 47 |
-
preferred = checkpoint_name(repo_id)
|
| 48 |
-
if preferred in root:
|
| 49 |
-
return preferred
|
| 50 |
-
if len(root) == 1:
|
| 51 |
-
return root[0]
|
| 52 |
-
raise FileNotFoundError(
|
| 53 |
-
f"{repo_id} has {len(root)} checkpoint files {root}. Pass filename= to choose one."
|
| 54 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
canopy/inference/quantize.py
DELETED
|
@@ -1,36 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,109 +0,0 @@
|
|
| 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 | None = None,
|
| 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 |
-
|
| 57 |
-
``repo_id`` is an ``org/name`` id. ``filename`` defaults to the single
|
| 58 |
-
``.pt`` at the repo root, preferring ``<repo-name>.pt``.
|
| 59 |
-
"""
|
| 60 |
-
try:
|
| 61 |
-
from huggingface_hub import hf_hub_download, list_repo_files
|
| 62 |
-
except ImportError as exc:
|
| 63 |
-
raise ImportError("pip install huggingface_hub") from exc
|
| 64 |
-
from canopy.inference.hub import pick_checkpoint
|
| 65 |
-
|
| 66 |
-
files = list_repo_files(repo_id, revision=revision, token=token)
|
| 67 |
-
chosen = pick_checkpoint(repo_id, files, filename)
|
| 68 |
-
path = hf_hub_download(repo_id=repo_id, filename=chosen, revision=revision, token=token)
|
| 69 |
-
return cls.from_checkpoint(path, device=device, **kw)
|
| 70 |
-
|
| 71 |
-
def attach_lm(self, language: str, lm) -> None:
|
| 72 |
-
"""``lm``: ArpaLM or KenLMScorer. Use language "*" for a language-agnostic LM."""
|
| 73 |
-
self.lms[language] = lm
|
| 74 |
-
|
| 75 |
-
@torch.no_grad()
|
| 76 |
-
def log_probs(self, wav: torch.Tensor, language: str | None = None) -> torch.Tensor:
|
| 77 |
-
lid = None
|
| 78 |
-
if language is not None and self.model.lang_embed is not None:
|
| 79 |
-
lid = torch.tensor([self.lang2id.get(language, 0)], device=self.device)
|
| 80 |
-
chunks = [wav[i : i + self.max_chunk] for i in range(0, max(1, wav.numel()), self.max_chunk)]
|
| 81 |
-
outs = []
|
| 82 |
-
for c in chunks:
|
| 83 |
-
if c.numel() < 400:
|
| 84 |
-
continue
|
| 85 |
-
x = c.unsqueeze(0).to(self.device)
|
| 86 |
-
res = self.model(x, torch.tensor([c.numel()], device=self.device), lid)
|
| 87 |
-
outs.append(res["log_probs"][0, : int(res["lengths"][0])])
|
| 88 |
-
if not outs:
|
| 89 |
-
return torch.zeros(0, self.tokenizer.vocab_size)
|
| 90 |
-
return torch.cat(outs, dim=0)
|
| 91 |
-
|
| 92 |
-
def transcribe(self, audio, language: str | None = None, decoder: str = "greedy", beam: int = 10,
|
| 93 |
-
alpha: float = 0.5, beta: float = 1.0) -> str:
|
| 94 |
-
wav = load_audio(audio) if isinstance(audio, (str, Path)) else torch.as_tensor(audio, dtype=torch.float32)
|
| 95 |
-
lp = self.log_probs(wav, language)
|
| 96 |
-
if lp.shape[0] == 0:
|
| 97 |
-
return ""
|
| 98 |
-
bias = language_mask_bias(self.tokenizer, [language], lp.shape[-1], device=lp.device) if language else None
|
| 99 |
-
if decoder == "greedy":
|
| 100 |
-
ids = greedy_decode(lp.unsqueeze(0), torch.tensor([lp.shape[0]]), bias)[0]
|
| 101 |
-
elif decoder == "beam":
|
| 102 |
-
labels = self.tokenizer.labels_for_pyctcdecode()
|
| 103 |
-
allowed = np.isfinite(bias[0].cpu().numpy()) if bias is not None else None
|
| 104 |
-
lm = self.lms.get(language or "*") or self.lms.get("*")
|
| 105 |
-
ids = prefix_beam_search(lp.float().cpu().numpy(), [""] + labels[1:], beam=beam, lm=lm, alpha=alpha,
|
| 106 |
-
beta=beta, allowed=allowed)
|
| 107 |
-
else:
|
| 108 |
-
raise ValueError(f"unknown decoder {decoder!r}")
|
| 109 |
-
return self.tokenizer.decode(ids)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
canopy/language_model/__init__.py
DELETED
|
@@ -1,4 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,188 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,62 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,92 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,4 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,107 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,58 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,183 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
from canopy.text.normalize import Normalizer, get_normalizer
|
| 2 |
-
|
| 3 |
-
__all__ = ["Normalizer", "get_normalizer"]
|
|
|
|
|
|
|
|
|
|
|
|
canopy/text/normalize.py
DELETED
|
@@ -1,110 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,88 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,5 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,96 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,76 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,95 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,111 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
from canopy.training.schedule import WarmupCosine
|
| 2 |
-
|
| 3 |
-
__all__ = ["WarmupCosine"]
|
|
|
|
|
|
|
|
|
|
|
|
canopy/training/checkpoint.py
DELETED
|
@@ -1,77 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,30 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,48 +0,0 @@
|
|
| 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")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|