diff --git a/docs/source/en/model_doc/voxtral.md b/docs/source/en/model_doc/voxtral.md index 3740a25d50f6..b322b76f24e4 100644 --- a/docs/source/en/model_doc/voxtral.md +++ b/docs/source/en/model_doc/voxtral.md @@ -67,7 +67,7 @@ conversation = [ } ] -inputs = processor.apply_chat_template(conversation) +inputs = processor.apply_chat_template(conversation, tokenize=True, return_dict=True) inputs = inputs.to(model.device) outputs = model.generate(**inputs, max_new_tokens=500) @@ -107,7 +107,7 @@ conversation = [ } ] -inputs = processor.apply_chat_template(conversation) +inputs = processor.apply_chat_template(conversation, tokenize=True, return_dict=True) inputs = inputs.to(model.device) outputs = model.generate(**inputs, max_new_tokens=500) @@ -161,7 +161,7 @@ conversation = [ }, ] -inputs = processor.apply_chat_template(conversation) +inputs = processor.apply_chat_template(conversation, tokenize=True, return_dict=True) inputs = inputs.to(model.device) outputs = model.generate(**inputs, max_new_tokens=500) @@ -196,7 +196,7 @@ conversation = [ } ] -inputs = processor.apply_chat_template(conversation) +inputs = processor.apply_chat_template(conversation, tokenize=True, return_dict=True) inputs = inputs.to(model.device) outputs = model.generate(**inputs, max_new_tokens=500) @@ -231,7 +231,7 @@ conversation = [ } ] -inputs = processor.apply_chat_template(conversation) +inputs = processor.apply_chat_template(conversation, tokenize=True, return_dict=True) inputs = inputs.to(model.device) outputs = model.generate(**inputs, max_new_tokens=500) @@ -288,7 +288,7 @@ conversations = [ ], ] -inputs = processor.apply_chat_template(conversations) +inputs = processor.apply_chat_template(conversations, tokenize=True, return_dict=True) inputs = inputs.to(model.device) outputs = model.generate(**inputs, max_new_tokens=500) @@ -316,10 +316,10 @@ processor = AutoProcessor.from_pretrained(repo_id) model = VoxtralForConditionalGeneration.from_pretrained(repo_id, device_map="auto") # set the language is already know for better accuracy -inputs = processor.apply_transcription_request(language="en", audio="https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/obama.mp3", model_id=repo_id) +inputs = processor.apply_transcription_request(language="en", audio="https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/obama.mp3", model_id=repo_id, tokenize=True, return_dict=True) # # but you can also let the model detect the language automatically -# inputs = processor.apply_transcription_request(audio="https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/obama.mp3", model_id=repo_id) +# inputs = processor.apply_transcription_request(audio="https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/obama.mp3", model_id=repo_id, tokenize=True, return_dict=True) inputs = inputs.to(model.device) outputs = model.generate(**inputs, max_new_tokens=500) @@ -346,6 +346,8 @@ This model was contributed by [Eustache Le Bihan](https://huggingface.co/eustlb) [[autodoc]] VoxtralProcessor - __call__ + - apply_chat_template + - apply_transcription_request ## VoxtralEncoder diff --git a/src/transformers/models/voxtral/modeling_voxtral.py b/src/transformers/models/voxtral/modeling_voxtral.py index cc1c640b36be..b017bb949abb 100644 --- a/src/transformers/models/voxtral/modeling_voxtral.py +++ b/src/transformers/models/voxtral/modeling_voxtral.py @@ -538,7 +538,7 @@ def forward( } ] - >>> inputs = processor.apply_chat_template(conversation) + >>> inputs = processor.apply_chat_template(conversation, tokenize=True, return_dict=True) >>> inputs = inputs.to(device, dtype=torch.bfloat16) >>> outputs = model.generate(**inputs, max_new_tokens=30) diff --git a/src/transformers/models/voxtral/modular_voxtral.py b/src/transformers/models/voxtral/modular_voxtral.py index bc280ce35425..9dc94467fb6f 100644 --- a/src/transformers/models/voxtral/modular_voxtral.py +++ b/src/transformers/models/voxtral/modular_voxtral.py @@ -308,7 +308,7 @@ def forward( } ] - >>> inputs = processor.apply_chat_template(conversation) + >>> inputs = processor.apply_chat_template(conversation, tokenize=True, return_dict=True) >>> inputs = inputs.to(device, dtype=torch.bfloat16) >>> outputs = model.generate(**inputs, max_new_tokens=30) diff --git a/src/transformers/models/voxtral/processing_voxtral.py b/src/transformers/models/voxtral/processing_voxtral.py index 19b2cee47f3e..d5e451041072 100644 --- a/src/transformers/models/voxtral/processing_voxtral.py +++ b/src/transformers/models/voxtral/processing_voxtral.py @@ -13,6 +13,9 @@ # limitations under the License. import io +import warnings + +import numpy as np from ...utils import ( auto_docstring, @@ -37,12 +40,16 @@ from ...audio_utils import AudioInput, load_audio_as, make_list_of_audio from ...feature_extraction_utils import BatchFeature from ...processing_utils import AudioKwargs, ProcessingKwargs, ProcessorMixin, Unpack -from ...tokenization_utils_base import PreTokenizedInput, TextInput -from ...utils.chat_template_utils import _get_template_variables +from ...tokenization_mistral_common import MistralCommonBackend +from ...tokenization_utils_base import EncodedInput, PreTokenizedInput, TextInput logger = logging.get_logger(__name__) +# Fallbacks used when the tokenizer does not expose a `mistral-common` audio encoder to derive them from. +DEFAULT_AUDIO_TOKEN_ID = 24 +DEFAULT_RAW_AUDIO_LENGTH_PER_TOK = 1280 + class VoxtralAudioKwargs(AudioKwargs, total=False): """ @@ -58,6 +65,7 @@ class VoxtralProcessorKwargs(ProcessingKwargs, total=False): _defaults = { "text_kwargs": { "padding": True, + "add_special_tokens": False, }, "audio_kwargs": { "sampling_rate": 16000, @@ -68,25 +76,68 @@ class VoxtralProcessorKwargs(ProcessingKwargs, total=False): }, "common_kwargs": { "return_tensors": "pt", - "return_dict": True, - "tokenize": True, }, } -@requires(backends=("torch",)) +@requires(backends=("torch", "mistral-common")) @auto_docstring class VoxtralProcessor(ProcessorMixin): + valid_processor_kwargs = VoxtralProcessorKwargs + def __init__( self, feature_extractor, tokenizer, ): - self.audio_token_id = 24 + if not isinstance(tokenizer, MistralCommonBackend): + raise ValueError("`tokenizer` must be a `MistralCommonBackend` tokenizer.") + + audio_encoder = self._get_audio_encoder(tokenizer) + special_ids = getattr(audio_encoder, "special_ids", None) + audio_token_id = getattr(special_ids, "audio", None) + self.audio_token_id = DEFAULT_AUDIO_TOKEN_ID if audio_token_id is None else audio_token_id self.audio_token = tokenizer.convert_ids_to_tokens(self.audio_token_id) super().__init__(feature_extractor, tokenizer) + @staticmethod + def _get_audio_encoder(tokenizer): + """`mistral-common`'s audio encoder, when the tokenizer exposes one.""" + mistral_tokenizer = getattr(tokenizer, "tokenizer", None) + instruct_tokenizer = getattr(mistral_tokenizer, "instruct_tokenizer", None) + return getattr(instruct_tokenizer, "audio_encoder", None) + + @staticmethod + def _resolve_tokenize_and_return_dict(tokenize, return_dict): + """Voxtral has always behaved as if both were `True`, unlike `ProcessorMixin` which defaults to `False`.""" + if tokenize is None or return_dict is None: + warnings.warn( + "`VoxtralProcessor` currently defaults to `tokenize=True, return_dict=True`, which differs from the " + "`ProcessorMixin` defaults. In a future version these defaults will change to `tokenize=False, " + "return_dict=False`. Pass `tokenize=True, return_dict=True` explicitly to keep the current behavior " + "and silence this warning.", + FutureWarning, + stacklevel=3, + ) + return True if tokenize is None else tokenize, True if return_dict is None else return_dict + + @property + def mistral_common_audio_config(self): + """`mistral-common`'s audio config, when the tokenizer exposes one.""" + return getattr(self._get_audio_encoder(self.tokenizer), "audio_config", None) + + @property + def raw_audio_length_per_tok(self) -> int: + """Number of raw audio samples represented by a single `audio_token`.""" + length_per_tok = getattr(self.mistral_common_audio_config, "raw_audio_length_per_tok", None) + return DEFAULT_RAW_AUDIO_LENGTH_PER_TOK if length_per_tok is None else length_per_tok + + @property + def unused_input_names(self) -> list[str]: + "Input names returned always by subprocessors but not used in model's `forward`" + return ["num_audio_tokens"] + def _retrieve_input_features(self, audio, max_source_positions, **kwargs): """ Handles specific logic of Voxtral expected input features: audio arrays should be padded to next multiple of 480000 (duration is a multiple of 30s), see VoxtralProcessorKwargs' default audio_kwargs. @@ -104,6 +155,88 @@ def _retrieve_input_features(self, audio, max_source_positions, **kwargs): return torch.cat(input_features_list) + def _get_audio_token_length(self, audio_lengths: "torch.Tensor", pad_to_multiple_of: int) -> "torch.Tensor": + """ + Number of `audio_token` placeholders for each audio, once padded to a whole number of 30s chunks. + Both quantities are derived from `mistral-common` so they cannot drift from the tokenizer. + """ + num_chunks = (audio_lengths - 1) // pad_to_multiple_of + 1 + return num_chunks * (pad_to_multiple_of // self.raw_audio_length_per_tok) + + def _process_audio(self, audio: AudioInput, **kwargs): + max_source_positions = kwargs.pop("max_source_positions") + pad_to_multiple_of = kwargs["pad_to_multiple_of"] + + audio_inputs = { + "input_features": self._retrieve_input_features(audio, max_source_positions, **kwargs), + "num_audio_tokens": self._get_audio_token_length( + torch.tensor([np.asarray(audio_array).shape[-1] for audio_array in audio]), pad_to_multiple_of + ), + } + audio_replacements = [self.replace_audio_token(audio_inputs, audio_idx=idx) for idx in range(len(audio))] + + return audio_inputs, audio_replacements + + def replace_audio_token(self, audio_inputs: dict, audio_idx: int) -> str: + return self.audio_token * int(audio_inputs["num_audio_tokens"][audio_idx]) + + def get_text_with_replacements( + self, + text, + images_replacements: list[str] = [], + videos_replacements: list[str] = [], + audio_replacements: list[str] = [], + ): + """ + Same as [`ProcessorMixin.get_text_with_replacements`], but returns **token ids** instead of strings. + + `MistralCommonBackend` never encodes special tokens from a string: `encode("[AUDIO]")` returns the + tokenization of the literal characters, not `audio_token_id`. The placeholder-expanded text therefore + cannot be handed to the tokenizer as text. Since `MistralCommonBackend.__call__` accepts `EncodedInput`, + we encode here and let [`ProcessorMixin.__call__`] tokenize the ids, which still gives us padding, + `attention_mask` and `return_tensors` for free. + + Note that the returned replacement offsets refer to the expanded *text*, not to the returned ids. + """ + + def _is_encoded_input(text): + """Whether `text` is already token ids, e.g. rendered by `apply_chat_template` through `mistral-common`.""" + return not isinstance(text[0], str) + + if _is_encoded_input(text): + return text, [] + + text, replacement_offsets = super().get_text_with_replacements( + text, images_replacements, videos_replacements, audio_replacements + ) + return [self._encode_with_audio_tokens(sample) for sample in text], replacement_offsets + + def _encode_with_audio_tokens(self, text: str) -> list[int]: + """Encode `text`, mapping every `audio_token` occurrence to `audio_token_id`.""" + segments = text.split(self.audio_token) + input_ids = self.tokenizer.encode(segments[0]) + for segment in segments[1:]: + input_ids.append(self.audio_token_id) + input_ids += self.tokenizer.encode(segment, add_special_tokens=False) + + return input_ids + + def _check_special_mm_tokens(self, text, text_inputs: BatchFeature, modalities: list[str]): + """`text` holds token ids here (see `get_text_with_replacements`), so count ids on both sides.""" + expected = [list(ids).count(self.audio_token_id) for ids in text] + got = [list(ids).count(self.audio_token_id) for ids in text_inputs["input_ids"]] + if expected != got: + raise ValueError( + f"Mismatch in `audio` token count between text and `input_ids`. Got ids={got} and text={expected}. " + "Likely due to `truncation='max_length'`. Please disable truncation or increase `max_length`." + ) + + def validate_inputs(self, images=None, text=None, videos=None, audio=None, **kwargs): + super().validate_inputs(images=images, text=text, videos=videos, audio=audio, **kwargs) + + if text is None: + raise ValueError(f"You need to specify `text` input to {self.__class__.__name__}.") + def apply_chat_template( self, conversation: list[dict[str, str]] | list[list[dict[str, str]]], @@ -113,9 +246,9 @@ def apply_chat_template( add_generation_prompt: bool = False, continue_final_message: bool = False, return_assistant_tokens_mask: bool = False, - tokenize: bool = False, + tokenize: bool | None = None, return_tensors: str | None = None, - return_dict: bool = False, + return_dict: bool | None = None, load_audio_from_video: bool = False, processor_kwargs: dict | None = None, **kwargs, @@ -153,20 +286,28 @@ def apply_chat_template( ] processor = VoxtralProcessor.from_pretrained("mistralai/Voxtral-Mini-3B-2507") - inputs = processor.apply_chat_template(conversation) + inputs = processor.apply_chat_template(conversation, tokenize=True, return_dict=True) ``` Args: conversation (`Union[list[Dict, [str, str]], list[list[dict[str, str]]]]`): The conversation to format. """ - if continue_final_message: - if add_generation_prompt: - raise ValueError( - "continue_final_message and add_generation_prompt are not compatible. Use continue_final_message when you want the model to continue the final message, and add_generation_prompt when you want to add a header that will prompt it to start a new assistant message instead." - ) - if return_assistant_tokens_mask: - raise ValueError("continue_final_message is not compatible with return_assistant_tokens_mask.") + + tokenize, return_dict = self._resolve_tokenize_and_return_dict(tokenize, return_dict) + + if chat_template is not None: + raise ValueError( + f"{self.__class__.__name__} renders conversations with `mistral-common`, not with a Jinja template, " + "so `chat_template` is not supported." + ) + if documents is not None: + raise ValueError(f"`documents` is not supported by {self.__class__.__name__}.") + if return_assistant_tokens_mask: + raise ValueError( + "`return_assistant_tokens_mask` is not supported by `MistralCommonBackend`, which cannot return " + "the offset mapping needed to infer token boundaries." + ) if isinstance(conversation, (list, tuple)) and ( isinstance(conversation[0], (list, tuple)) or hasattr(conversation[0], "content") @@ -177,77 +318,58 @@ def apply_chat_template( is_batched = False conversations = [conversation] - # Users might still be passing processing kwargs in `**kwargs` so we need to filter - # out additional kwargs that the template expects via Jinja2 template introspection - # We strip unrelated kwargs to avoid passing unrecognized kwargs to `_merge_kwargs`. + # Users might still be passing processing kwargs in `**kwargs`. There is no Jinja template to introspect + # here, so anything left in `**kwargs` is meant for `__call__`. processor_kwargs = processor_kwargs or {} - template_kwargs = _get_template_variables(chat_template) - processor_kwargs_from_kwargs = {k: v for k, v in kwargs.items() if k not in template_kwargs} - if processor_kwargs_from_kwargs: + if kwargs: logger.warning( "Kwargs passed to `processor.__call__` have to be in `processor_kwargs` dict, not in `**kwargs`" ) - processor_kwargs = processor_kwargs_from_kwargs + processor_kwargs = {**processor_kwargs, **kwargs} - if return_tensors: + if return_tensors is not None: processor_kwargs["return_tensors"] = return_tensors - output_kwargs = self._merge_kwargs( - VoxtralProcessorKwargs, - **processor_kwargs, - ) - text_kwargs = output_kwargs["text_kwargs"] - audio_kwargs = output_kwargs["audio_kwargs"] - return_tensors = text_kwargs.get("return_tensors", None) - if return_tensors != "pt": - raise ValueError(f"{self.__class__.__name__} only supports `return_tensors='pt'`.") - - tokenizer_kwargs = output_kwargs["text_kwargs"] - tokenizer_kwargs["return_tensors"] = None # let's not return tensors here - encoded_instruct_inputs = self.tokenizer.apply_chat_template(conversations, **tokenizer_kwargs) - - if text_kwargs.get("tokenize", False): - if text_kwargs.get("return_dict", False): - audio = encoded_instruct_inputs.pop("audio", None) - data = dict(encoded_instruct_inputs) - if audio is not None: - max_source_positions = audio_kwargs.pop("max_source_positions") - data["input_features"] = self._retrieve_input_features(audio, max_source_positions, **audio_kwargs) - - return BatchFeature(data=data, tensor_type=return_tensors) + # `mistral-common` cannot batch audio into tensors, so always render without them and let `__call__` do it. + rendered = self.tokenizer.apply_chat_template( + conversations, + tools=tools, + add_generation_prompt=add_generation_prompt, + continue_final_message=continue_final_message, + tokenize=tokenize, + return_dict=True, + return_tensors=None, + ) - if not is_batched: - return encoded_instruct_inputs[0] + if tokenize: + audio = rendered.pop("audio", None) + if return_dict: + return self(text=rendered["input_ids"], audio=audio, **processor_kwargs) + else: + return rendered["input_ids"] - return encoded_instruct_inputs + return rendered if is_batched else rendered[0] @auto_docstring( custom_intro=r""" - Method to prepare text to be fed as input to the model. This method forwards the `text` - arguments to MistralCommonBackend's [`~MistralCommonBackend.__call__`] to encode - the text. Please refer to the docstring of the above methods for more information. - This method does not support audio. To prepare the audio, please use: - 1. `apply_chat_template` [`~VoxtralProcessor.apply_chat_template`] method. - 2. `apply_transcription_request` [`~VoxtralProcessor.apply_transcription_request`] method. + Method to prepare text and audio to be fed as input to the model. + + `text` is either a string containing one `audio_token` per audio (which is expanded to the right number of + placeholders here), or the token ids rendered by + [`apply_chat_template`] [`~VoxtralProcessor.apply_chat_template`]. """ ) def __call__( self, - text: TextInput | PreTokenizedInput | list[TextInput] | list[PreTokenizedInput] | None, + text: TextInput | PreTokenizedInput | list[TextInput] | list[PreTokenizedInput] | EncodedInput | None = None, + audio: AudioInput | None = None, **kwargs: Unpack[VoxtralProcessorKwargs], - ): - if isinstance(text, str): - text = [text] - - if any(self.audio_token in t for t in text): - raise ValueError( - f"{self.audio_token} is present in the provided text which is not supported by VoxtralProcessor. Please use the `apply_chat_template` method instead." - ) - - output_kwargs = self._merge_kwargs(VoxtralProcessorKwargs, **kwargs) - out = self.tokenizer(text, **output_kwargs["text_kwargs"]) + ) -> BatchFeature: + # Check only if passed explicitly as another value since by default we'll use `pt` + if "return_tensors" in kwargs and kwargs["return_tensors"] != "pt": + raise ValueError(f"{self.__class__.__name__} only supports `return_tensors='pt'`.") - return BatchFeature(data=out, tensor_type=output_kwargs["text_kwargs"].get("return_tensors", None)) + return super().__call__(text=text, audio=audio, **kwargs) # TODO: @eustlb, this should be moved to mistral_common + testing @requires(backends=("mistral-common",)) @@ -258,6 +380,8 @@ def apply_transcription_request( language: str | list[str | None] | None = None, sampling_rate: int | None = None, format: str | list[str] | None = None, + tokenize: bool | None = None, + return_dict: bool | None = None, **kwargs: Unpack[VoxtralProcessorKwargs], ): """ @@ -274,10 +398,12 @@ def apply_transcription_request( audio = "https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/obama.mp3" # set the language is already know for better accuracy - inputs = processor.apply_transcription_request(language=language, audio=audio, model_id=model_id) + inputs = processor.apply_transcription_request( + language=language, audio=audio, model_id=model_id, tokenize=True, return_dict=True + ) # but you can also let the model detect the language automatically - inputs = processor.apply_transcription_request(audio=audio, model_id=model_id) + inputs = processor.apply_transcription_request(audio=audio, model_id=model_id, tokenize=True, return_dict=True) ``` Args: @@ -297,11 +423,13 @@ def apply_transcription_request( format (`str`, `list[str]`, *optional*): The format of the audio, necessary if is provided as `np.ndarray`, `torch.Tensor`, `list[np.ndarray]`, `list[torch.Tensor]`. """ + + tokenize, return_dict = self._resolve_tokenize_and_return_dict(tokenize, return_dict) + output_kwargs = self._merge_kwargs( VoxtralProcessorKwargs, **kwargs, ) - text_kwargs = output_kwargs["text_kwargs"] audio_kwargs = output_kwargs["audio_kwargs"] is_str = isinstance(audio, str) @@ -320,16 +448,6 @@ def apply_transcription_request( sampling_rate = audio_kwargs["sampling_rate"] - # make sure to remove from text_kwargs and audio_kwargs - return_dict = text_kwargs.pop("return_dict", False) - tokenize = text_kwargs.pop("tokenize", False) - _ = audio_kwargs.pop("return_dict", False) - _ = audio_kwargs.pop("tokenize", False) - - return_tensors = text_kwargs.pop("return_tensors", None) - if return_tensors != "pt": - raise ValueError(f"{self.__class__.__name__} only supports `return_tensors='pt'`.") - # validate audio input if is_str: audio = [load_audio_as(audio, return_format="buffer", force_mono=True, sampling_rate=sampling_rate)] @@ -397,21 +515,10 @@ def apply_transcription_request( if tokenize: if return_dict: - # text are already tokenized but we need to pad etc - encoding = self.tokenizer( - input_ids, - add_special_tokens=False, - **text_kwargs, - ) - data = dict(encoding) - - # extract the input features - max_source_positions = audio_kwargs.pop("max_source_positions") - data["input_features"] = self._retrieve_input_features( - audio_arrays, max_source_positions, **audio_kwargs - ) - - return BatchFeature(data=data, tensor_type=return_tensors) + # `text` is already tokenized, `__call__` takes care of padding and feature extraction + return self(text=input_ids, audio=audio_arrays, **kwargs) + else: + return input_ids return texts