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()
|