mahwizzzz commited on
Commit
f3d902c
·
verified ·
1 Parent(s): 3e4162a

Remove the vendored canopy package

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +0 -9
  2. canopy/__init__.py +0 -6
  3. canopy/data/__init__.py +0 -3
  4. canopy/data/commonvoice.py +0 -113
  5. canopy/data/dataset.py +0 -104
  6. canopy/data/generic.py +0 -75
  7. canopy/data/manifest.py +0 -124
  8. canopy/data/sampler.py +0 -128
  9. canopy/data/splits.py +0 -188
  10. canopy/data/validation.py +0 -193
  11. canopy/decoding/__init__.py +0 -4
  12. canopy/decoding/beam.py +0 -132
  13. canopy/decoding/greedy.py +0 -43
  14. canopy/evaluation/__init__.py +0 -3
  15. canopy/evaluation/metrics.py +0 -135
  16. canopy/evaluation/runner.py +0 -98
  17. canopy/evaluation/status.py +0 -79
  18. canopy/frontend/__init__.py +0 -5
  19. canopy/frontend/audio.py +0 -69
  20. canopy/frontend/augment.py +0 -56
  21. canopy/frontend/features.py +0 -111
  22. canopy/inference/__init__.py +0 -4
  23. canopy/inference/__main__.py +0 -3
  24. canopy/inference/api.py +0 -54
  25. canopy/inference/benchmark.py +0 -64
  26. canopy/inference/cli.py +0 -36
  27. canopy/inference/hub.py +0 -54
  28. canopy/inference/quantize.py +0 -36
  29. canopy/inference/transcriber.py +0 -109
  30. canopy/language_model/__init__.py +0 -4
  31. canopy/language_model/arpa.py +0 -188
  32. canopy/language_model/kenlm_backend.py +0 -62
  33. canopy/language_model/text.py +0 -38
  34. canopy/languages.py +0 -92
  35. canopy/models/__init__.py +0 -4
  36. canopy/models/canopy_ctc.py +0 -107
  37. canopy/models/config.py +0 -58
  38. canopy/models/modules.py +0 -183
  39. canopy/text/__init__.py +0 -3
  40. canopy/text/normalize.py +0 -110
  41. canopy/text/rules.py +0 -88
  42. canopy/tokenizer/__init__.py +0 -5
  43. canopy/tokenizer/base.py +0 -96
  44. canopy/tokenizer/char.py +0 -76
  45. canopy/tokenizer/diagnostics.py +0 -95
  46. canopy/tokenizer/spm.py +0 -111
  47. canopy/training/__init__.py +0 -3
  48. canopy/training/checkpoint.py +0 -77
  49. canopy/training/losses.py +0 -30
  50. 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")