Skip to content

feat(ssl): add ViP-VL self-supervised pretraining for ChunkFormer - #41

Merged
khanld merged 13 commits into
mainfrom
kl/add_bestrq_ssl
Jun 7, 2026
Merged

feat(ssl): add ViP-VL self-supervised pretraining for ChunkFormer#41
khanld merged 13 commits into
mainfrom
kl/add_bestrq_ssl

Conversation

@khanld

@khanld khanld commented Jun 5, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the self-supervised pretraining method of ViP-VL (ViP-VL: Vietnamese
Self-supervised Speech Pretraining Model with Vector-Quantization Learning
, submitted
to Interspeech 2026) on top of the ChunkFormer encoder.

ViP-VL extends the random-projection-quantizer technique (BEST-RQ, arXiv:2202.01855)
to an aggressive 8× subsampling ChunkFormer backbone and fixes the synchronization
between the masking manifold and the encoder's subsampling rate. This PR ports only the
code needed for that pretraining path — no unrelated experiment cruft — and keeps the
existing ASR / RNN-T / classification training fully intact.

Paper method → code

  • ViP-VL model (BEST-RQ + ChunkFormer backbone)chunkformer/ssl/vipvl/model.py
    (ViPVL) wraps the existing ChunkFormerEncoder. Config matches the paper's 78M setup:
    12 blocks, 512 output size, 8 heads, 2048 FFN, CNN kernel 15, 8× temporal subsampling
    (examples/ssl/vipvl/conf/vipvl.yaml).
  • Acoustic Stacking & Receptive Field Alignment (§2.3) — Subsampling.stacking() stacks
    each output frame's full receptive field (size = reverse_calc_length(1), step = subsampling_rate),
    i.e. the 15-frame window / stride-8 alignment derived from the 3-stacked conv stem
    (kernel 3, stride 2). Concatenated stacking feeds the random-projection quantizer targets.
  • Mask Selection Strategy (§2.4) — masking is applied to the raw 10 ms filterbank frames
    before subsampling; a subsampled frame is labelled "masked" iff ≥ 80 % of its
    constituent pre-subsampled frames are masked (mask_indices >= 0.8 in model.py).
  • Random-projection quantizer (§2.2, Eq. 1) — chunkformer/ssl/modules/quantizer.py:
    frozen Xavier-initialized projection, codebook c_i ~ N(0, I), L2 nearest-neighbour on
    L2-normalized vectors; input mean-variance normalization via CMVN to prevent codebook
    collapse. MLM negative-log-likelihood loss over masked positions (modules/loss.py).

What's added

  • chunkformer/ssl/vipvl/ViPVL model + shared quantizer / loss / utils modules.
  • examples/ssl/vipvl/ — clean recipe: conf/vipvl.yaml, run.sh, path.sh,
    README.md, and tools / chunkformer symlinks.
  • tools/export_ssl_encoder.py — exports an encoder-only checkpoint (drops SSL heads)
    with a sanitized config.yaml for downstream finetuning / HF upload.

Integration (behavior-preserving)

  • init_model / init_dataset / train.py: register the vipvl model type (no tokenizer).
    ASR / RNN-T / classification paths are unchanged.
  • subsampling: adds the stacking() helper used to build quantizer targets.
  • encoder: optional LayerDrop (encoder_layerdrop), training-only, off by default.
  • dataset / processor: optional random feature cropping via crop_conf (no-op when unset).
  • executor.cv: generic scalar-metric accumulation (handles SSL python-float metrics like corr).
  • train_utils: deepspeed import made optional; vocab_size set only for ASR.

