zipformer-rnnt-v3
Vietnamese Zipformer2 transducer (RNNT) fine-tuned for Actable meeting audio, starting from
actableai/zipformer-rnnt-v2 and trained on
actableai/vi-meeting-soniox-wer16
with Soniox transcripts as golden labels.
|
|
| Base |
actableai/zipformer-rnnt-v2 (policy-domain Zipformer, BPE-3000) |
| Fine-tune data |
Meeting segments with WER(Soniox, baseline Qwen) β€ 16% |
| Labels |
Soniox text only (mixed-case; not uppercased) |
| Params |
~70.7 M (transducer; no CTC head in this fine-tune) |
| Sample rate |
16 kHz, 80-dim log-Mel fbank (on-the-fly) |
Performance
Meeting dev (Soniox references)
Eval set: session_soniox_dev.jsonl β 5,453 utterances, ~13.8 h
Decoding: greedy search, icefall greedy_search_batch
Text norm for WER: lowercase, strip punctuation / bracket tags, collapse whitespace
Checkpoint: best-valid-loss.pt using model_avg weights
| Model |
Meeting-dev WER |
Errors / words |
v2 baseline (actableai/zipformer-rnnt-v2) |
31.07% |
56,369 / 181,450 |
| v3 (this model) |
20.71% |
37,580 / 181,450 |
| Ξ absolute |
β10.36 |
β |
| Ξ relative |
β33.3% |
β |
Measured 2026-07-27 with eval_zipformer_meeting_wer16.py (full meeting dev).
Validation RNNT loss (during fine-tune)
Loss on meeting dev CutSet at the start of each epoch (icefall validation):
| Epoch |
Valid loss |
Simple |
Pruned |
| 1 |
0.4787 |
0.4529 |
0.2577 |
| 2 |
0.3846 |
0.3782 |
0.1805 |
| 3 |
0.3605 |
0.3684 |
0.1725 |
| 4 |
0.3478 |
0.3633 |
0.1662 |
| 5 |
0.3433 |
0.3604 |
0.1631 |
| 6 |
0.3416 |
0.3589 |
0.1622 |
| 7 |
0.3398 |
0.3578 |
0.1609 |
| 8 |
0.3385 |
0.3565 |
0.1603 |
| 9 |
0.3369 |
0.3558 |
0.1590 |
| 10 (best) |
0.3362 |
0.3552 |
0.1586 |
- Best valid loss: 0.3362 @ epoch 10
- Best train loss: 0.2452 @ epoch 10
- Wall time: ~1 h 38 m (single NVIDIA A100 40GB)
Training details
Data
| Split |
Segments |
Hours |
Source |
| train |
22,693 |
~68.1 |
actableai/vi-meeting-soniox-wer16 (= local session_soniox_train_filtered.jsonl) |
| dev |
5,453 |
~13.8 |
session_soniox_dev.jsonl (Soniox labels) |
- Filter: keep train segments where WER(Soniox ref, baseline Qwen hyp) β€ 16%
- Labels: Soniox field
text only β never Qwen hypotheses
- Audio: 16 kHz mono session WAVs (
full_session.wav + start/end offsets)
- Mix / replay: none (
use_mux=0) β pure meeting wer16
- Utt duration filter: 0.3β30 s
Optimization
| Knob |
Value |
| Recipe |
icefall Zipformer2 finetune.py |
| Init |
load encoder,encoder_embed,decoder,joiner,simple_am_proj,simple_lm_proj from v2 |
| CTC |
disabled (use_ctc=0) β joint CTC FT was unstable (CTC bias grad explosion under fp16) |
| Optimizer |
ScaledAdam + Eden LR schedule |
base_lr |
5e-4 |
| Epochs |
10 |
| Batching |
max_duration=300 s / batch, dynamic bucketing |
| Precision |
fp16 |
| Features |
on-the-fly 80-dim Kaldi fbank |
| SpecAugment |
on (time-warp factor 80) |
| MUSAN |
off |
| Speed perturb |
off |
| Seed |
42 |
| Hardware |
1Γ A100-SXM4-40GB |
| Global steps |
~8,674 |
Architecture (unchanged from v2)
| Param |
Value |
num_encoder_layers |
2,2,3,4,3,2 |
encoder_dim |
192,256,384,512,384,256 |
downsampling_factor |
1,2,4,8,4,2 |
feedforward_dim |
512,768,1024,1536,1024,768 |
num_heads |
4,4,4,8,4,4 |
cnn_module_kernel |
31,31,15,15,15,31 |
encoder_unmasked_dim |
192,192,256,256,256,192 |
decoder_dim / joiner_dim |
512 / 512 |
| Vocab |
BPE-3000 (mixed-case Vietnamese; same as v2) |
Files
| File |
Description |
best-valid-loss.pt |
Best icefall checkpoint (model + model_avg + train metadata) |
bpe.model |
SentencePiece BPE-3000 (shared with v2) |
tokens.txt |
Token id map for sherpa / icefall |
config.json |
Architecture + training metadata |
train_zipformer_meeting_wer16.sh |
Launch script used for this run |
prep_lhotse_meeting_wer16.py |
Lhotse CutSet prep (Soniox labels) |
eval_zipformer_meeting_wer16.py |
Greedy WER eval helper |
Usage (icefall)
import torch, sys
sys.path.insert(0, "/path/to/icefall")
sys.path.insert(0, "/path/to/zipformer_work")
from finetune import get_model, get_params, add_model_arguments
from beam_search import greedy_search_batch
import argparse, sentencepiece as spm
parser = argparse.ArgumentParser()
add_model_arguments(parser)
params = get_params()
params.update(vars(parser.parse_args([])))
params.encoder_dim = "192,256,384,512,384,256"
params.num_encoder_layers = "2,2,3,4,3,2"
params.downsampling_factor = "1,2,4,8,4,2"
params.feedforward_dim = "512,768,1024,1536,1024,768"
params.num_heads = "4,4,4,8,4,4"
params.cnn_module_kernel = "31,31,15,15,15,31"
params.encoder_unmasked_dim = "192,192,256,256,256,192"
params.decoder_dim = 512
params.joiner_dim = 512
params.causal = False
params.vocab_size = 3000
params.blank_id = 0
params.context_size = 2
params.use_transducer = True
params.use_ctc = False
model = get_model(params)
ckpt = torch.load("best-valid-loss.pt", map_location="cpu")
state = ckpt.get("model_avg") or ckpt["model"]
model.load_state_dict(state, strict=False)
model.eval().cuda()
sp = spm.SentencePieceProcessor()
sp.load("bpe.model")
with torch.no_grad():
encoder_out, encoder_out_lens = model.forward_encoder(feature, feature_lens)
token_ids = greedy_search_batch(model, encoder_out, encoder_out_lens)
text = sp.decode(token_ids[0])
print(text)
Notes / limitations
- Optimized for Vietnamese meeting / conversational audio; not re-evaluated on open-domain policy or YouTube suites in this card.
- Greedy decoding only in the reported WER; beam search may improve further.
- Transcripts are Soniox pseudo-labels (filtered), not human gold.
- CTC head from v2 was not fine-tuned / not present in the shipped transducer-only graph.
Changelog
| Date |
Event |
| 2026-07-27 |
Fine-tune v2 β meeting wer16, 10 epochs; valid loss 0.479 β 0.336 |
| 2026-07-27 |
Meeting-dev WER 31.07% (v2) β 20.71% (v3); push actableai/zipformer-rnnt-v3 |