| |
| """ |
| # TODO: |
| 1) add max length (i.e. context length of the fine tune model) |
| 2) torch.compile the LLM with the fixed length |
| |
| Authors |
| * Adel Moumen 2025 |
| """ |
|
|
| import os |
| import sys |
| from pathlib import Path |
|
|
| import torch |
| from hyperpyyaml import load_hyperpyyaml |
|
|
| import speechbrain as sb |
| from speechbrain.utils.distributed import if_main_process, run_on_main |
| from speechbrain.utils.logger import get_logger |
|
|
| logger = get_logger(__name__) |
|
|
|
|
| def get_multimodal_attention_mask(wav, wav_lens, txt, txt_lens, device): |
| batch_size = wav.size(0) |
| wav_len = wav.size(1) |
| txt_len = txt.size(1) |
| |
| max_total_len = wav_len + txt_len |
| attention_mask = torch.zeros(batch_size, max_total_len, dtype=torch.bool, device=device) |
| for i in range(batch_size): |
| actual_wav_len = int(wav_lens[i].item() * wav_len) |
| actual_txt_len = int(txt_lens[i].item() * txt_len) |
| |
| attention_mask[i, :actual_wav_len] = True |
| |
| attention_mask[i, wav_len:wav_len + actual_txt_len] = True |
| return attention_mask |
|
|
|
|
| |
| class ASR(sb.core.Brain): |
| def compute_forward(self, batch, stage): |
| """Forward computations from the waveform batches to the output probabilities.""" |
| batch = batch.to(self.device) |
| wavs, wav_lens = batch.sig |
| tokens_bos, tokens_bos_lens = batch.tokens_bos |
| prompt_len = batch.prompt_len |
| wavs = self.hparams.normalize(wavs, wav_lens) |
| audio_feats = self.modules.ssl(wavs, wav_lens) |
| audio_down_feats = self.modules.feat_downsampler(audio_feats) |
| projected_audio_feats = self.modules.proj(audio_down_feats) |
| txt_embds = self.txt_embedding(tokens_bos) |
| multimodal_embds = torch.cat([projected_audio_feats, txt_embds], dim=1) |
| |
| attention_mask = get_multimodal_attention_mask( |
| projected_audio_feats, wav_lens, txt_embds, tokens_bos_lens, self.device |
| ) |
| logits = self.modules.llm( |
| inputs_embeds=multimodal_embds, |
| attention_mask=attention_mask |
| ).logits |
|
|
| if hasattr(self.modules.llm, "module"): |
| gen_func = self.modules.llm.module.model.generate |
| else: |
| gen_func = self.modules.llm.model.generate |
|
|
| hyps = None |
| if stage != sb.Stage.TRAIN: |
| audio_and_prompt_len = projected_audio_feats.shape[1] + prompt_len[0] |
| hyps = gen_func( |
| inputs_embeds=multimodal_embds[ |
| :, :audio_and_prompt_len |
| ], |
| attention_mask=attention_mask[:, :audio_and_prompt_len], |
| generation_config=self.val_decoding_config, |
| ) |
| return logits, hyps, projected_audio_feats.shape[1] |
|
|
| def compute_objectives(self, predictions, batch, stage): |
| """Computes the loss (CTC+NLL) given predictions and targets.""" |
| logits, hyps, _ = predictions |
| tokens_eos, _ = batch.tokens_eos |
| ids = batch.id |
|
|
| num_audio_feats = logits.shape[1] - tokens_eos.shape[1] |
| |
| |
| target_tokens = torch.cat([ |
| torch.full((tokens_eos.shape[0], num_audio_feats), -100, device=self.device), |
| tokens_eos, |
| ], dim=1).long() |
| |
| loss = torch.nn.functional.cross_entropy( |
| logits.view(-1, logits.shape[-1]), |
| target_tokens.view(-1), |
| ignore_index=-100 |
| ) |
| if stage != sb.Stage.TRAIN: |
| |
| target_tokens = target_tokens.masked_fill(target_tokens == -100, self.tokenizer.pad_token_id) |
| preds = self.tokenizer.batch_decode(hyps, skip_special_tokens=True) |
| preds_words = [pred.split(" ") for pred in preds] |
| targets = self.tokenizer.batch_decode(target_tokens, skip_special_tokens=True) |
| targets_words = [target.split(" ") for target in targets] |
| |
| |
| |
| |
| |
| self.cer_metric.append(ids, preds_words, targets_words) |
| self.wer_metric.append(ids, preds_words, targets_words) |
| return loss |
|
|
| def on_stage_start(self, stage, epoch): |
| """Gets called at the beginning of each epoch""" |
| |
| import transformers |
| self.val_decoding_config = transformers.GenerationConfig( |
| pad_token_id=self.tokenizer.pad_token_id, |
| eos_token_id=self.tokenizer.eos_token_id, |
| max_new_tokens=400, |
| do_sample=False, |
| num_beams=1, |
| temperature=1.0, |
| top_k=0, |
| top_p=1.0, |
| repetition_penalty=1.0 |
| ) |
|
|
| if not hasattr(self, "txt_embedding"): |
| |
| self.txt_embedding = ( |
| self.modules.llm.model.get_input_embeddings() |
| if not hasattr(self.modules.llm, "module") |
| else self.modules.llm.module.model.get_input_embeddings() |
| ) |
|
|
| if stage != sb.Stage.TRAIN: |
| self.cer_metric = self.hparams.cer_computer() |
| self.wer_metric = self.hparams.error_rate_computer() |
|
|
| def on_stage_end(self, stage, stage_loss, epoch): |
| """Gets called at the end of a epoch.""" |
| |
| stage_stats = {"loss": stage_loss} |
| if stage == sb.Stage.TRAIN: |
| self.train_stats = stage_stats |
| else: |
| stage_stats["CER"] = self.cer_metric.summarize("error_rate") |
| stage_stats["WER"] = self.wer_metric.summarize("error_rate") |
|
|
| |
| if stage == sb.Stage.VALID: |
| lr = self.hparams.noam_annealing.current_lr |
| steps = self.optimizer_step |
| optimizer = self.optimizer.__class__.__name__ |
|
|
| epoch_stats = { |
| "epoch": epoch, |
| "lr": lr, |
| "steps": steps, |
| "optimizer": optimizer, |
| } |
| self.hparams.train_logger.log_stats( |
| stats_meta=epoch_stats, |
| train_stats=self.train_stats, |
| valid_stats=stage_stats, |
| ) |
| self.checkpointer.save_and_keep_only( |
| meta={"WER": stage_stats["WER"], "epoch": epoch}, |
| max_keys=["WER"], |
| |
| ) |
|
|
| elif stage == sb.Stage.TEST: |
| self.hparams.train_logger.log_stats( |
| stats_meta={"Epoch loaded": self.hparams.epoch_counter.current}, |
| test_stats=stage_stats, |
| ) |
| if if_main_process(): |
| with open( |
| self.hparams.test_wer_file, "w", encoding="utf-8" |
| ) as w: |
| self.wer_metric.write_stats(w) |
|
|
| def on_fit_batch_end(self, batch, outputs, loss, should_step): |
| """At the end of the optimizer step, apply noam annealing.""" |
| |
| |
|
|
|
|
| def dataio_prepare(hparams, tokenizer): |
| """This function prepares the datasets to be used in the brain class. |
| It also defines the data processing pipeline through user-defined functions. |
| """ |
| data_folder = hparams["data_folder"] |
|
|
| train_data = sb.dataio.dataset.DynamicItemDataset.from_csv( |
| csv_path=hparams["train_csv"], |
| replacements={"data_root": data_folder}, |
| ) |
|
|
| if hparams["sorting"] == "ascending": |
| |
| train_data = train_data.filtered_sorted(sort_key="duration") |
| |
| hparams["train_dataloader_opts"]["shuffle"] = False |
|
|
| elif hparams["sorting"] == "descending": |
| train_data = train_data.filtered_sorted( |
| sort_key="duration", reverse=True |
| ) |
| |
| hparams["train_dataloader_opts"]["shuffle"] = False |
|
|
| elif hparams["sorting"] == "random": |
| pass |
|
|
| else: |
| raise NotImplementedError( |
| "sorting must be random, ascending or descending" |
| ) |
| valid_data = sb.dataio.dataset.DynamicItemDataset.from_csv( |
| csv_path=hparams["valid_csv"], |
| replacements={"data_root": data_folder}, |
| ) |
| valid_data = valid_data.filtered_sorted(sort_key="duration") |
|
|
| |
| test_datasets = {} |
| for csv_file in hparams["test_csv"]: |
| name = Path(csv_file).stem |
| test_datasets[name] = sb.dataio.dataset.DynamicItemDataset.from_csv( |
| csv_path=csv_file, replacements={"data_root": data_folder} |
| ) |
| test_datasets[name] = test_datasets[name].filtered_sorted( |
| sort_key="duration" |
| ) |
|
|
| datasets = [train_data, valid_data] + [i for k, i in test_datasets.items()] |
|
|
| |
| @sb.utils.data_pipeline.takes("wav") |
| @sb.utils.data_pipeline.provides("sig") |
| def audio_pipeline(wav): |
| sig = sb.dataio.dataio.read_audio(wav) |
| return sig |
|
|
| sb.dataio.dataset.add_dynamic_item(datasets, audio_pipeline) |
|
|
| bos_index = tokenizer.bos_token_id |
| eos_index = tokenizer.eos_token_id |
| pad_index = tokenizer.pad_token_id |
| prompt = "Transcribe speech to text." |
| print(bos_index, eos_index, pad_index, prompt) |
|
|
| prompt_ids = tokenizer( |
| prompt, return_tensors="pt", add_special_tokens=False |
| ).input_ids.view(-1).tolist() |
|
|
| |
| @sb.utils.data_pipeline.takes("wrd") |
| @sb.utils.data_pipeline.provides( |
| "wrd", "tokens_list", "tokens_bos", "tokens_eos", "tokens", "prompt_len" |
| ) |
| def text_pipeline(wrd): |
| |
| yield wrd |
| tokens_list = tokenizer(wrd, add_special_tokens=False).input_ids |
| yield tokens_list |
| tokens_bos = torch.LongTensor(prompt_ids + [bos_index] + tokens_list ) |
| yield tokens_bos |
| tokens_eos = torch.LongTensor(tokens_list + [eos_index]) |
| yield tokens_eos |
| tokens = torch.LongTensor(tokens_list) |
| yield tokens |
| prompt_len = len(prompt_ids + [bos_index]) |
| yield prompt_len |
|
|
| sb.dataio.dataset.add_dynamic_item(datasets, text_pipeline) |
|
|
| |
| sb.dataio.dataset.set_output_keys( |
| datasets, |
| ["id", "sig", "wrd", "tokens_bos", "tokens_eos", "tokens", "prompt_len"], |
| ) |
|
|
| |
| train_batch_sampler = None |
| valid_batch_sampler = None |
| if hparams["dynamic_batching"]: |
| from speechbrain.dataio.sampler import DynamicBatchSampler |
|
|
| dynamic_hparams_train = hparams["dynamic_batch_sampler_train"] |
| dynamic_hparams_valid = hparams["dynamic_batch_sampler_valid"] |
|
|
| train_batch_sampler = DynamicBatchSampler( |
| train_data, |
| length_func=lambda x: x["duration"], |
| **dynamic_hparams_train, |
| ) |
| valid_batch_sampler = DynamicBatchSampler( |
| valid_data, |
| length_func=lambda x: x["duration"], |
| **dynamic_hparams_valid, |
| ) |
|
|
| return ( |
| train_data, |
| valid_data, |
| test_datasets, |
| tokenizer, |
| train_batch_sampler, |
| valid_batch_sampler, |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| |
| hparams_file, run_opts, overrides = sb.parse_arguments(sys.argv[1:]) |
| with open(hparams_file, encoding="utf-8") as fin: |
| hparams = load_hyperpyyaml(fin, overrides) |
|
|
| |
| sb.utils.distributed.ddp_init_group(run_opts) |
|
|
| |
| from librispeech_prepare import prepare_librispeech |
|
|
| |
| sb.create_experiment_directory( |
| experiment_directory=hparams["output_folder"], |
| hyperparams_to_save=hparams_file, |
| overrides=overrides, |
| ) |
|
|
| |
| run_on_main( |
| prepare_librispeech, |
| kwargs={ |
| "data_folder": hparams["data_folder"], |
| "tr_splits": hparams["train_splits"], |
| "dev_splits": hparams["dev_splits"], |
| "te_splits": hparams["test_splits"], |
| "save_folder": hparams["output_folder"], |
| "merge_lst": hparams["train_splits"], |
| "merge_name": "train.csv", |
| "skip_prep": hparams["skip_prep"], |
| }, |
| ) |
|
|
| |
| tokenizer = hparams["llm"].tokenizer |
| |
| ( |
| train_data, |
| valid_data, |
| test_datasets, |
| tokenizer, |
| train_bsampler, |
| valid_bsampler, |
| ) = dataio_prepare(hparams, tokenizer) |
|
|
| |
| asr_brain = ASR( |
| modules=hparams["modules"], |
| opt_class=hparams["Adam"], |
| hparams=hparams, |
| run_opts=run_opts, |
| checkpointer=hparams["checkpointer"], |
| ) |
| |
| asr_brain.tokenizer = tokenizer |
| |
| train_dataloader_opts = hparams["train_dataloader_opts"] |
| valid_dataloader_opts = hparams["valid_dataloader_opts"] |
|
|
| if train_bsampler is not None: |
| collate_fn = None |
| if "collate_fn" in train_dataloader_opts: |
| collate_fn = train_dataloader_opts["collate_fn"] |
|
|
| train_dataloader_opts = { |
| "batch_sampler": train_bsampler, |
| "num_workers": hparams["num_workers"], |
| } |
|
|
| if collate_fn is not None: |
| train_dataloader_opts["collate_fn"] = collate_fn |
|
|
| if valid_bsampler is not None: |
| collate_fn = None |
| if "collate_fn" in valid_dataloader_opts: |
| collate_fn = valid_dataloader_opts["collate_fn"] |
|
|
| valid_dataloader_opts = {"batch_sampler": valid_bsampler} |
|
|
| if collate_fn is not None: |
| valid_dataloader_opts["collate_fn"] = collate_fn |
| |
| asr_brain.fit( |
| asr_brain.hparams.epoch_counter, |
| train_data, |
| valid_data, |
| train_loader_kwargs=train_dataloader_opts, |
| valid_loader_kwargs=valid_dataloader_opts, |
| ) |
|
|
| |
| os.makedirs(hparams["output_wer_folder"], exist_ok=True) |
|
|
| for k in test_datasets.keys(): |
| asr_brain.hparams.test_wer_file = os.path.join( |
| hparams["output_wer_folder"], f"wer_{k}.txt" |
| ) |
| asr_brain.evaluate( |
| test_datasets[k], |
| min_key="WER", |
| test_loader_kwargs=hparams["test_dataloader_opts"], |
| ) |
|
|