File size: 11,039 Bytes
e4c97d2 | 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 | """Fine-tune Meta omniASR CTC (default omniASR_CTC_1B_v2, Apache-2.0) on Nigerian speech (+ optional synthetic code-switch).
Full fine-tune (CNN feature extractor frozen), bf16 autocast, AdamW, warmup + linear decay, duration-bucketed batches.
The model stays a plain CTC wav2vec2 (same architecture/tokenizer), so the duplex asr_server streams it unchanged.
~/venv_omni/bin/python train_asr.py --run /scratch/asr/runs/A --steps 4000 [--synth /scratch/asr/synth/manifest.jsonl --synth_frac 0.2]
"""
import argparse, json, math, os, random, time, re, unicodedata
import numpy as np, torch, torch.nn.functional as F
import soundfile # noqa: F401
from scipy.signal import resample_poly, fftconvolve
from fairseq2.models.hub import load_model
from fairseq2.data.tokenizers.hub import load_tokenizer
from fairseq2.nn.batch_layout import BatchLayout
ap = argparse.ArgumentParser()
ap.add_argument("--card", default="omniASR_CTC_1B_v2"); ap.add_argument("--run", required=True)
ap.add_argument("--data", default="/scratch/asr/data"); ap.add_argument("--synth", default="")
ap.add_argument("--synth_frac", type=float, default=0.2); ap.add_argument("--steps", type=int, default=4000)
ap.add_argument("--lr", type=float, default=2e-5); ap.add_argument("--warmup", type=int, default=300)
ap.add_argument("--batch_sec", type=float, default=280.0); ap.add_argument("--max_sec", type=float, default=20.0)
ap.add_argument("--real_aug_p", type=float, default=0.15); ap.add_argument("--synth_aug_p", type=float, default=0.8)
ap.add_argument("--val_every", type=int, default=500); ap.add_argument("--seed", type=int, default=1)
ap.add_argument("--workers", type=int, default=10); ap.add_argument("--smoke", action="store_true")
a = ap.parse_args()
os.makedirs(a.run, exist_ok=True); json.dump(vars(a), open(f"{a.run}/args.json", "w"), indent=1)
random.seed(a.seed); np.random.seed(a.seed); torch.manual_seed(a.seed)
dev = torch.device("cuda")
if os.environ.get("MEM_FRAC"): torch.cuda.set_per_process_memory_fraction(float(os.environ["MEM_FRAC"]))
model = load_model(a.card, device=dev, dtype=torch.float32)
tok = load_tokenizer(a.card); enc = tok.create_encoder(); dec = tok.create_decoder(skip_special_tokens=True)
VI = tok.vocab_info; BLANK = 0; PAD = VI.pad_idx if VI.pad_idx is not None else 1
print("vocab", VI.size, "pad", VI.pad_idx, "unk", VI.unk_idx, "bos", VI.bos_idx, "eos", VI.eos_idx, flush=True)
SPECIAL = {i for i in (VI.pad_idx, VI.bos_idx, VI.eos_idx) if i is not None}
def encode(t):
ids = [int(x) for x in enc(t).tolist()]
return [i for i in ids if i not in SPECIAL]
def rows(p):
return [json.loads(l) for l in open(p)]
train = rows(f"{a.data}/train.jsonl"); devset = rows(f"{a.data}/dev.jsonl")
synth = rows(a.synth) if a.synth else []
for r in train: r["kind"] = "real"
for r in synth: r["kind"] = "synth"; r.setdefault("w", 1.0)
unk = VI.unk_idx
def ok(r):
ids = encode(r["text"]); r["n_ids"] = len(ids)
return len(ids) > 0 and (unk is None or unk not in ids) and r["dur"] <= a.max_sec
n0 = len(train); train = [r for r in train if ok(r)]; synth = [r for r in synth if ok(r)]
print(f"train {len(train)}/{n0} after tokenizer filter, synth {len(synth)}", flush=True)
_mm = {}
def load_audio(r):
if r.get("wav"):
y, sr = soundfile.read(r["wav"], dtype="float32")
if y.ndim > 1: y = y.mean(1)
if sr != 16000: y = resample_poly(y, 16000, sr).astype(np.float32)
return y
if r["pcm"] not in _mm: _mm[r["pcm"]] = np.memmap(r["pcm"], dtype=np.int16, mode="r")
y = _mm[r["pcm"]][r["off24"]: r["off24"] + r["n24"]].astype(np.float32) / 32768
return resample_poly(y, 2, 3).astype(np.float32)
def augment(y, rng):
"""noise (white/pink/babble-ish), simple synthetic reverb, phone band-limit, gain; ~ mic/channel variety"""
if rng.random() < 0.5: # reverb: exponentially decaying noise RIR, RT60 0.15-0.7 s
rt = rng.uniform(0.15, 0.7); n = int(16000 * rt); t = np.arange(n) / 16000
rir = rng.standard_normal(n) * np.exp(-6.9 * t / rt); rir[0] = 1.0; rir /= np.sqrt((rir ** 2).sum())
y = fftconvolve(y, rir)[: len(y)].astype(np.float32)
if rng.random() < 0.35: # telephone / cheap mic channel
y = resample_poly(resample_poly(y, 1, 2), 2, 1)[: len(y)].astype(np.float32) if rng.random() < 0.5 else \
resample_poly(resample_poly(y, 8, 16), 16, 8)[: len(y)].astype(np.float32)
if rng.random() < 0.5: y = np.tanh(y * rng.uniform(1.5, 4.0)).astype(np.float32)
if rng.random() < 0.7: # additive noise at SNR 5-30 dB
n = rng.standard_normal(len(y)).astype(np.float32)
if rng.random() < 0.5: n = np.cumsum(n); n -= np.convolve(n, np.ones(400) / 400, "same") # brownish
snr = rng.uniform(5, 30); ps = (y ** 2).mean() + 1e-8; pn = (n ** 2).mean() + 1e-8
y = y + n * np.sqrt(ps / (pn * 10 ** (snr / 10)))
y = y * rng.uniform(0.3, 1.5)
return np.clip(y, -1, 1).astype(np.float32)
class DS(torch.utils.data.Dataset):
def __init__(self, items, train_mode): self.items, self.tm = items, train_mode
def __len__(self): return len(self.items)
def __getitem__(self, i):
r = self.items[i]; y = load_audio(r)
if self.tm:
rng = np.random.default_rng((i * 7919 + int(time.time() * 1000)) % 2 ** 32)
p = a.synth_aug_p if r["kind"] == "synth" else a.real_aug_p
if rng.random() < p: y = augment(y, rng)
return torch.from_numpy(y), torch.tensor(encode(r["text"])), i
def epoch_order(rng):
"""weighted sampling (source weights) + synth mixed at synth_frac of audio hours; bucketed by duration"""
pick = []
for r in train:
k = r["w"]; n = int(k) + (1 if rng.random() < k - int(k) else 0); pick += [r] * n
if synth:
real_h = sum(r["dur"] for r in pick); want = real_h * a.synth_frac / (1 - a.synth_frac); have = sum(r["dur"] for r in synth)
rep = want / have; s = []
for r in synth:
n = int(rep) + (1 if rng.random() < rep - int(rep) else 0); s += [r] * n
pick += s
rng.shuffle(pick)
batches = []
for c in range(0, len(pick), 4000): # sort within chunks of 4000 to bucket by duration
ch = sorted(pick[c: c + 4000], key=lambda r: r["dur"]); cur, cs = [], 0.0
for r in ch:
if cs + r["dur"] > a.batch_sec and cur: batches.append(cur); cur, cs = [], 0.0
cur.append(r); cs += r["dur"]
if cur: batches.append(cur)
rng.shuffle(batches); return batches
def collate(b):
ys = [F.layer_norm(x, x.shape) for x, _, _ in b]; L = torch.tensor([len(x) for x in ys])
X = torch.zeros(len(ys), int(L.max())); [X[i, : len(x)].copy_(x) for i, x in enumerate(ys)]
T = [t for _, t, _ in b]; TL = torch.tensor([len(t) for t in T]); Y = torch.cat(T)
return X, L, Y, TL
def forward(X, L):
bl = BatchLayout(X.shape, seq_lens=L.tolist(), device=dev)
logits, blo = model(X, bl)
return logits, torch.tensor(blo.seq_lens, device=dev)
for p in model.encoder_frontend.feature_extractor.parameters(): p.requires_grad = False
params = [p for p in model.parameters() if p.requires_grad]
print("trainable params %.1fM" % (sum(p.numel() for p in params) / 1e6), flush=True)
opt = torch.optim.AdamW(params, lr=a.lr, betas=(0.9, 0.98), eps=1e-8, weight_decay=0.01)
sched = torch.optim.lr_scheduler.LambdaLR(opt, lambda s: min(1.0, (s + 1) / a.warmup) * max(0.02, 1 - max(0, s - a.warmup) / max(1, a.steps - a.warmup)))
def cer_eval(items, bs=16):
model.eval(); hyps = []
with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
for i in range(0, len(items), bs):
b = [(torch.from_numpy(load_audio(r)), torch.tensor([1]), 0) for r in items[i: i + bs]]
X, L, _, _ = collate(b); lg, ol = forward(X.to(dev), L)
pr = lg.argmax(-1)
for j in range(pr.shape[0]):
s = pr[j, : ol[j]]; m = torch.ones_like(s, dtype=torch.bool); m[1:] = s[1:] != s[:-1]; s = s[m]; s = s[s != BLANK]
hyps.append(str(dec(s)))
model.train()
import jiwer
def n(x): x = unicodedata.normalize("NFC", x.lower()); x = re.sub(r"[^\w\s']", " ", x); return re.sub(r"\s+", " ", x).strip() or "x"
out = {}
for lg in sorted({r["lang"] for r in items}):
idx = [k for k, r in enumerate(items) if r["lang"] == lg]
out[lg] = round(jiwer.cer([n(items[k]["text"]) for k in idx], [n(hyps[k]) for k in idx]), 4)
out["mean"] = round(float(np.mean([v for k, v in out.items()])), 4)
return out, hyps
log = open(f"{a.run}/log.jsonl", "a")
v0, _ = cer_eval(devset); print("dev step0", v0, flush=True); log.write(json.dumps(dict(step=0, dev=v0)) + "\n"); log.flush()
best = v0["mean"]; step = 0; rng = random.Random(a.seed); model.train(); t0 = time.time(); ema = None
steps_total = 60 if a.smoke else a.steps
while step < steps_total:
batches = epoch_order(rng); print(f"epoch with {len(batches)} batches", flush=True)
lut = {}
flat = []
for b in batches:
flat.append([len(lut) + k for k in range(len(b))]); [lut.setdefault(len(lut), r) for r in b]
items = [lut[k] for k in range(len(lut))]
dl = torch.utils.data.DataLoader(DS(items, True), batch_sampler=flat, num_workers=a.workers, collate_fn=collate,
prefetch_factor=4, persistent_workers=False)
for X, L, Y, TL in dl:
X, Y = X.to(dev, non_blocking=True), Y.to(dev)
with torch.autocast("cuda", dtype=torch.bfloat16):
lg, ol = forward(X, L)
lp = F.log_softmax(lg.float(), -1).transpose(0, 1)
loss = F.ctc_loss(lp, Y, ol, TL.to(dev), blank=BLANK, reduction="sum", zero_infinity=True) / TL.sum()
opt.zero_grad(set_to_none=True); loss.backward()
gn = torch.nn.utils.clip_grad_norm_(params, 5.0)
if torch.isfinite(gn): opt.step()
sched.step(); step += 1
ema = loss.item() if ema is None else 0.98 * ema + 0.02 * loss.item()
if step % 25 == 0:
el = time.time() - t0
print(f"step {step} loss {ema:.4f} gn {gn:.2f} lr {sched.get_last_lr()[0]:.2e} {el / step:.2f}s/step mem {torch.cuda.max_memory_allocated() / 2**30:.1f}G", flush=True)
log.write(json.dumps(dict(step=step, loss=round(ema, 4), sps=round(el / step, 3))) + "\n"); log.flush()
if step % a.val_every == 0 or step == steps_total:
v, _ = cer_eval(devset); print("dev", step, v, flush=True); log.write(json.dumps(dict(step=step, dev=v)) + "\n"); log.flush()
sd = {k: t.to(torch.bfloat16) for k, t in model.state_dict().items()}
if v["mean"] < best and not a.smoke:
best = v["mean"]; torch.save(dict(model=sd, step=step, dev=v, card=a.card), f"{a.run}/best.pt"); print("saved best", flush=True)
if step >= steps_total: break
if not a.smoke:
torch.save(dict(model={k: t.to(torch.bfloat16) for k, t in model.state_dict().items()}, step=step, card=a.card), f"{a.run}/last.pt")
print("TRAIN_DONE best", best, flush=True)
|