Files
nvidia-nemo--speech/nemo/collections/tts/models/magpietts_preference_optimization.py
wehub-resource-sync ba4be087d5
Create PR to main with cherry-pick from release / cherry-pick (push) Failing after 0s
CICD NeMo / pre-flight (push) Failing after 0s
CICD NeMo / configure (push) Has been skipped
Build, validate, and release Neural Modules / pre-flight (push) Failing after 1s
CICD NeMo / code-linting (push) Has been skipped
Build, validate, and release Neural Modules / release (push) Has been skipped
Build, validate, and release Neural Modules / release-summary (push) Has been cancelled
CICD NeMo / cicd-test-container-build (push) Has been cancelled
CICD NeMo / cicd-import-tests (push) Has been cancelled
CICD NeMo / L0_Setup_Test_Data_And_Models (push) Has been cancelled
CICD NeMo / cicd-main-unit-tests (push) Has been cancelled
CICD NeMo / cicd-main-speech (push) Has been cancelled
CICD NeMo / Nemo_CICD_Test (push) Has been cancelled
CICD NeMo / Coverage (e2e) (push) Has been cancelled
CICD NeMo / Coverage (unit-test) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CICD NeMo / cicd-wait-in-queue (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:58 +08:00

1060 lines
52 KiB
Python

# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import copy
import json
import os
import random
import numpy as np
import soundfile as sf
import torch
from lightning.pytorch import Trainer
from omegaconf import DictConfig, open_dict
import nemo.collections.asr as nemo_asr
from nemo.collections.asr.metrics.wer import word_error_rate
from nemo.collections.tts.parts.utils.helpers import (
get_speaker_embeddings_from_filepaths,
process_text_for_cer,
transcribe_with_whisper,
transcribe_with_whisper_from_filepaths,
)
from nemo.utils import logging
try:
import torchaudio
from torchaudio.pipelines import SQUIM_OBJECTIVE
HAVE_TORCHAUDIO = True
except ImportError:
HAVE_TORCHAUDIO = False
try:
from nemo_text_processing.text_normalization.normalize import Normalizer
PYNINI_AVAILABLE = True
except (ImportError, ModuleNotFoundError):
Normalizer = None
PYNINI_AVAILABLE = False
from nemo.collections.tts.models import MagpieTTSModel
from nemo.collections.tts.modules.magpietts_modules import add_eos_token, pad_audio_codes
class MagpieTTSModelOfflinePODataGen(MagpieTTSModel):
"""Small override of MagpieTTSModel for parallel multi-GPU inference and metrics calculation.
This class is used in 'test' mode and leverages trainer.test() for multi-GPU/multi-node inference.
Saves the predicted audio files and logs the CER/WER metrics as individual json files for each audio.
"""
def __init__(self, cfg: DictConfig, trainer: 'Trainer' = None):
super().__init__(cfg, trainer)
if cfg.get('pref_set_language', "en") == "en":
self.eval_asr_model = nemo_asr.models.EncDecRNNTBPEModel.from_pretrained(
model_name="nvidia/parakeet-ctc-0.6b"
)
self.eval_asr_model.freeze()
self.eval_speaker_verification_model = nemo_asr.models.EncDecSpeakerLabelModel.from_pretrained(
model_name='titanet_large'
)
self.eval_speaker_verification_model.freeze()
if cfg.get('load_whisper_model', False):
from transformers import WhisperForConditionalGeneration, WhisperProcessor
self.whisper_processor = WhisperProcessor.from_pretrained("openai/whisper-large-v3")
self.whisper_model = WhisperForConditionalGeneration.from_pretrained(
"openai/whisper-large-v3", torch_dtype="auto"
)
self.whisper_model.eval()
self._normalize_whisper_transcript = cfg.get('normalize_whisper_transcript', True)
if self._normalize_whisper_transcript and PYNINI_AVAILABLE:
self._normalizer_cache = {}
# Pre-create normalizer for the configured language
lang = cfg.get('pref_set_language', 'en')
self._get_cached_normalizer(lang)
def _get_cached_normalizer(self, lang_key):
"""Get or create a cached normalizer for the given language."""
if not PYNINI_AVAILABLE:
return None
lang_key = lang_key if lang_key else "en"
if lang_key not in self._normalizer_cache:
logging.info(f"Creating normalizer for language: {lang_key}")
self._normalizer_cache[lang_key] = Normalizer(input_case="cased", lang=lang_key)
return self._normalizer_cache[lang_key]
def test_step(self, batch, batch_idx):
with torch.no_grad():
test_dl_batch_size = self._test_dl.batch_size
self.inference_parameters.max_decoder_steps = self.cfg.get('max_decoder_steps', 500)
self.inference_parameters.temperature = self.cfg.get('inference_temperature', 0.7)
self.inference_parameters.topk = self.cfg.get('inference_topk', 80)
self.inference_parameters.cfg_scale = self.cfg.get('inference_cfg_scale', 1.0)
output = self.infer_batch(
batch,
use_cfg=self.cfg.get('inference_use_cfg', False),
)
predicted_audio = output.predicted_audio
predicted_audio_lens = output.predicted_audio_lens
predicted_codes = output.predicted_codes
predicted_codes_lens = output.predicted_codes_lens
predicted_audio_paths = []
audio_durations = []
batch_invalid = False
for idx in range(predicted_audio.size(0)):
predicted_audio_np = predicted_audio[idx].float().detach().cpu().numpy()
predicted_audio_np = predicted_audio_np[: predicted_audio_lens[idx]]
item_idx = batch_idx * test_dl_batch_size + idx
# Save the predicted audio
log_dir = self.logger.log_dir
audio_dir = os.path.join(log_dir, 'audios')
if not os.path.exists(audio_dir):
os.makedirs(audio_dir)
audio_path = os.path.join(audio_dir, f'predicted_audioRank{self.global_rank}_{item_idx}.wav')
audio_durations.append(len(predicted_audio_np) / self.sample_rate)
sf.write(audio_path, predicted_audio_np, self.sample_rate)
predicted_codes_torch = predicted_codes[idx].cpu().type(torch.int16)
predicted_codes_torch = predicted_codes_torch[:, : predicted_codes_lens[idx]]
torch.save(
predicted_codes_torch,
os.path.join(audio_dir, f'predicted_audioRank{self.global_rank}_{item_idx}_codes.pt'),
)
predicted_audio_paths.append(audio_path)
if not batch_invalid:
with torch.no_grad():
try:
if self.cfg.get("pref_set_language", "en") == "en":
pred_transcripts = self.eval_asr_model.transcribe(
predicted_audio_paths, batch_size=len(predicted_audio_paths)
)
pred_transcripts = [
process_text_for_cer(transcript.text) for transcript in pred_transcripts
]
else:
pred_transcripts = []
for audio_path in predicted_audio_paths:
normalizer = (
self._get_cached_normalizer(self.cfg.pref_set_language)
if self._normalize_whisper_transcript
else None
)
transcript = transcribe_with_whisper(
audio_path,
self.cfg.pref_set_language,
self.whisper_processor,
self.whisper_model,
self.device,
normalizer,
)
pred_transcripts.append(transcript)
pred_transcripts = [
process_text_for_cer(transcript) for transcript in pred_transcripts
]
except Exception as e:
assert (
predicted_audio_lens[idx] < 1000
).any(), f"Expected short audio file to be the only cause of ASR errors, but got error with lengths {predicted_audio_lens}"
logging.warning(f"Exception during ASR transcription: {e}")
logging.warning(
"Skipping processing of the batch; generating metrics indicating a WER of 100% and Speaker Similarity of 0.0"
)
batch_invalid = True
continue # don't break since we want to continue building audio durations list
pred_speaker_embeddings = get_speaker_embeddings_from_filepaths(
predicted_audio_paths, self.eval_speaker_verification_model, self.device
)
gt_speaker_embeddings = get_speaker_embeddings_from_filepaths(
batch['audio_filepaths'], self.eval_speaker_verification_model, self.device
)
for idx in range(predicted_audio.size(0)):
if not batch_invalid:
item_idx = batch_idx * test_dl_batch_size + idx
pred_transcript = pred_transcripts[idx]
gt_transcript = process_text_for_cer(batch['raw_texts'][idx])
cer_gt = word_error_rate([pred_transcript], [gt_transcript], use_cer=True)
wer_gt = word_error_rate([pred_transcript], [gt_transcript], use_cer=False)
spk_embedding_pred = pred_speaker_embeddings[idx].cpu().numpy()
spk_embedding_gt = gt_speaker_embeddings[idx].cpu().numpy()
spk_similarity = np.dot(spk_embedding_pred, spk_embedding_gt) / (
np.linalg.norm(spk_embedding_pred) * np.linalg.norm(spk_embedding_gt)
)
else:
# Create an entry indicating invalid metrics
cer_gt = 1.0
wer_gt = 1.0
spk_similarity = 0.0
pred_transcript = "<INVALID>" # do not change this string; subsequent processing relies on it
gt_transcript = process_text_for_cer(batch['raw_texts'][idx])
item_metrics = {
'cer_gt': float(cer_gt),
'wer_gt': float(wer_gt),
'duration': audio_durations[idx],
'spk_similarity': float(spk_similarity),
'pred_transcript': pred_transcript,
'gt_transcript': gt_transcript,
}
with open(
os.path.join(audio_dir, f'predicted_audioRank{self.global_rank}_{item_idx}_metrics.json'), 'w'
) as f:
json.dump(item_metrics, f)
class MagpieTTSModelOfflinePO(MagpieTTSModel):
"""
MagpieTTS_Model_OfflinePO is a class that extends MagpieTTS_Model to support
offline preference optimization (DPO, IPO, RPO).
Set cfg.model.dpo_loss_type to 'dpo', 'ipo', or 'rpo' to use the corresponding loss.
"""
def __init__(self, cfg: DictConfig, trainer: 'Trainer' = None):
super().__init__(cfg, trainer)
ref_model_cfg = copy.deepcopy(cfg)
with open_dict(ref_model_cfg):
ref_model_cfg.train_ds = None
ref_model_cfg.validation_ds = None
self._reference_model = MagpieTTSModel(cfg=ref_model_cfg)
print("Loading reference model from checkpoint")
self._reference_model.load_state_dict(
torch.load(cfg.reference_model_ckpt_path, map_location="cpu")['state_dict']
)
self._reference_model.freeze()
self._reference_model._no_state_dict = True
print("Reference model loaded and frozen")
def state_dict(self, destination=None, prefix='', keep_vars=False):
state_dict = super().state_dict(destination, prefix, keep_vars)
keys_substrings_to_exclude = ['_speaker_verification_model', '_codec_model', '_reference_model']
for key in list(state_dict.keys()):
if any([substring in key for substring in keys_substrings_to_exclude]):
del state_dict[key]
return state_dict
def _get_batch_logps(self, logits, labels, loss_mask, average_log_prob=False):
"""Compute the log probabilities of the given labels under the given logits.
Args:
logits: Logits of the model (unnormalized). Shape: (batch_size, sequence_length, vocab_size)
labels: Labels for which to compute the log probabilities. Label tokens with a value of -100 are ignored. Shape: (batch_size, sequence_length)
average_log_prob: If True, return the average log probability per (non-masked) token. Otherwise, return the sum of the log probabilities of the (non-masked) tokens.
Returns:
A tensor of shape (batch_size,) containing the average/sum log probabilities of the given labels under the given logits.
"""
per_token_logps = torch.gather(logits.log_softmax(-1), dim=2, index=labels.unsqueeze(2)).squeeze(2)
if average_log_prob:
return (per_token_logps * loss_mask).sum(-1) / loss_mask.sum(-1)
else:
return (per_token_logps * loss_mask).sum(-1)
def preference_loss(
self,
policy_chosen_logps,
policy_rejected_logps,
reference_chosen_logps,
reference_rejected_logps,
chosen_gt_rewards=None,
rejected_gt_rewards=None,
beta=0.2,
gt_reward_scale=1.0,
label_smoothing=0,
loss_type="dpo",
reference_free=False,
):
"""Compute the DPO loss for a batch of policy and reference model log probabilities.
Referenced From: https://github.com/eric-mitchell/direct-preference-optimization/blob/main/trainers.py
Args:
policy_chosen_logps: Log probabilities of the policy model for the chosen responses. Shape: (batch_size,)
policy_rejected_logps: Log probabilities of the policy model for the rejected responses. Shape: (batch_size,)
reference_chosen_logps: Log probabilities of the reference model for the chosen responses. Shape: (batch_size,)
reference_rejected_logps: Log probabilities of the reference model for the rejected responses. Shape: (batch_size,)
beta: Temperature parameter for the DPO loss, typically something in the range of 0.1 to 0.5. We ignore the reference model as beta -> 0.
label_smoothing: conservativeness for DPO loss, which assumes that preferences are noisy (flipped with probability label_smoothing)
ipo: If True, use the IPO loss instead of the DPO loss.
reference_free: If True, we ignore the _provided_ reference model and implicitly use a reference model that assigns equal probability to all responses.
Returns:
A tuple of three tensors: (losses, chosen_rewards, rejected_rewards).
The losses tensor contains the DPO loss for each example in the batch.
The chosen_rewards and rejected_rewards tensors contain the rewards for the chosen and rejected responses, respectively.
"""
pi_logratios = policy_chosen_logps - policy_rejected_logps
ref_logratios = reference_chosen_logps - reference_rejected_logps
if reference_free:
ref_logratios = 0
logits = pi_logratios - ref_logratios # also known as h_{\pi_\theta}^{y_w,y_l}
# logits = (policy_chosen_logps - policy_rejected_logps) - (reference_chosen_logps - reference_rejected_logps)
# logits = (policy_chosen_logps - reference_chosen_logps) - (policy_rejected_logps - reference_rejected_logps)
# logits is the same as rewards_delta in NeMo aligner: https://github.com/NVIDIA/NeMo-Aligner/blob/0b5bffeb78a8316dd57e0816a2a9544540f0c8dd/nemo_aligner/models/nlp/gpt/megatron_gpt_dpo_model.py#L241
if loss_type == "ipo":
losses = (logits - 1 / (2 * beta)) ** 2 # Eq. 17 of https://arxiv.org/pdf/2310.12036v2.pdf
elif loss_type == "rpo":
# https://github.com/NVIDIA/NeMo-Aligner/blob/0b5bffeb78a8316dd57e0816a2a9544540f0c8dd/nemo_aligner/models/nlp/gpt/megatron_gpt_dpo_model.py#L241
logbeta_hat_chosen = torch.nn.functional.logsigmoid(beta * logits)
logbeta_hat_rejected = torch.nn.functional.logsigmoid(-beta * logits)
gt_rewards_delta = gt_reward_scale * (chosen_gt_rewards - rejected_gt_rewards)
logalpha_hat_chosen = torch.nn.functional.logsigmoid(gt_rewards_delta)
logalpha_hat_rejected = torch.nn.functional.logsigmoid(-gt_rewards_delta)
losses = torch.exp(logalpha_hat_chosen) * (logalpha_hat_chosen - logbeta_hat_chosen) + torch.exp(
logalpha_hat_rejected
) * (logalpha_hat_rejected - logbeta_hat_rejected)
elif loss_type == "rpo_sq":
gt_rewards_delta = gt_reward_scale * (chosen_gt_rewards - rejected_gt_rewards)
losses = (beta * logits - gt_rewards_delta) ** 2
elif loss_type == "dpo":
# Eq. 3 https://ericmitchell.ai/cdpo.pdf; label_smoothing=0 gives original DPO (Eq. 7 of https://arxiv.org/pdf/2305.18290.pdf)
F = torch.nn.functional
losses = (
-F.logsigmoid(beta * logits) * (1 - label_smoothing) - F.logsigmoid(-beta * logits) * label_smoothing
)
else:
raise NotImplementedError("loss type {} is not implemented".format(loss_type))
chosen_rewards = beta * (policy_chosen_logps - reference_chosen_logps).detach()
rejected_rewards = beta * (policy_rejected_logps - reference_rejected_logps).detach()
return losses, chosen_rewards, rejected_rewards
def process_batch_dpo(self, batch_chosen_rejected):
batch_chosen = batch_chosen_rejected['chosen']
batch_rejected = batch_chosen_rejected['rejected']
model_output_chosen = self.process_batch(batch_chosen)
model_output_rejected = self.process_batch(batch_rejected)
with torch.no_grad():
reference_model_output_chosen = self._reference_model.process_batch(batch_chosen)
reference_model_output_rejected = self._reference_model.process_batch(batch_rejected)
chosen_policy_logprobs = None
rejected_policy_logprobs = None
chosen_ref_logprobs = None
rejected_ref_logprobs = None
for codebook_idx in range(self.num_audio_codebooks):
si = codebook_idx * self.num_all_tokens_per_codebook
ei = si + self.num_all_tokens_per_codebook
codebook_logits_chosen = model_output_chosen['logits'][:, :, si:ei]
codebook_logits_rejected = model_output_rejected['logits'][:, :, si:ei]
ref_codebook_logits_chosen = reference_model_output_chosen['logits'][:, :, si:ei]
ref_codebook_logits_rejected = reference_model_output_rejected['logits'][:, :, si:ei]
codebook_labels_chosen = model_output_chosen['audio_codes_target'][:, codebook_idx]
codebook_labels_rejected = model_output_rejected['audio_codes_target'][:, codebook_idx]
codebook_log_probs_chosen = self._get_batch_logps(
codebook_logits_chosen, codebook_labels_chosen, model_output_chosen['loss_mask'][:, codebook_idx]
)
codebook_log_probs_rejected = self._get_batch_logps(
codebook_logits_rejected, codebook_labels_rejected, model_output_rejected['loss_mask'][:, codebook_idx]
)
with torch.no_grad():
ref_codebook_log_probs_chosen = self._get_batch_logps(
ref_codebook_logits_chosen,
codebook_labels_chosen,
reference_model_output_chosen['loss_mask'][:, codebook_idx],
)
ref_codebook_log_probs_rejected = self._get_batch_logps(
ref_codebook_logits_rejected,
codebook_labels_rejected,
reference_model_output_rejected['loss_mask'][:, codebook_idx],
)
if chosen_policy_logprobs is None:
chosen_policy_logprobs = codebook_log_probs_chosen
rejected_policy_logprobs = codebook_log_probs_rejected
chosen_ref_logprobs = ref_codebook_log_probs_chosen
rejected_ref_logprobs = ref_codebook_log_probs_rejected
else:
chosen_policy_logprobs += codebook_log_probs_chosen
rejected_policy_logprobs += codebook_log_probs_rejected
chosen_ref_logprobs += ref_codebook_log_probs_chosen
rejected_ref_logprobs += ref_codebook_log_probs_rejected
rewards_chosen = batch_chosen['rewards']
rewards_rejected = batch_rejected['rewards']
assert torch.all(rewards_chosen == 1)
assert torch.all(rewards_rejected < 1)
pref_loss, chosen_rewards, rejected_rewards = self.preference_loss(
chosen_policy_logprobs,
rejected_policy_logprobs,
chosen_ref_logprobs,
rejected_ref_logprobs,
chosen_gt_rewards=rewards_chosen,
rejected_gt_rewards=rewards_rejected,
beta=self.cfg.get('dpo_beta', 0.01),
loss_type=self.cfg.get('dpo_loss_type', 'dpo'),
)
pref_loss = pref_loss.mean()
sft_loss = -chosen_policy_logprobs.mean()
pref_loss_weight = self.cfg.get('dpo_pref_loss_weight', 1.0)
sft_loss_weight = self.cfg.get('dpo_sft_loss_weight', 0.0)
loss = pref_loss_weight * pref_loss + sft_loss * sft_loss_weight
alignment_loss = model_output_chosen['alignment_loss']
if alignment_loss is not None:
loss += alignment_loss
return {
'loss': loss,
'pref_loss': pref_loss,
'sft_loss': sft_loss,
'alignment_loss': alignment_loss,
}
def training_step(self, batch, batch_idx):
dpo_outputs = self.process_batch_dpo(batch)
self.log('train_loss', dpo_outputs['loss'], prog_bar=True, sync_dist=True)
self.log('train_pref_loss', dpo_outputs['pref_loss'], prog_bar=True, sync_dist=True)
self.log('train_sft_loss', dpo_outputs['sft_loss'], prog_bar=True, sync_dist=True)
return dpo_outputs['loss']
def validation_step(self, batch, batch_idx, dataloader_idx=0):
dpo_outputs = self.process_batch_dpo(batch)
val_loss = dpo_outputs['loss']
val_pref_loss = dpo_outputs['pref_loss']
val_sft_loss = dpo_outputs['sft_loss']
val_alignment_loss = dpo_outputs['alignment_loss']
self.validation_step_outputs[dataloader_idx].append(
{
'val_loss': val_loss,
'val_pref_loss': val_pref_loss,
'val_sft_loss': val_sft_loss,
'val_alignment_loss': val_alignment_loss,
}
)
def on_validation_epoch_end(self):
def collect(key):
values = []
for val_outputs in self.validation_step_outputs:
for x in val_outputs:
if x[key] is not None:
values.append(x[key])
else:
values.append(torch.tensor(0.0, device=self.device))
stacked_values = torch.stack(values)
return stacked_values.mean()
val_loss = collect("val_loss")
val_pref_loss = collect("val_pref_loss")
val_sft_loss = collect("val_sft_loss")
val_alignment_loss = collect("val_alignment_loss")
self.log("val_loss", val_loss, prog_bar=True, sync_dist=True)
self.log("val_pref_loss", val_pref_loss, prog_bar=True, sync_dist=True)
self.log("val_sft_loss", val_sft_loss, prog_bar=True, sync_dist=True)
if val_alignment_loss is not None:
self.log("val_alignment_loss", val_alignment_loss, prog_bar=True, sync_dist=True)
for val_outputs in self.validation_step_outputs:
val_outputs.clear()
class MagpieTTSModelOnlinePO(MagpieTTSModel):
"""
MagpieTTS_Model_OnlinePO is a class that extends MagpieTTS_Model to support
online preference optimization (GRPO).
"""
def __init__(self, cfg: DictConfig, trainer: 'Trainer' = None):
super().__init__(cfg, trainer)
# Copy cfg
ref_model_cfg = copy.deepcopy(cfg)
with open_dict(ref_model_cfg):
ref_model_cfg.train_ds = None
ref_model_cfg.validation_ds = None
self.reference_free = self.cfg.get('reference_free', False) # True means we dont use the reference model
if not self.reference_free:
self._reference_model = MagpieTTSModel(cfg=ref_model_cfg)
print("Loading reference model from checkpoint")
self._reference_model.load_state_dict(
torch.load(cfg.reference_model_ckpt_path, map_location="cpu")['state_dict']
)
self._reference_model.freeze()
self._reference_model._no_state_dict = True
print("Reference model loaded and frozen")
if cfg.get('reward_asr_model', "nemo") == "nemo":
self.eval_asr_model = nemo_asr.models.EncDecRNNTBPEModel.from_pretrained(
model_name="nvidia/parakeet-ctc-0.6b"
)
self.eval_asr_model.freeze()
elif cfg.get('reward_asr_model', "nemo") == "whisper":
from transformers import WhisperForConditionalGeneration, WhisperProcessor
self.whisper_processor = WhisperProcessor.from_pretrained("openai/whisper-large-v3")
self.whisper_model = WhisperForConditionalGeneration.from_pretrained(
"openai/whisper-large-v3", torch_dtype="auto"
)
self.whisper_model.eval()
else:
raise ValueError(f"Unknown reward_asr_model: {cfg.reward_asr_model}")
self.eval_speaker_verification_model = nemo_asr.models.EncDecSpeakerLabelModel.from_pretrained(
model_name='titanet_large'
)
self.eval_speaker_verification_model.freeze()
if cfg.get('load_whisper_model', False):
from transformers import WhisperForConditionalGeneration, WhisperProcessor
self.whisper_processor = WhisperProcessor.from_pretrained("openai/whisper-large-v3")
self.whisper_model = WhisperForConditionalGeneration.from_pretrained(
"openai/whisper-large-v3", torch_dtype="auto"
)
self.whisper_model.eval()
use_pesq = self.cfg.get('use_pesq', False)
if use_pesq:
assert HAVE_TORCHAUDIO, "torchaudio is required for PESQ reward"
self.squim_objective_model = SQUIM_OBJECTIVE.get_model()
self.loss_type = self.cfg.get('loss_type', 'grpo')
if self.loss_type not in ['grpo', 'dr_grpo']:
raise ValueError(
f"Received loss_type of {self.loss_type}, but the model only accepts one of ['grpo', 'dr_grpo']"
)
self.scale_rewards = self.cfg.get('scale_rewards', True)
self.max_decoder_steps = self.cfg.get('max_decoder_steps', 430)
self._normalize_whisper_transcript = self.cfg.get('normalize_whisper_transcript', True)
if cfg.get('reward_asr_model', "nemo") == "whisper" and self._normalize_whisper_transcript:
self._normalizer_cache = {}
# If the best record in the group is above this threshold, we will not use that group for training
# Setting this to 1.0, because we clamp the ASR rewards to be in [0, 1] for OnlinePO
self.best_cer_threshold = self.cfg.get('best_cer_threshold', 1.0)
# If the worst record in the group exceeds this threshold, we will not use that group for training
# Setting this to 1.0, because we clamp the ASR rewards to be in [0, 1] for OnlinePO
self.worst_cer_threshold = self.cfg.get('worst_cer_threshold', 1.0)
def _get_cached_normalizer(self, lang_key):
"""Get or create a cached normalizer for the given language."""
if not PYNINI_AVAILABLE:
return None
lang_key = lang_key if lang_key else "en"
if lang_key not in self._normalizer_cache:
logging.info(f"Creating normalizer for language: {lang_key}")
self._normalizer_cache[lang_key] = Normalizer(input_case="cased", lang=lang_key)
return self._normalizer_cache[lang_key]
def state_dict(self, destination=None, prefix='', keep_vars=False):
state_dict = super().state_dict(destination, prefix, keep_vars)
keys_substrings_to_exclude = [
'_speaker_verification_model',
'_codec_model',
'_reference_model',
'eval_asr_model',
'eval_speaker_verification_model',
'whisper_model',
]
for key in list(state_dict.keys()):
if any([substring in key for substring in keys_substrings_to_exclude]):
del state_dict[key]
return state_dict
def _get_per_token_logps(self, logits, labels, loss_mask):
"""Compute the log probabilities of the given labels under the given logits.
Args:
logits: Logits of the model (unnormalized). Shape: (batch_size, sequence_length, vocab_size)
labels: Labels for which to compute the log probabilities.
"""
per_token_logps = torch.gather(logits.log_softmax(-1), dim=2, index=labels.unsqueeze(2)).squeeze(2)
per_token_logps = per_token_logps * loss_mask
return per_token_logps
def repeat_items_in_batch(self, batch, num_repeats):
repeated_batch = {}
for key, value in batch.items():
if isinstance(value, torch.Tensor):
repeated_value = value.repeat_interleave(num_repeats, dim=0)
elif isinstance(value, list):
repeated_value = []
for item in value:
repeated_value.extend([item] * num_repeats)
else:
repeated_value = value
repeated_batch[key] = repeated_value
return repeated_batch
def generate_and_reward(
self, batch, num_generations_per_item, mode='train', use_local_transformer_for_inference=False
):
batch_repeated = self.repeat_items_in_batch(batch, num_generations_per_item)
use_cfg = False
self.inference_parameters.cfg_scale = 1.0
use_pesq = self.cfg.get('use_pesq', False)
inference_cfg_prob = self.cfg.get('inference_cfg_prob', 0.0)
if (inference_cfg_prob == 1.0) or (inference_cfg_prob > 0.0 and mode == 'train'):
# Randomly set use_cfg based on the given probability
use_cfg = random.random() < self.cfg.inference_cfg_prob
self.inference_parameters.cfg_scale = self.cfg.get('inference_cfg_scale', 1.0)
self.inference_parameters.max_decoder_steps = self.max_decoder_steps
self.inference_parameters.temperature = self.cfg.get('inference_temperature', 0.7)
self.inference_parameters.topk = self.cfg.get('inference_topk', 80)
self.inference_parameters.use_LT_kv_cache = False
output = self.infer_batch(
batch_repeated,
use_cfg=use_cfg,
use_local_transformer_for_inference=use_local_transformer_for_inference,
)
predicted_audio = output.predicted_audio
predicted_audio_lens = output.predicted_audio_lens
predicted_codes = output.predicted_codes
predicted_codes_lens = output.predicted_codes_lens
predicted_audio_paths = []
audio_durations = []
for idx in range(predicted_audio.size(0)):
predicted_audio_np = predicted_audio[idx].float().detach().cpu().numpy()
predicted_audio_np = predicted_audio_np[: predicted_audio_lens[idx]]
if predicted_audio_np.shape[0] < 1000:
# Corner case to handle short audio files
predicted_audio_np = np.pad(predicted_audio_np, (0, 1000 - predicted_audio_np.shape[0]))
item_idx = idx
# Save the predicted audio
log_dir = self.logger.log_dir
audio_dir = os.path.join(log_dir, 'audios')
os.makedirs(audio_dir, exist_ok=True)
audio_path = os.path.join(audio_dir, f'predicted_audioRank{self.global_rank}_{item_idx}.wav')
audio_durations.append(len(predicted_audio_np) / self.sample_rate)
sf.write(audio_path, predicted_audio_np, self.sample_rate)
predicted_codes_torch = predicted_codes[idx].cpu().type(torch.int16)
predicted_codes_torch = predicted_codes_torch[:, : predicted_codes_lens[idx]] # C, T
torch.save(
predicted_codes_torch,
os.path.join(audio_dir, f'predicted_audioRank{self.global_rank}_{item_idx}_codes.pt'),
)
predicted_audio_paths.append(audio_path)
with torch.no_grad():
if self.cfg.get("reward_asr_model", "nemo") == "nemo":
pred_transcripts = self.eval_asr_model.transcribe(
predicted_audio_paths, batch_size=len(predicted_audio_paths)
)
pred_transcripts = [process_text_for_cer(transcript.text) for transcript in pred_transcripts]
elif self.cfg.get("reward_asr_model", "nemo") == "whisper":
pred_transcripts = [""] * len(predicted_audio_paths)
language_groups = {}
for item_idx, audio_path in enumerate(predicted_audio_paths):
language = batch_repeated['languages'][item_idx]
language_groups.setdefault(language, []).append((item_idx, audio_path))
for language, grouped_items in language_groups.items():
normalizer = self._get_cached_normalizer(language) if self._normalize_whisper_transcript else None
grouped_paths = [audio_path for _, audio_path in grouped_items]
grouped_transcripts = transcribe_with_whisper_from_filepaths(
audio_filepaths=grouped_paths,
language=language,
whisper_processor=self.whisper_processor,
whisper_model=self.whisper_model,
device=self.device,
normalizer=normalizer,
)
for (item_idx, _), transcript in zip(grouped_items, grouped_transcripts):
pred_transcripts[item_idx] = transcript
pred_transcripts = [process_text_for_cer(transcript) for transcript in pred_transcripts]
else:
# Address CodeQL issue where pred_transcripts might be undefined for future code
raise ValueError(
f"{self} received a value of {self.cfg.get('reward_asr_model', 'nemo')} in cfg.reward_asr_model "
"but this class only supports 'nemo' or 'whisper'."
)
pred_speaker_embeddings = get_speaker_embeddings_from_filepaths(
predicted_audio_paths, self.eval_speaker_verification_model, self.device
)
gt_speaker_embeddings = get_speaker_embeddings_from_filepaths(
batch_repeated['audio_filepaths'], self.eval_speaker_verification_model, self.device
)
batch_metrics = []
cer_reward_weight = self.cfg.get('cer_reward_weight', 0.5)
ssim_reward_weight = self.cfg.get('ssim_reward_weight', 0.5)
pesq_reward_weight = self.cfg.get('pesq_reward_weight', 0.0)
for idx in range(predicted_audio.size(0)):
audio_path = predicted_audio_paths[idx]
item_idx = idx
pred_transcript = pred_transcripts[idx]
gt_transcript = process_text_for_cer(batch_repeated['raw_texts'][idx])
cer_gt = word_error_rate([pred_transcript], [gt_transcript], use_cer=True)
wer_gt = word_error_rate([pred_transcript], [gt_transcript], use_cer=False)
cer_gt = min(max(cer_gt, 0.0), 1.0) # Ensure CER is in [0, 1]
wer_gt = min(max(wer_gt, 0.0), 1.0) # Ensure WER is in [0, 1]
spk_embedding_pred = pred_speaker_embeddings[idx].cpu().float().numpy()
spk_embedding_gt = gt_speaker_embeddings[idx].cpu().float().numpy()
spk_similarity = np.dot(spk_embedding_pred, spk_embedding_gt) / (
np.linalg.norm(spk_embedding_pred) * np.linalg.norm(spk_embedding_gt)
)
if use_pesq:
sample_audio, sr = torchaudio.load(audio_path)
sample_audio = sample_audio.to(self.device)
if sr != 16000:
sample_audio = torchaudio.functional.resample(sample_audio, sr, 16000)
_, pesq_hyp, _ = self.squim_objective_model(sample_audio)
pesq_hyp = pesq_hyp.item()
item_metrics = {
'cer_gt': float(cer_gt),
'wer_gt': float(wer_gt),
'duration': audio_durations[idx],
'spk_similarity': float(spk_similarity),
'pred_transcript': pred_transcript,
'gt_transcript': gt_transcript,
'codes_len': predicted_codes_lens[idx].item(),
'pesq': pesq_hyp if use_pesq else 0.0,
}
with open(
os.path.join(audio_dir, f'predicted_audioRank{self.global_rank}_{item_idx}_metrics.json'), 'w'
) as f:
json.dump(item_metrics, f)
batch_metrics.append(item_metrics)
num_groups = len(batch['audio_filepaths'])
best_ssim_achievable = self.cfg.get(
"best_ssim_achievable", 0.9
) # Examples with this speaker similarity or higher will have SSIM reward of 1
mean_cer_dataset = self.cfg.get("mean_cer_dataset", 0.1) # CER equal to this value will have reward of 0.5
mean_ssim_dataset = self.cfg.get("mean_ssim_dataset", 0.6) # SSIM equal to this value will have reward of 0.5
all_groups_mean_reward = 0.0
all_groups_std_reward = 0.0
group_validities = []
for group_idx in range(num_groups):
group_start_idx = group_idx * num_generations_per_item
group_end_idx = group_start_idx + num_generations_per_item
group_rewards = []
mean_reward = 0
is_group_valid = True
group_best_cer = 1.0
group_worst_cer = 0.0
for idx in range(group_start_idx, group_end_idx):
# Lower CER and higher speaker similarity is better, means high reward
# Higher pesq is better, means high reward
# Reward for best CER and best speaker similarity should be 1
item_cer = batch_metrics[idx]['cer_gt']
item_ssim = batch_metrics[idx]['spk_similarity']
item_cer = min(max(item_cer, 0.0), 1.0)
item_ssim = max(min(item_ssim, best_ssim_achievable), 0.0)
item_pesq = batch_metrics[idx]['pesq']
group_best_cer = min(group_best_cer, item_cer)
group_worst_cer = max(group_worst_cer, item_cer)
if item_cer <= mean_cer_dataset:
cer_reward = 0.5 + 0.5 * (mean_cer_dataset - item_cer) / mean_cer_dataset # 0.5 to 1
else:
cer_reward = 0.5 - 0.5 * (item_cer - mean_cer_dataset) / (1 - mean_cer_dataset) # 0 to 0.5
if item_ssim >= mean_ssim_dataset:
spk_similarity_reward = 0.5 + 0.5 * (item_ssim - mean_ssim_dataset) / (
best_ssim_achievable - mean_ssim_dataset
)
else:
spk_similarity_reward = 0.5 - 0.5 * (mean_ssim_dataset - item_ssim) / (mean_ssim_dataset)
if use_pesq:
pesq_reward = item_pesq / 4.5
else:
pesq_reward = 0.0
batch_metrics[idx]['reward'] = (
cer_reward * cer_reward_weight
+ spk_similarity_reward * ssim_reward_weight
+ pesq_reward * pesq_reward_weight
)
if (batch_metrics[idx]['codes_len'] >= 425) or (
batch_metrics[idx]['codes_len'] <= 3
): # TODO: Remove hardcoded lengths
# This means it did not complete the sentence or generated an extremely short sentence
batch_metrics[idx]['reward'] = 0.0
print(
"Item idx: ",
idx,
" CER: ",
item_cer,
" SSIM: ",
item_ssim,
" Reward: ",
batch_metrics[idx]['reward'],
" Codes len: ",
batch_metrics[idx]['codes_len'],
)
batch_metrics[idx]['cer_reward'] = cer_reward
batch_metrics[idx]['spk_similarity_reward'] = spk_similarity_reward
batch_metrics[idx]['pesq_reward'] = pesq_reward
mean_reward += batch_metrics[idx]['reward']
group_rewards.append(batch_metrics[idx]['reward'])
if group_best_cer > self.best_cer_threshold:
is_group_valid = False
print(
f"Group {group_idx} has best CER {group_best_cer} which is above the threshold {self.best_cer_threshold}. Group is invalid."
)
if group_worst_cer > self.worst_cer_threshold:
is_group_valid = False
print(
f"Group {group_idx} has worst CER {group_worst_cer} which is above the threshold {self.worst_cer_threshold}. Group is invalid."
)
for _ in range(num_generations_per_item):
group_validities.append(is_group_valid)
mean_reward /= num_generations_per_item
std_reward = np.std(group_rewards)
all_groups_mean_reward += mean_reward
all_groups_std_reward += std_reward
for idx in range(group_start_idx, group_end_idx):
batch_metrics[idx]['advantage'] = batch_metrics[idx]['reward'] - mean_reward
if self.scale_rewards:
batch_metrics[idx]['advantage'] = batch_metrics[idx]['advantage'] / (std_reward + 1e-4)
all_groups_mean_reward = all_groups_mean_reward / num_groups
all_groups_std_reward = all_groups_std_reward / num_groups
advantages = [x['advantage'] for x in batch_metrics]
advantages = torch.tensor(advantages, device=self.device)
print("Mean reward: ", all_groups_mean_reward)
group_validities = torch.tensor(group_validities, device=self.device)
return {
'mean_reward': torch.tensor(all_groups_mean_reward, device=self.device),
'std_reward': torch.tensor(all_groups_std_reward, device=self.device),
'batch_repeated': batch_repeated,
'metrics': batch_metrics,
'predicted_codes': predicted_codes,
'predicted_codes_lens': predicted_codes_lens,
'advantages': advantages,
'group_validities': group_validities,
}
def process_batch_online_po(self, batch, n_generations_per_item, mode='train'):
use_kv_cache_during_online_po = self.cfg.get("use_kv_cache_during_online_po", False)
if use_kv_cache_during_online_po:
self.use_kv_cache_for_inference = True
self.decoder.reset_cache(use_cache=True)
use_local_transformer_for_inference = False
logits_key = 'logits'
use_local_transformer_prob = self.cfg.get('use_local_transformer_prob', 0.0)
if use_local_transformer_prob > 0.0 and mode == 'train':
use_local_transformer_for_inference = random.random() < use_local_transformer_prob
logits_key = 'local_transformer_logits'
with torch.no_grad():
self.eval()
generated_codes_and_metrics = self.generate_and_reward(
batch,
n_generations_per_item,
mode,
use_local_transformer_for_inference=use_local_transformer_for_inference,
)
self.train()
if use_kv_cache_during_online_po:
self.use_kv_cache_for_inference = False
self.decoder.reset_cache(use_cache=False)
batch_repeated = generated_codes_and_metrics['batch_repeated']
predicted_codes = generated_codes_and_metrics['predicted_codes'] # B, 8, T
predicted_codes_lens = generated_codes_and_metrics['predicted_codes_lens'] # B
predicted_codes = predicted_codes[:, :, : predicted_codes_lens.max()]
advantages = generated_codes_and_metrics['advantages'] # B
batch_repeated['audio_codes'] = predicted_codes
batch_repeated['audio_codes_lens'] = predicted_codes_lens
if 'audio' in batch_repeated:
del batch_repeated['audio']
if 'audio_lens' in batch_repeated:
del batch_repeated['audio_lens']
policy_model_outputs = self.process_batch(batch_repeated)
reference_model_output = (
None # Address CodeQL issue even though this varibable is only used not self.reference_free
)
if not self.reference_free:
with torch.no_grad():
reference_model_output = self._reference_model.process_batch(batch_repeated)
codebook_targets, _ = add_eos_token(
codes=predicted_codes, codes_len=predicted_codes_lens, eos_id=self.audio_eos_id
)
codebook_targets = pad_audio_codes(codebook_targets, self.frame_stacking_factor).long()
total_loss = None
total_kl = None
for fs_idx in range(self.frame_stacking_factor):
for codebook_idx in range(self.num_audio_codebooks):
policy_codebook_loss_mask = policy_model_outputs['loss_mask'][
:, codebook_idx, fs_idx :: self.frame_stacking_factor
]
reference_codebook_loss_mask = (
reference_model_output['loss_mask'][:, codebook_idx, fs_idx :: self.frame_stacking_factor]
if not self.reference_free
else None
)
si = (codebook_idx + self.num_audio_codebooks * fs_idx) * self.num_all_tokens_per_codebook
ei = si + self.num_all_tokens_per_codebook
codebook_logits = policy_model_outputs[logits_key][:, :, si:ei] # B, T, C
codebook_labels = codebook_targets[:, codebook_idx, fs_idx :: self.frame_stacking_factor]
per_token_codebook_log_probs = self._get_per_token_logps(
codebook_logits, codebook_labels, policy_codebook_loss_mask
)
per_token_loss = -(
torch.exp(per_token_codebook_log_probs - per_token_codebook_log_probs.detach())
* advantages.unsqueeze(1)
)
group_validities = generated_codes_and_metrics['group_validities'] # B * n_generations_per_item
per_token_loss = per_token_loss * group_validities.unsqueeze(1) # B, T
if not self.reference_free:
with torch.no_grad():
ref_codebook_logits = reference_model_output[logits_key][:, :, si:ei]
per_token_ref_codebook_log_probs = self._get_per_token_logps(
ref_codebook_logits, codebook_labels, reference_codebook_loss_mask
)
# https://github.com/huggingface/trl/blob/ffcb9f4aee725a2bd072d0387afe68a4b1c7967c/trl/trainer/grpo_trainer.py#L703
per_token_codebook_kl = (
torch.exp(per_token_ref_codebook_log_probs - per_token_codebook_log_probs)
- (per_token_ref_codebook_log_probs - per_token_codebook_log_probs)
- 1
)
per_token_loss = per_token_loss + self.cfg.grpo_beta * per_token_codebook_kl
codebook_kl_loss_mean = (
(per_token_codebook_kl * policy_codebook_loss_mask).sum(dim=1)
/ policy_codebook_loss_mask.sum(dim=1)
).mean()
else:
codebook_kl_loss_mean = torch.tensor(0.0, device=self.device)
if self.loss_type == "grpo":
codebook_loss = (
(per_token_loss * policy_codebook_loss_mask).sum(dim=1) / policy_codebook_loss_mask.sum(dim=1)
).mean()
elif self.loss_type == "dr_grpo":
# https://github.com/huggingface/trl/blob/main/trl/trainer/grpo_trainer.py
total_tokens = per_token_loss.shape[0] * self.max_decoder_steps
codebook_loss = (per_token_loss * policy_codebook_loss_mask).sum() / total_tokens
else:
raise ValueError(f"Unknown loss function: {self.loss_type}")
if total_loss is None:
total_loss = codebook_loss
total_kl = codebook_kl_loss_mean
else:
total_loss += codebook_loss
total_kl += codebook_kl_loss_mean
total_loss /= self.num_audio_codebooks * self.frame_stacking_factor
total_kl /= self.num_audio_codebooks * self.frame_stacking_factor
return {
'mean_reward': generated_codes_and_metrics['mean_reward'],
'std_reward': generated_codes_and_metrics['std_reward'],
'loss': total_loss,
'kl_loss': total_kl,
'batch_metrics': generated_codes_and_metrics['metrics'],
}
def training_step(self, batch, batch_idx):
torch.cuda.empty_cache()
n_generations_per_item = self.cfg.get('n_generations_per_item', 6)
po_outputs = self.process_batch_online_po(batch, n_generations_per_item)
self.log('train_loss', po_outputs['loss'], prog_bar=True, sync_dist=True)
self.log('train_kl_loss', po_outputs['kl_loss'], prog_bar=True, sync_dist=True)
self.log('train_mean_reward', po_outputs['mean_reward'], prog_bar=True, sync_dist=True)
self.log('train_std_reward', po_outputs['std_reward'], prog_bar=True, sync_dist=True)
return po_outputs['loss']
def validation_step(self, batch, batch_idx, dataloader_idx=0):
po_outputs = self.process_batch_online_po(batch, 1, mode='val')
batch_metrics = po_outputs['batch_metrics']
mean_reward = po_outputs['mean_reward']
val_loss = po_outputs['loss']
val_kl_loss = po_outputs['kl_loss']
self.validation_step_outputs[dataloader_idx].append(
{
'mean_reward': mean_reward,
'std_reward': po_outputs['std_reward'],
'val_loss': val_loss,
'val_kl_loss': val_kl_loss,
'batch_metrics': batch_metrics,
}
)
def on_validation_epoch_end(self):
def collect(key):
values = []
for val_outputs in self.validation_step_outputs:
for x in val_outputs:
if x[key] is not None:
values.append(x[key])
else:
values.append(torch.tensor(0.0, device=self.device))
stacked_values = torch.stack(values)
return stacked_values.mean()
val_loss = collect("val_loss")
val_kl_loss = collect("val_kl_loss")
mean_reward = collect("mean_reward")
std_reward = collect("std_reward")
self.log("val_loss", val_loss, prog_bar=True, sync_dist=True)
self.log("val_kl_loss", val_kl_loss, prog_bar=True, sync_dist=True)
self.log("val_mean_reward", mean_reward, prog_bar=True, sync_dist=True)
self.log("val_std_reward", std_reward, prog_bar=True, sync_dist=True)
mean_metrics = {}
for val_outputs in self.validation_step_outputs:
for val_output in val_outputs:
batch_metrics = val_output['batch_metrics']
for item_metrics in batch_metrics:
for key, value in item_metrics.items():
if "transcript" not in key:
if key not in mean_metrics:
mean_metrics[key] = []
mean_metrics[key].append(value)
for key, values in mean_metrics.items():
mean_metrics[key] = np.mean(values)
self.log(f"val_{key}", mean_metrics[key], prog_bar=True, sync_dist=True)
for val_outputs in self.validation_step_outputs:
val_outputs.clear()