| """ |
| Cascade ASR BPE: Self-speculative decoding with dual-head CTC BPE encoder. |
| Based on v32 - replaces grapheme CTC draft with BPE CTC draft. |
| |
| The full Granite Speech encoder already has grapheme out/out_mid heads; only out_llm |
| (BPE linear head) needs to be loaded separately from the dual-head encoder safetensors. |
| |
| Single encoder pass: embeddings from the CTC draft step are reused for verify/fallback. |
| Importance for posterior_weighted_pool uses mid-layer (layer num_layers//2) grapheme |
| blank probability, captured via a forward hook on the encoder. |
| """ |
|
|
| import argparse |
| import math |
| import os |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| import evaluate |
| from normalizer import data_utils |
| import time |
| from tqdm import tqdm |
| from transformers import AutoProcessor, AutoModelForSpeechSeq2Seq, models |
| from safetensors.torch import load_file |
| from huggingface_hub import hf_hub_download |
|
|
| assert hasattr(models, "granite_speech") |
|
|
| wer_metric = evaluate.load("wer") |
| torch.set_float32_matmul_precision('high') |
|
|
| LLM_DOWNSAMPLE_WINDOW = 4 |
| LLM_OUT_DIM = 100353 |
|
|
|
|
| def posterior_weighted_pool(hidden, importance, window_size=4): |
| """Importance-weighted downsampling. importance[b,t] = 1 - blank_prob.""" |
| B, T, D = hidden.shape |
| pad_len = (window_size - T % window_size) % window_size |
| if pad_len > 0: |
| hidden = F.pad(hidden, (0, 0, 0, pad_len)) |
| importance = F.pad(importance, (0, pad_len)) |
| num_windows = hidden.shape[1] // window_size |
| hidden = hidden.view(B, num_windows, window_size, D) |
| importance = importance.view(B, num_windows, window_size) |
| weights = importance / (importance.sum(dim=-1, keepdim=True) + 1e-8) |
| return (hidden * weights.unsqueeze(-1)).sum(dim=2), window_size |
|
|
|
|
| def main(args): |
| device = f"cuda:{args.device}" if args.device >= 0 else "cpu" |
|
|
| |
| processor = AutoProcessor.from_pretrained(args.model_id) |
| tokenizer = processor.tokenizer |
| model = AutoModelForSpeechSeq2Seq.from_pretrained(args.model_id, torch_dtype=torch.bfloat16).to(device) |
| model.eval() |
| print(f"Model size: {sum(p.numel() for p in model.parameters()) / 1e9:.2f}B parameters") |
| logits_scaling = getattr(model.language_model.config, 'logits_scaling', 1.0) |
|
|
| |
| out_llm = nn.Linear(model.encoder.config.hidden_dim, LLM_OUT_DIM, bias=True) |
| llm_weights = load_file(hf_hub_download(repo_id=args.model_id, filename="out_llm.safetensors")) |
| out_llm.load_state_dict(llm_weights) |
| out_llm.to(torch.bfloat16).eval().to(device) |
|
|
| |
| |
| |
| num_enc_layers = model.encoder.config.num_layers |
| mid_layer_idx = num_enc_layers // 2 - 1 |
| _mid_hidden = {} |
|
|
| def _save_mid_hidden(module, input, output): |
| _mid_hidden['h'] = output[0] if isinstance(output, tuple) else output |
|
|
| _hook = model.encoder.layers[mid_layer_idx].register_forward_hook(_save_mid_hidden) |
|
|
| |
| text_instruction = "<|audio|>can you transcribe the speech into a written format?" |
|
|
| |
| message = [ |
| {"role": "user", "content": text_instruction}, |
| ] |
| text_prompt = tokenizer.apply_chat_template(message, tokenize=False, add_generation_prompt=True) |
|
|
| |
| prompt_prefix, prompt_suffix = text_prompt.split("<|audio|>") |
|
|
| |
| embed_layer = model.language_model.get_input_embeddings() |
| prefix_ids = tokenizer.encode(prompt_prefix, add_special_tokens=False) |
| suffix_ids = tokenizer.encode(prompt_suffix, add_special_tokens=False) |
| cached_prefix_embeds = embed_layer(torch.tensor([prefix_ids], device=device)) |
| cached_suffix_embeds = embed_layer(torch.tensor([suffix_ids], device=device)) |
|
|
| HOP_LENGTH = 160 |
| confidence_threshold = args.confidence_threshold |
| ctc_threshold = args.ctc_threshold |
|
|
| @torch.no_grad() |
| def ctc_decode_bpe(audios): |
| """BPE CTC draft: single encoder pass, reuse embeddings for verify/fallback.""" |
| texts = [text_prompt] * len(audios) |
| model_inputs = processor(texts, audios, device=device, return_tensors="pt").to(device) |
|
|
| with torch.amp.autocast(device_type='cuda', dtype=torch.bfloat16): |
| encoder_output = model.encoder(model_inputs["input_features"]) |
| |
| |
| enc_hidden = encoder_output.last_hidden_state if hasattr(encoder_output, 'last_hidden_state') else encoder_output |
|
|
| |
| mid_h = _mid_hidden['h'] |
| mid_grapheme_logits = model.encoder.out(mid_h) |
| grapheme_probs_mid = F.softmax(mid_grapheme_logits.float(), dim=-1) |
| importance = 1.0 - grapheme_probs_mid[:, :, 0] |
|
|
| |
| x_pooled, _ = posterior_weighted_pool(enc_hidden, importance, window_size=LLM_DOWNSAMPLE_WINDOW) |
|
|
| |
| |
| |
| pooled_lengths = [] |
| for i in range(len(audios)): |
| enc_len = len(audios[i]) // HOP_LENGTH // 2 + 1 |
| enc_len = min(enc_len, enc_hidden.shape[1]) |
| pooled_len = math.ceil(enc_len / LLM_DOWNSAMPLE_WINDOW) |
| pooled_lengths.append(min(pooled_len, x_pooled.shape[1])) |
|
|
| |
| valid_positions = [] |
| for i, plen in enumerate(pooled_lengths): |
| for t in range(plen): |
| valid_positions.append((i, t)) |
|
|
| batch_idx = torch.tensor([p[0] for p in valid_positions], device=device) |
| time_idx = torch.tensor([p[1] for p in valid_positions], device=device) |
| x_valid = x_pooled[batch_idx, time_idx, :] |
| bpe_logits_valid = out_llm(x_valid) |
| bpe_probs_valid = F.softmax(bpe_logits_valid.float(), dim=-1) |
|
|
| |
| bpe_texts, bpe_entropies, embed_lengths = [], [], [] |
| offset = 0 |
| for i in range(len(audios)): |
| plen = pooled_lengths[i] |
| probs_i = bpe_probs_valid[offset:offset + plen] |
| offset += plen |
|
|
| _, idx = probs_i.max(dim=-1) |
| entropy_i = -(probs_i * torch.log(probs_i + 1e-10)).sum(dim=-1) |
|
|
| dedup = torch.unique_consecutive(idx, dim=-1) |
| non_blank = dedup[dedup > 0] |
| token_ids = [t.item() - 1 for t in non_blank] |
| text = tokenizer.decode(token_ids) if token_ids else "" |
| bpe_texts.append(text) |
| bpe_entropies.append(entropy_i.max().item() if token_ids else float('inf')) |
| embed_lengths.append(len(audios[i]) // HOP_LENGTH // 2 + 1) |
|
|
| return bpe_texts, bpe_entropies, enc_hidden, embed_lengths |
|
|
| @torch.no_grad() |
| def verify(ctc_texts, embeddings, embed_lengths): |
| """Verify BPE draft tokens with LLM. Identical to v32 except inputs are already BPE text.""" |
| batch_sz = len(ctc_texts) |
|
|
| ctc_token_ids = [] |
| for text in ctc_texts: |
| text = text.strip() if text else "" |
| ctc_token_ids.append(tokenizer.encode(text, add_special_tokens=False) if text else []) |
|
|
| with torch.amp.autocast(device_type='cuda', dtype=torch.bfloat16): |
| audio_embeds = model.projector(embeddings) |
| max_proj_len = audio_embeds.shape[1] |
|
|
| window_size, downsample_rate = model.config.window_size, model.config.downsample_rate |
| num_queries = window_size // downsample_rate |
| proj_lengths = [min(math.ceil(enc_len / window_size) * num_queries, max_proj_len) for enc_len in embed_lengths] |
|
|
| if not any(ctc_token_ids): |
| return [(False, ctc_texts[i]) for i in range(batch_sz)], audio_embeds, proj_lengths |
|
|
| audio_token_id = model.config.audio_token_id |
| all_input_ids, prompt_lens, audio_ranges = [], [], [] |
|
|
| for i, proj_len in enumerate(proj_lengths): |
| audio_start = len(prefix_ids) |
| audio_ranges.append((audio_start, audio_start + proj_len)) |
| prompt_part = prefix_ids + [audio_token_id] * proj_len + suffix_ids |
| prompt_lens.append(len(prompt_part)) |
| all_input_ids.append(prompt_part + ctc_token_ids[i]) |
|
|
| max_len = max(len(ids) for ids in all_input_ids) |
| padded_ids = torch.full((batch_sz, max_len), tokenizer.pad_token_id, dtype=torch.long, device=device) |
| attn_mask = torch.zeros(batch_sz, max_len, dtype=torch.long, device=device) |
| for i, ids in enumerate(all_input_ids): |
| padded_ids[i, :len(ids)] = torch.tensor(ids, dtype=torch.long, device=device) |
| attn_mask[i, :len(ids)] = 1 |
|
|
| inputs_embeds = model.language_model.get_input_embeddings()(padded_ids) |
| for i in range(batch_sz): |
| s, e = audio_ranges[i] |
| inputs_embeds[i, s:e, :] = audio_embeds[i, :e-s, :] |
|
|
| with torch.amp.autocast(device_type='cuda', dtype=torch.bfloat16): |
| hidden = model.language_model.model(attention_mask=attn_mask, inputs_embeds=inputs_embeds, use_cache=False).last_hidden_state |
|
|
| |
| sample_idx, pos_idx, ctc_flat = [], [], [] |
| sample_ranges, sample_valid = [], [] |
| offset = 0 |
|
|
| for i in range(batch_sz): |
| ctc_tokens = ctc_token_ids[i] |
| if not ctc_tokens or prompt_lens[i] - 1 + len(ctc_tokens) > hidden.shape[1]: |
| sample_ranges.append((offset, offset)) |
| sample_valid.append(False) |
| continue |
| verify_start = prompt_lens[i] - 1 |
| for k in range(len(ctc_tokens)): |
| sample_idx.append(i) |
| pos_idx.append(verify_start + k) |
| ctc_flat.append(ctc_tokens[k]) |
| sample_ranges.append((offset, offset + len(ctc_tokens))) |
| sample_valid.append(True) |
| offset += len(ctc_tokens) |
|
|
| if pos_idx: |
| gathered = hidden[torch.tensor(sample_idx, device=device), torch.tensor(pos_idx, device=device), :] |
| with torch.amp.autocast(device_type='cuda', dtype=torch.bfloat16): |
| logits = model.language_model.lm_head(gathered) / logits_scaling |
| probs = F.softmax(logits.float(), dim=-1) |
| ctc_probs = probs[torch.arange(len(ctc_flat), device=device), torch.tensor(ctc_flat, device=device)] |
|
|
| results = [] |
| for i in range(batch_sz): |
| s, e = sample_ranges[i] |
| if not sample_valid[i]: |
| results.append((False, ctc_texts[i])) |
| continue |
| token_probs = ctc_probs[s:e] |
| accepted = (token_probs >= confidence_threshold).all().item() |
| results.append((accepted, ctc_texts[i])) |
|
|
| return results, audio_embeds, proj_lengths |
|
|
| @torch.no_grad() |
| def fallback(audio_embeds, indices, proj_lengths): |
| """AR fallback for failed samples.""" |
| if not indices: |
| return [] |
|
|
| batch_sz = len(indices) |
| hidden_dim = audio_embeds.shape[-1] |
| all_embeds, all_lengths = [], [] |
|
|
| for i in indices: |
| sample_embeds = audio_embeds[i, :proj_lengths[i], :].unsqueeze(0) |
| combined = torch.cat([cached_prefix_embeds, sample_embeds, cached_suffix_embeds], dim=1) |
| all_embeds.append(combined.squeeze(0)) |
| all_lengths.append(combined.shape[1]) |
|
|
| max_len = max(all_lengths) |
| padded = torch.zeros(batch_sz, max_len, hidden_dim, device=device, dtype=audio_embeds.dtype) |
| attn_mask = torch.zeros(batch_sz, max_len, dtype=torch.long, device=device) |
| for i, (emb, length) in enumerate(zip(all_embeds, all_lengths)): |
| padded[i, max_len - length:] = emb |
| attn_mask[i, max_len - length:] = 1 |
|
|
| outputs = model.language_model.generate( |
| inputs_embeds=padded, attention_mask=attn_mask, |
| bos_token_id=tokenizer.bos_token_id, pad_token_id=tokenizer.pad_token_id, |
| eos_token_id=tokenizer.eos_token_id, max_new_tokens=args.max_new_tokens, |
| num_beams=args.num_beams, early_stopping=True, |
| do_sample=False, use_cache=True |
| ) |
|
|
| return [tokenizer.decode(outputs[i], skip_special_tokens=True) for i in range(batch_sz)] |
|
|
| def benchmark(batch): |
| audios = [audio["array"] for audio in batch["audio"]] |
| batch_sz = len(audios) |
| sampling_rate = batch["audio"][0]["sampling_rate"] |
| batch["audio_length_s"] = [len(audio) / sampling_rate for audio in audios] |
| batch["audio_filepath"] = data_utils.extract_audio_filepaths_from_batch(batch, batch_sz) |
|
|
| start_time = time.time() |
|
|
| |
| bpe_texts, bpe_entropies, embeddings, embed_lengths = ctc_decode_bpe(audios) |
|
|
| |
| predictions = [None] * batch_sz |
| verify_idx = [] |
|
|
| for i, (text, ent) in enumerate(zip(bpe_texts, bpe_entropies)): |
| if ent <= ctc_threshold and text.strip(): |
| predictions[i] = text.strip() |
| else: |
| verify_idx.append(i) |
|
|
| |
| if verify_idx: |
| verify_emb = embeddings[verify_idx] |
| verify_embed_lengths = [embed_lengths[i] for i in verify_idx] |
| verify_bpe_texts = [bpe_texts[i] for i in verify_idx] |
|
|
| results, audio_embeds, proj_lengths = verify(verify_bpe_texts, verify_emb, verify_embed_lengths) |
|
|
| fail_idx = [] |
| for j, (accepted, text) in enumerate(results): |
| i = verify_idx[j] |
| if accepted: |
| predictions[i] = text.strip() |
| else: |
| fail_idx.append(j) |
|
|
| |
| if fail_idx: |
| fallback_texts = fallback(audio_embeds, fail_idx, proj_lengths) |
| for k, j in enumerate(fail_idx): |
| predictions[verify_idx[j]] = fallback_texts[k] |
|
|
| runtime = time.time() - start_time |
|
|
| batch["transcription_time_s"] = [runtime / batch_sz] * batch_sz |
| batch["predictions"] = predictions |
| batch["references"] = batch["original_text"] |
| return batch |
|
|
| |
| dataset = data_utils.load_data(args) |
| if args.max_eval_samples is not None and args.max_eval_samples > 0: |
| print(f"Subsampling to {args.max_eval_samples} samples") |
| dataset = dataset.select(range(min(args.max_eval_samples, len(dataset)))) |
| dataset = data_utils.prepare_data(dataset) |
|
|
| dataset = dataset.map(benchmark, batch_size=args.batch_size, batched=True, remove_columns=["audio"], desc="Processing") |
|
|
| all_results = {"audio_length_s": [], "transcription_time_s": [], "predictions": [], "references": [], "audio_filepath": []} |
| for result in tqdm(dataset, desc="Samples"): |
| for key in all_results: |
| all_results[key].append(result[key]) |
|
|
| _hook.remove() |
|
|
| |
| manifest_path = data_utils.write_manifest( |
| all_results["references"], all_results["predictions"], args.model_id, |
| args.dataset_path, args.dataset, args.split, |
| audio_length=all_results["audio_length_s"], transcription_time=all_results["transcription_time_s"], |
| audio_filepaths=all_results["audio_filepath"], |
| ) |
| print("Results saved at:", os.path.abspath(manifest_path)) |
|
|
| norm_refs = [data_utils.normalizer(r) for r in all_results["references"]] |
| norm_preds = [data_utils.normalizer(p) for p in all_results["predictions"]] |
| wer = round(100 * wer_metric.compute(references=norm_refs, predictions=norm_preds), 2) |
| rtfx = round(sum(all_results["audio_length_s"]) / sum(all_results["transcription_time_s"]), 2) |
| print(f"{args.model_id} - WER: {wer}%, RTFx: {rtfx}") |
|
|
|
|
| if __name__ == "__main__": |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--model_id", type=str, required=True, |
| help="Full Granite Speech model (provides encoder, projector, LLM)") |
| parser.add_argument("--dataset_path", type=str, default="esb/datasets") |
| parser.add_argument("--dataset", type=str, required=True) |
| parser.add_argument("--split", type=str, default="test") |
| parser.add_argument("--device", type=int, default=-1) |
| parser.add_argument("--batch_size", type=int, default=16) |
| parser.add_argument("--max_eval_samples", type=int, default=None) |
| parser.add_argument("--max_new_tokens", type=int, default=200) |
| parser.add_argument("--num_beams", type=int, default=1) |
| parser.add_argument("--confidence_threshold", type=float, default=0.2) |
| parser.add_argument("--ctc_threshold", type=float, default=0.7) |
| parser.add_argument("--no-streaming", dest="streaming", action="store_false") |
| args = parser.parse_args() |
| args.streaming = False |
| main(args) |