Verification

  • No regressions: ASRModel, Transducer, SpeechClassificationModel, and ViPVL all build.
  • Pretrained checkpoint: the released avg_50.pt loads under model: vipvl with identical
    forward-parity metrics (weights byte-identical; only the config's model: field was migrated).
  • Export: encoder-only checkpoint loads into a downstream ASR model with 0 missing encoder.* keys.
  • Lint: pre-commit (black / isort / flake8 / mypy) passes.

…coder

Adds a clean, isolated BEST-RQ (random-projection quantizer) SSL pretraining
path for the ChunkFormer encoder, plus a finetune-ready encoder export tool.

- chunkformer/ssl: BestRQ model + quantizer/loss/utils modules
- init_model/init_dataset/train: register 'bestrq' model type (no tokenizer);
  behavior-preserving for asr_model/transducer/classification
- encoder: optional LayerDrop (encoder_layerdrop, training-only, off by default)
- dataset/processor: optional random feature cropping via crop_conf (no-op when unset)
- executor.cv: generic scalar-metric accumulation (handles SSL python-float metrics)
- train_utils: make deepspeed import optional; vocab_size only for ASR
- examples/ssl/bestrq: clean recipe (conf from released checkpoint, sanitized data paths)
- tools/export_ssl_encoder.py: export encoder-only checkpoint with sanitized config
- tools/verify_bestrq_parity.py: deterministic forward-parity check

Verified: forward output is bit-for-bit identical to the kl/add_wav2vec2_ssl
branch on avg_50.pt; ASR/RNN-T/classification/BestRQ all still build; exported
encoder loads with 0 missing encoder.* keys.
Copilot AI review requested due to automatic review settings June 5, 2026 09:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a BEST-RQ self-supervised learning (SSL) pretraining path for the ChunkFormer encoder, including a dedicated recipe, encoder-only export tooling for downstream finetuning, and minimal integration into existing training/dataset/model initialization flows.

Changes:

  • Added BEST-RQ SSL model implementation and supporting quantizer/loss/util modules under chunkformer/ssl/.
  • Added an end-to-end BEST-RQ recipe under examples/ssl/bestrq/, plus tools for parity verification and encoder-only checkpoint export.
  • Integrated SSL mode into existing initialization/training/dataset utilities (tokenizer-less path, optional cropping, optional LayerDrop, generic CV metric aggregation, optional DeepSpeed import).

Reviewed changes

Copilot reviewed 23 out of 25 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
tools/verify_bestrq_parity.py Adds a deterministic forward-pass parity checker for BEST-RQ checkpoints.
tools/export_ssl_encoder.py Adds a utility to export an encoder-only checkpoint bundle + sanitized config/README.
examples/ssl/bestrq/run.sh Adds a staged BEST-RQ pretraining pipeline script (train/avg/export/upload).
examples/ssl/bestrq/README.md Documents BEST-RQ recipe usage, data layout, export, and parity check.
examples/ssl/bestrq/path.sh Sets recipe environment variables and paths for running BEST-RQ stages.
examples/ssl/bestrq/conf/bestrq.yaml Adds a BEST-RQ training configuration (encoder/model/dataset settings).
chunkformer/utils/train_utils.py Makes DeepSpeed optional and adjusts vocab sizing for tokenizer-less modes.
chunkformer/utils/mask.py Adds compute_mask_indices used for BEST-RQ masking behavior.
chunkformer/utils/init_model.py Registers bestrq model type and constructs the BestRQ wrapper model.
chunkformer/utils/init_dataset.py Routes SSL/classification through the tokenizer-less dataset pipeline.
chunkformer/utils/executor.py Generalizes CV scalar metric accumulation (supports SSL scalar metrics).
chunkformer/ssl/modules/utils.py Adds small tensor helper utilities used by BEST-RQ modules.
chunkformer/ssl/modules/quantizer.py Adds random-projection quantizers used by BEST-RQ pretraining.
chunkformer/ssl/modules/loss.py Adds BEST-RQ MLM-style NLL loss wrapper.
chunkformer/ssl/modules/init.py Adds/defines SSL modules package entry point.
chunkformer/ssl/bestrq/model.py Implements the BEST-RQ wrapper model (masking, quantizer targets, loss/metrics).
chunkformer/ssl/bestrq/init.py Exports BestRQ from the BEST-RQ package.
chunkformer/ssl/init.py Adds package-level documentation for SSL support.
chunkformer/modules/subsampling.py Adds stacking() helper used to build BEST-RQ quantizer targets.
chunkformer/modules/encoder.py Adds optional LayerDrop support and cache-handling changes in forward_layers.
chunkformer/dataset/processor.py Adds optional random feature cropping and guards deprecated torchaudio API usage.
chunkformer/dataset/dataset.py Wires crop_conf into batching/padding and dynamic batch window sizing.
chunkformer/bin/train.py Skips tokenizer initialization for bestrq model type.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +558 to +563
if crop_conf is not None:
crop_length = crop_conf.get("crop_length", "min")
if crop_length == "min":
crop_length = min([x["feat"].size(0) for x in sample])
crop_length = min(crop_length, crop_conf.get("max_crop_length"))
sample = [crop(s, crop_length=crop_length) for s in sample]
Comment on lines +631 to +641
if self.crop_conf is not None:
# Account for cropping when estimating the padded batch size so the
# frame budget reflects the post-crop sequence length.
self.shortest_frames = min(self.shortest_frames, new_sample_frames)
self.longest_frames = max(self.longest_frames, new_sample_frames)
if self.crop_length == "min":
shortest_frames = min(self.shortest_frames, self.max_crop_length)
frames_after_padding = shortest_frames * (buffer_size + 1)
else:
longest_frames = min(self.longest_frames, self.crop_length)
frames_after_padding = longest_frames * (buffer_size + 1)
Comment on lines +294 to +303
# LayerDrop (https://arxiv.org/abs/1909.11556): randomly skip whole
# encoder layers during training. Disabled when encoder_layerdrop=0
# (default), so inference/finetuning behaviour is unchanged.
if self.training and self.encoder_layerdrop > 0.0:
if random.random() < self.encoder_layerdrop:
new_att_cache = att_cache[idx] if att_cache.size(0) > 0 else torch.zeros((0, 0, 0, 0))
new_cnn_cache = cnn_cache[idx] if cnn_cache.size(0) > 0 else torch.zeros((0, 0, 0))
r_att_cache.append(new_att_cache)
r_cnn_cache.append(new_cnn_cache)
continue
Comment thread chunkformer/modules/encoder.py Outdated
Comment on lines +304 to +320
xs, _, new_att_cache, new_cnn_cache = layer(
xs,
masks,
pos_emb,
mask_pad,
att_cache=att_cache[idx] if att_cache.size(0) > 0 else att_cache,
cnn_cache=cnn_cache[idx] if cnn_cache.size(0) > 0 else cnn_cache,
att_cache=att_cache[idx] if att_cache.size(0) > 0 else torch.zeros((0, 0, 0, 0)),
cnn_cache=cnn_cache[idx] if cnn_cache.size(0) > 0 else torch.zeros((0, 0, 0)),
chunk_size=chunk_size,
left_context_size=left_context_size,
right_context_size=right_context_size,
)
# During training/SSL the caches are empty; force empty placeholders
# so torch.stack works even when some layers are dropped.
if att_cache.size(0) == 0:
new_att_cache = torch.zeros((0, 0, 0, 0))
if cnn_cache.size(0) == 0:
new_cnn_cache = torch.zeros((0, 0, 0))
Comment thread chunkformer/ssl/modules/quantizer.py Outdated
Comment on lines +132 to +137
if self.dist_fn == "cosine":
# (B, T, num_books, code_dim) -> (B, T, num_books, num_classes)
xid = torch.einsum('btdh,dch->btdc', x, self.codebooks)
# (B, T, num_books, num_classes) -> (B, T, num_books)
xid = xid.max(dim=-1)[1]
elif self.dist_fn == "l2":
Comment thread chunkformer/utils/mask.py
Comment on lines +537 to +546
for i, mask_idc in enumerate(mask_idcs):
if target_len is not None and len(mask_idc) > target_len:
mask_idc = rng.choice(mask_idc, target_len, replace=False)

mask[i, mask_idc] = True

if target_len is not None and len(mask_idc) < target_len:
unmasked = np.flatnonzero(~mask[i])
to_mask = rng.choice(unmasked, target_len - len(mask_idc), replace=False)
mask[i, to_mask] = True
Comment thread chunkformer/ssl/modules/loss.py Outdated
Comment on lines +11 to +22
def __init__(
self,
mask_threshold: float = 0.8,
):
super().__init__()
self.nll_loss = torch.nn.NLLLoss()
self.mask_threshold = mask_threshold

def __call__(self, logits, targets):
loss = self.nll_loss(logits, targets)
loss = torch.mean(loss)
return loss
Comment on lines +170 to +173
# 2. sanitised config
with open(args.config, "r") as fin:
config = yaml.load(fin, Loader=yaml.FullLoader)
clean_config = sanitize_config(config)
Comment thread tools/verify_vipvl_parity.py Outdated
Comment on lines +41 to +44
def build_model(config_path: str):
with open(config_path, "r") as fin:
configs = yaml.load(fin, Loader=yaml.FullLoader)
# Skip CMVN so we do not depend on an external global_cmvn file. The CMVN
Comment on lines 226 to 229
# DeepSpeed automaticly add '--deepspeed' and '--deepspeed_config' to parser
parser = deepspeed.add_config_arguments(parser)
if DEEPSPEED_AVAILABLE:
parser = deepspeed.add_config_arguments(parser)
return parser
khanld added 2 commits June 5, 2026 10:32
- processor: guard crop_conf 'max_crop_length' None in padding() and DynamicBatchWindow
- encoder: device-aware empty cache placeholders for LayerDrop (avoid CPU/GPU mismatch)
- quantizer: remove cosine distance (l2-only, the one we use); fixes the broken cosine branch
- loss: remove unused mask_threshold parameter
- train_utils: clear RuntimeError when train_engine=deepspeed but DeepSpeed unavailable
- tools: use yaml.safe_load instead of yaml.load(FullLoader)
- lint: black/isort/flake8 clean (wrap long lines, drop placeholder-less f-strings)

Parity with avg_50.pt preserved bit-for-bit; ASR/classification/BestRQ still build.
feature_extractor returns 4 tensors (xs, pos_emb, masks, xs_norm) but was
annotated as a 2-tuple, causing a return-value error plus a cascade of
unpack / 'cannot determine type' errors in BestRQ.forward under mypy.
Copilot AI review requested due to automatic review settings June 5, 2026 10:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 25 changed files in this pull request and generated 8 comments.

Comment thread chunkformer/utils/mask.py
Comment on lines +532 to +542
target_len = None
if require_same_masks:
if add_masks:
target_len = max([len(m) for m in mask_idcs])
else:
target_len = min([len(m) for m in mask_idcs])

for i, mask_idc in enumerate(mask_idcs):
if target_len is not None and len(mask_idc) > target_len:
mask_idc = rng.choice(mask_idc, target_len, replace=False)

Comment thread chunkformer/utils/mask.py
Comment on lines +441 to +443
if padding_mask is not None:
sz = all_sz - padding_mask[i].long().sum().item()
assert sz >= 0, sz
Comment thread chunkformer/utils/mask.py
uniform = sample from uniform distribution [mask_other, mask_length*2]
normal = sample from normal distribution with mean mask_length and stdev
mask_other. mask is min 1 element
poisson = sample from possion distribution with lambda = mask length
Comment on lines +101 to +105
# (num_books, num_classes, hid_dim)
codebooks = torch.randn(self.num_books, self.num_classes, self.code_dim).double()
torch.nn.init.normal_(codebooks, mean=0, std=1)
codebooks = F.normalize(codebooks, dim=-1)
self.codebooks = nn.Parameter(codebooks)
Comment on lines +33 to +36
# NOTE: sox_utils.set_buffer_size is deprecated and was removed in newer
# torchaudio releases. Guard it so import does not fail across versions.
if hasattr(torchaudio.utils, "sox_utils"):
torchaudio.utils.sox_utils.set_buffer_size(16500)
Comment on lines +242 to +245
logit = x[mask_indices]
logit = self.final_proj(logit)
logit = logit.reshape(-1, self.quantizer.num_books, self.quantizer.num_classes)
logit = logit.log_softmax(-1)
Comment on lines +1 to +5
# Copyright (c) Facebook, Inc. and its affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.

Comment on lines +87 to +97
readme = f"""---
tags:
- speech
- self-supervised-learning
- best-rq
- chunkformer
- pretrained-encoder
- pytorch
license: apache-2.0
library_name: transformers
---
@khanld khanld changed the title feat(ssl): add BEST-RQ self-supervised pretraining for ChunkFormer encoder feat(ssl): add ViP-VL — BEST-RQ self-supervised pretraining for ChunkFormer Jun 5, 2026
Rename the public-facing model/method/code from BEST-RQ to ViP-VL:
- class BestRQ -> ViPVL; package chunkformer/ssl/bestrq -> chunkformer/ssl/vipvl
- model type 'bestrq' -> 'vipvl' (kept as a backward-compatible alias so
  existing checkpoints/configs still load)
- recipe examples/ssl/bestrq -> examples/ssl/vipvl; conf/bestrq.yaml -> conf/vipvl.yaml
- tools/verify_bestrq_parity.py -> tools/verify_vipvl_parity.py
- docstrings/README rebranded to ViP-VL; 'BEST-RQ' kept only as a citation of the
  underlying random-projection-quantizer technique (arXiv:2202.01855)

Parity with avg_50.pt unchanged (verified via the bestrq alias); vipvl and bestrq
both build; black/isort/flake8 clean.
@khanld khanld changed the title feat(ssl): add ViP-VL — BEST-RQ self-supervised pretraining for ChunkFormer feat(ssl): add ViP-VL self-supervised pretraining for ChunkFormer Jun 5, 2026
Untrack the dev parity script (git rm --cached) and gitignore it so it stays
local. Remove the recipe README's reproducibility section that referenced it.
Copilot AI review requested due to automatic review settings June 5, 2026 17:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 25 changed files in this pull request and generated 6 comments.

Comment on lines +6 to +15
from typing import Tuple

import torch
import torch.nn as nn

from chunkformer.ssl.modules.loss import MLMLoss
from chunkformer.ssl.modules.quantizer import RandomProjectionVectorQuantizer
from chunkformer.ssl.modules.utils import index_put
from chunkformer.utils.mask import compute_mask_indices, make_pad_mask

Comment thread chunkformer/utils/mask.py
Comment on lines +539 to +554
for i, mask_idc in enumerate(mask_idcs):
if target_len is not None and len(mask_idc) > target_len:
mask_idc = rng.choice(mask_idc, target_len, replace=False)

mask[i, mask_idc] = True

if target_len is not None and len(mask_idc) < target_len:
unmasked = np.flatnonzero(~mask[i])
to_mask = rng.choice(unmasked, target_len - len(mask_idc), replace=False)
mask[i, to_mask] = True

if mask_dropout > 0:
masked = np.flatnonzero(mask[i])
num_holes = np.rint(len(masked) * mask_dropout).astype(int)
to_drop = rng.choice(masked, num_holes, replace=False)
mask[i, to_drop] = False
Comment on lines +101 to +105
# (num_books, num_classes, hid_dim)
codebooks = torch.randn(self.num_books, self.num_classes, self.code_dim).double()
torch.nn.init.normal_(codebooks, mean=0, std=1)
codebooks = F.normalize(codebooks, dim=-1)
self.codebooks = nn.Parameter(codebooks)

loss = self.criterion(logit, y)

mask_percentile = (mask_indices.sum(-1).float() / x_mask.float().sum(-1)).mean().item()
Comment on lines +218 to +224
unmasked_features = self.stacking(unmask_xs)
unmasked_features = unmasked_features.reshape(
unmasked_features.size(0), unmasked_features.size(1), -1
)
# B, T, L
mask_indices = self.stacking(mask_indices)
# B, T
Comment on lines +33 to +36
# NOTE: sox_utils.set_buffer_size is deprecated and was removed in newer
# torchaudio releases. Guard it so import does not fail across versions.
if hasattr(torchaudio.utils, "sox_utils"):
torchaudio.utils.sox_utils.set_buffer_size(16500)
khanld added 8 commits June 5, 2026 17:30
Checkpoint loading configs were migrated to 'model: vipvl', so the backward-
compatible 'bestrq' alias is no longer needed. init_model/train now recognise
only 'vipvl' for the SSL path. Verified: the released avg_50.pt loads under
model: vipvl with identical parity metrics; weights file byte-identical.
- push_model_hf.py now detects the 'vipvl' (and legacy 'bestrq') model type and
  emits a paper-based ViP-VL self-supervised encoder model card instead of
  mislabelling it as an ASR model.
- create_repository uses create_repo(exist_ok=True) instead of a repo_info()
  existence probe, which reported private repos as missing and broke uploads.
- Title now spells out the ViP-VL acronym in bold.
- States INTERSPEECH 2026 acceptance (badge + intro + citation).
- Intro highlights the pretrained ViP-VL model; drops the encoder-only warning.
- Architecture table adds the 'Encoder: ChunkFormer' row.
load_checkpoint and load_trained_modules now resolve the path via
resolve_checkpoint_path: local file -> local dir/pytorch_model.pt ->
download pytorch_model.pt from the Hugging Face Hub. Lets users pass
checkpoint=khanhld/vip-vl-base-vie directly. Fully backward compatible
with existing local paths. Docs updated (recipe README, model card,
export tool).
@khanld
khanld merged commit 28e9f67 into main Jun 7, 2026
2 checks passed
@khanld
khanld deleted the kl/add_bestrq_ssl branch June 7, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants