File size: 10,135 Bytes
38c1604
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
"""
Evaluate on real phone recordings: ordinary people reciting into the Tarteel app (tarteel-ai/tlog).

    python eval_tlog.py                    # builds the test set once, then scores the model + NVIDIA
    python eval_tlog.py --clips 300        # a bigger test set (rebuilds it)
    python eval_tlog.py --onnx runs/full/export/model.int8.onnx   # the exported model, as the phone runs it

Training never uses these shards (download.py skips them), so every clip is unseen. The test set (data/tlog_test/) is drawn from 60
shards spread over the dataset (the shards are sorted by surah), with at most one clip per ayah,
6 per surah and 5 per shard, 1.5-30 s long. tlog's labels come from the file names and aren't
checked, so a clip that neither model can read (likely a wrong label or a non-recitation) is
reported separately. Needs `hf auth login` and the dataset's terms accepted on its page.
The scores use the leaderboard's kind of normalization (no diacritics, folded alef/ya/ta marbuta).
"""
import argparse
import io
import json
import os
import random
import sys
from types import SimpleNamespace

import numpy as np
import soundfile as sf
import torch

from quran_asr import audio, config as config_mod, model as model_mod, quran, text

HERE = os.path.dirname(os.path.abspath(__file__))
REPO = "datasets/tarteel-ai/tlog/data/clean-{:05d}-of-00411.parquet"
SHARDS = 411


def test_shards(seed=0):
    """The 60 shards the test set is drawn from (download.py never trains on them)."""
    rnd = random.Random(seed)
    return sorted({int(i * SHARDS / 60 + rnd.random() * SHARDS / 60) for i in range(60)}), rnd


def build(out, clips, seed):
    import pyarrow.parquet as pq
    from huggingface_hub import HfFileSystem

    fs = HfFileSystem()
    shards, rnd = test_shards(seed)
    candidates = []
    for k, shard in enumerate(shards, 1):
        print(f"  reading shard {shard} ({k}/{len(shards)})", flush=True)
        f = pq.ParquetFile(fs.open(REPO.format(shard), block_size=1 << 22))
        group = rnd.randrange(f.metadata.num_row_groups)
        for row in f.read_row_group(group, columns=["audio", "label"]).to_pylist():
            name = os.path.basename(row["audio"]["path"])
            try:
                sura, aya = (int(v) for v in name.split("_")[:2])
                wave, sr = sf.read(io.BytesIO(row["audio"]["bytes"]), dtype="float32", always_2d=True)
            except Exception:
                continue
            wave = audio.to_mono(wave)
            duration, rms = len(wave) / sr, float(np.sqrt(np.mean(wave ** 2)) if len(wave) else 0)
            label = text.normalize(row["label"] or "")
            if 1.5 <= duration <= 30 and rms > 1e-3 and label:
                candidates.append(dict(file=name, bytes=row["audio"]["bytes"], sura=sura, aya=aya, text=label,
                                       duration=round(duration, 2), shard=shard))
    rnd.shuffle(candidates)
    picked, per_sura, per_shard, ayahs = [], {}, {}, set()
    for c in candidates:
        if len(picked) >= clips or (c["sura"], c["aya"]) in ayahs:
            continue
        if per_sura.get(c["sura"], 0) >= 6 or per_shard.get(c["shard"], 0) >= 5:
            continue
        picked.append(c)
        ayahs.add((c["sura"], c["aya"]))
        per_sura[c["sura"]] = per_sura.get(c["sura"], 0) + 1
        per_shard[c["shard"]] = per_shard.get(c["shard"], 0) + 1
    os.makedirs(out, exist_ok=True)
    with open(os.path.join(out, "manifest.jsonl"), "w", encoding="utf-8", newline="\n") as m:
        for c in picked:
            with open(os.path.join(out, c["file"]), "wb") as f:
                f.write(c.pop("bytes"))
            m.write(json.dumps(c, ensure_ascii=False) + "\n")
    return picked


def describe(clips):
    d = sorted(c["duration"] for c in clips)
    suras = {c["sura"] for c in clips}
    lps = sorted(len(c["text"].replace(" ", "")) / c["duration"] for c in clips)
    print(f"test set: {len(clips)} clips, {sum(d) / 60:.1f} min, {len(suras)} surahs, "
          f"{len({c['shard'] for c in clips})} shards; duration median {d[len(d) // 2]:.1f} s "
          f"(10%: {d[len(d) // 10]:.1f} s, 90%: {d[9 * len(d) // 10]:.1f} s); "
          f"letters/s median {lps[len(lps) // 2]:.1f} (90%: {lps[9 * len(lps) // 10]:.1f}); "
          f"al-Fatiha {sum(c['sura'] == 1 for c in clips)}")


def join_tanween(s):
    """Our letters-only text splits a tanween alif off its word ("مرض ا"): rejoin it for scoring."""
    out = []
    for w in s.split():
        if w == "ا" and out:
            out[-1] += w
        else:
            out.append(w)
    return " ".join(out)


def scores(pairs):
    """Corpus-level letter / word / alef-insensitive word / skeleton letter error rates, in %."""
    tot = np.zeros(8)
    for hyp, ref in pairs:
        hyp, ref = join_tanween(text.normalize(hyp)), join_tanween(ref)
        ce, ct, we, wt, se, st = text.error_counts(hyp, ref)
        ha, ra = hyp.replace("ا", "").split(), ref.replace("ا", "").split()
        tot += [ce, ct, we, wt, text.edit_distance(ha, ra), len(ra), se, st]
    return {"letters": 100 * tot[0] / tot[1], "words": 100 * tot[2] / tot[3],
            "words_no_alef": 100 * tot[4] / tot[5], "skeleton": 100 * tot[6] / tot[7]}


def skeleton_error(hyp, ref):
    c = text.error_counts(join_tanween(text.normalize(hyp)), join_tanween(ref))
    return c[4] / max(1, c[5])


def our_model(cfg, args):
    if args.onnx:
        import sherpa_onnx

        rec = sherpa_onnx.OfflineRecognizer.from_zipformer_ctc(
            model=args.onnx, tokens=os.path.join(os.path.dirname(args.onnx), "tokens.txt"), num_threads=4)

        def run(wave):
            s = rec.create_stream()
            s.accept_waveform(audio.SAMPLE_RATE, wave)
            rec.decode_stream(s)
            return s.result.text.replace(text.WORD, " ")
        return f"ours (sherpa-onnx, {os.path.basename(args.onnx)})", run

    import export
    from quran_asr import features

    state, model_config = export.load_averaged(cfg.paths.run_dir, args.average)
    model = model_mod.build(SimpleNamespace(**model_config), len(text.VOCAB))
    model.load_state_dict(state)
    model.eval()

    def run(wave):
        f = features.fbank(torch.from_numpy(np.ascontiguousarray(wave)))
        with torch.no_grad():
            lp, lens = model(f[None], torch.tensor([f.shape[0]]))
        return text.decode(model_mod.greedy_decode(lp, lens)[0])
    if args.average <= 1:
        step = torch.load(os.path.join(cfg.paths.run_dir, "best.pt"), map_location="cpu", weights_only=False).get("step")
        return f"ours (best.pt, step {step})", run
    return f"ours (average of last {args.average} checkpoints)", run


def nvidia(cfg):
    import sherpa_onnx
    import prepare

    model, tokens = prepare._nvidia_model(cfg)
    rec = sherpa_onnx.OfflineRecognizer.from_nemo_ctc(model=model, tokens=tokens, num_threads=4)

    def run(wave):
        s = rec.create_stream()
        s.accept_waveform(audio.SAMPLE_RATE, wave)
        rec.decode_stream(s)
        return s.result.text
    return "NVIDIA FastConformer (the app's old model)", run


def main():
    parser = config_mod.from_args(__doc__)
    parser.set_defaults(config=os.path.join(HERE, "config", "full.yaml"))
    parser.add_argument("--clips", type=int, help="size of a new test set (default: keep the existing one, else 200)")
    parser.add_argument("--seed", type=int, default=0)
    parser.add_argument("--average", type=int, default=6, help="average the last N checkpoints")
    parser.add_argument("--onnx", help="score an exported model.int8.onnx through sherpa-onnx instead")
    parser.add_argument("--no-baseline", action="store_true", help="skip the NVIDIA model")
    args = parser.parse_args()
    sys.stdout.reconfigure(encoding="utf-8")
    torch.set_num_threads(4)  # leave the rest of the CPU to a running training
    cfg = config_mod.load(args.config)

    out = os.path.join(cfg.paths.data_dir, "tlog_test")
    manifest = os.path.join(out, "manifest.jsonl")
    clips = []
    if os.path.exists(manifest):
        clips = [json.loads(l) for l in open(manifest, encoding="utf-8")]
    if not clips or args.clips not in (None, len(clips)):
        args.clips = args.clips or 200
        print(f"building a {args.clips}-clip test set from tarteel-ai/tlog...")
        clips = build(out, args.clips, args.seed)
    describe(clips)

    systems = [our_model(cfg, args)] + ([] if args.no_baseline else [nvidia(cfg)])
    heard = {}
    for name, run in systems:
        print(f"transcribing with {name}...", flush=True)
        heard[name] = [run(audio.load(os.path.join(out, c["file"]))) for c in clips]

    # Clips no system can read are most likely mislabelled (tlog's labels are unchecked).
    unreadable = [i for i in range(len(clips))
                  if all(skeleton_error(heard[n][i], clips[i]["text"]) > 0.5 for n in heard)]
    keep = [i for i in range(len(clips)) if i not in set(unreadable)]
    print(f"\n{len(unreadable)} clips no model can read (probably a wrong label or not a recitation), "
          f"scored separately")
    print(f"{'':48s} {'letters':>8s} {'words':>8s} {'words*':>8s} {'skeleton':>9s}")
    for subset, idx in ((f"{len(keep)} readable clips", keep), (f"all {len(clips)} clips", range(len(clips)))):
        print(subset)
        for name in heard:
            s = scores([(heard[name][i], clips[i]["text"]) for i in idx])
            print(f"  {name:46s} {s['letters']:7.2f}% {s['words']:7.2f}% {s['words_no_alef']:7.2f}% {s['skeleton']:8.2f}%")
    print("words* = word error ignoring alef (as the leaderboard's alef-insensitive WER); skeleton = letters "
          "without alif and hamza (fair across spelling conventions: NVIDIA writes modern spelling)")

    ours = list(heard)[0]
    worst = sorted(keep, key=lambda i: -skeleton_error(heard[ours][i], clips[i]["text"]))[:5]
    print("\nhardest readable clips for our model:")
    for i in worst:
        c = clips[i]
        print(f"  {c['sura']}:{c['aya']} ({c['duration']} s)\n    label: {c['text'][:90]}\n    heard: {heard[ours][i][:90]}")


if __name__ == "__main__":
    main()