diff --git a/docs/source/en/_toctree.yml b/docs/source/en/_toctree.yml index 181fb35a0c21..dd600f421167 100644 --- a/docs/source/en/_toctree.yml +++ b/docs/source/en/_toctree.yml @@ -1103,6 +1103,8 @@ title: Audio Spectrogram Transformer - local: model_doc/bark title: Bark + - local: model_doc/canary + title: Canary - local: model_doc/clap title: CLAP - local: model_doc/cohere_asr diff --git a/docs/source/en/model_doc/audioflamingo3.md b/docs/source/en/model_doc/audioflamingo3.md index 8e1a3fa4a870..45f9177b02d3 100644 --- a/docs/source/en/model_doc/audioflamingo3.md +++ b/docs/source/en/model_doc/audioflamingo3.md @@ -289,7 +289,6 @@ conversation = [ inputs = processor.apply_chat_template( conversation, tokenize=True, - add_generation_prompt=True, return_dict=True, processor_kwargs={"output_labels": True}, ).to(model.device, dtype=model.dtype) diff --git a/docs/source/en/model_doc/canary.md b/docs/source/en/model_doc/canary.md new file mode 100644 index 000000000000..d5b794364832 --- /dev/null +++ b/docs/source/en/model_doc/canary.md @@ -0,0 +1,159 @@ + +*This model was published in HF papers on 2025-09-17 and contributed to Hugging Face Transformers on 2026-08-04.* + +
+SDPA +
+ +# Canary + +## Overview + +Canary-1B-v2 was proposed in [Canary-1B-v2 & Parakeet-TDT-0.6B-v3: Efficient and High-Performance Models for Multilingual ASR and AST](https://huggingface.co/papers/2509.14128) by Monica Sekoyan, Nithin Rao Koluguri, Nune Tadevosyan, Piotr Zelasko, Travis Bartley, Nikolay Karpov, Jagadeesh Balam, and Boris Ginsburg. + +The abstract from the paper is the following: + +*This report introduces Canary-1B-v2, a fast, robust multilingual model for Automatic Speech Recognition (ASR) and Speech-to-Text Translation (AST). Built with a FastConformer encoder and Transformer decoder, it supports 25 European languages. The model was trained on 1.7M hours of total data samples, including Granary and NeMo ASR Set 3.0, with non-speech audio added to reduce hallucinations for ASR and AST. We describe its two-stage pre-training and fine-tuning process with dynamic data balancing, as well as experiments with an nGPT encoder. Results show nGPT scales well with massive data, while FastConformer excels after fine-tuning. For timestamps, Canary-1B-v2 uses the NeMo Forced Aligner (NFA) with an auxiliary CTC model, providing reliable segment-level timestamps for ASR and AST. Evaluations show Canary-1B-v2 outperforms Whisper-large-v3 on English ASR while being 10× faster, and delivers competitive multilingual ASR and AST performance against larger models like Seamless-M4T-v2-large and LLM-based systems. We also release Parakeet-TDT-0.6B-v3, a successor to v2, offering multilingual ASR across the same 25 languages with just 600M parameters.* + +Canary reuses the [Fast Conformer](https://huggingface.co/papers/2305.05084) encoder from [Parakeet](./parakeet.md) (loaded through [`ParakeetEncoder`] / [`ParakeetEncoderConfig`]) and pairs it with a Transformer decoder that uses fixed sinusoidal positional embeddings, cross-attention to the encoder outputs and tied input/output embeddings. The task is selected through a decoder prompt prefix built by [`CanaryProcessor`] of the form `<|startofcontext|> <|startoftranscript|> <|emo:undefined|> <|noitn|> <|notimestamp|> <|nodiarize|>`, where `source_lang == target_lang` selects transcription and otherwise selects translation. + +The original implementation can be found in [NVIDIA NeMo](https://github.com/NVIDIA/NeMo). A model checkpoint is available at [harshaljanjani/canary-1b-v2-hf](https://huggingface.co/harshaljanjani/canary-1b-v2-hf). + +This model was contributed by [Harshal Janjani](https://huggingface.co/harshaljanjani). + +## Usage + +### Transcription + +The simplest way to transcribe audio is with `apply_transcription_request`, which builds the multitask decoder prompt for you (it is a convenience wrapper for `apply_chat_template`). + +```python +from datasets import load_dataset, Audio +from transformers import AutoProcessor, AutoModelForSpeechSeq2Seq + +processor = AutoProcessor.from_pretrained("harshaljanjani/canary-1b-v2-hf") +model = AutoModelForSpeechSeq2Seq.from_pretrained("harshaljanjani/canary-1b-v2-hf", device_map="auto") + +ds = load_dataset("hf-internal-testing/librispeech_asr_dummy", "clean", split="validation") +ds = ds.cast_column("audio", Audio(sampling_rate=processor.feature_extractor.sampling_rate)) + +inputs = processor.apply_transcription_request(audio=ds[0]["audio"]["array"], source_language="en").to(model.device) +generated_ids = model.generate(**inputs, max_new_tokens=128) +print(processor.decode(generated_ids, skip_special_tokens=True)[0]) +``` + +### Translation + +Set `target_language` to a different language than `source_language` for speech-to-text translation. + +```python +inputs = processor.apply_transcription_request( + audio=ds[0]["audio"]["array"], source_language="en", target_language="de" +).to(model.device) +generated_ids = model.generate(**inputs, max_new_tokens=128) +print(processor.decode(generated_ids, skip_special_tokens=True)[0]) +``` + +### Batch inference + +Pass a list of audios and, optionally, a list of `source_language` / `target_language`. + +```python +audios = [ds[0]["audio"]["array"], ds[1]["audio"]["array"]] +inputs = processor.apply_transcription_request( + audio=audios, source_language="en", target_language=["en", "de"] +).to(model.device) +generated_ids = model.generate(**inputs, max_new_tokens=128) +for text in processor.decode(generated_ids, skip_special_tokens=True): + print(text) +``` + +### Torch compile + +For autoregressive transcription, `torch.compile` accelerates the per-token forward passes inside `generate` by providing a `CompileConfig` object. + +```python +from transformers import CompileConfig + +inputs = processor.apply_transcription_request(audio=ds[0]["audio"]["array"], source_language="en").to(model.device) +compile_config = CompileConfig() + +# Warmup +for _ in range(3): + _ = model.generate(**inputs, max_new_tokens=128, cache_implementation="static", compile_config=compile_config) + +# Apply model +generated_ids = model.generate(**inputs, max_new_tokens=128, cache_implementation="static", compile_config=compile_config) +print(processor.decode(generated_ids, skip_special_tokens=True)[0]) +``` + +### Training + +Canary can be trained with the loss outputted by the model. Put the target transcript in the assistant turn and pass `output_labels=True`. Padding positions are masked automatically. + +```python +model.train() +transcription = "mister Quilter is the apostle of the middle classes, and we are glad to welcome his gospel." + +conversation = [ + [ + { + "role": "user", + "content": [ + {"type": "audio", "audio": ds[0]["audio"]["array"]}, + {"type": "text", "source_language": "en", "target_language": "en", "punctuation": True}, + ], + }, + {"role": "assistant", "content": transcription}, + ] +] + +inputs = processor.apply_chat_template( + conversation, + tokenize=True, + return_dict=True, + processor_kwargs={"output_labels": True}, +).to(model.device) + +outputs = model(**inputs) +outputs.loss.backward() +``` + +> [!NOTE] +> Segment-level timestamps for Canary-1B-v2 are produced by the external NeMo Forced Aligner (NFA) with an auxiliary CTC model, not by the decoder, so they are not part of the `generate` output. + +## CanaryConfig + +[[autodoc]] CanaryConfig + +## CanaryDecoderConfig + +[[autodoc]] CanaryDecoderConfig + +## CanaryProcessor + +[[autodoc]] CanaryProcessor + +## CanaryModel + +[[autodoc]] CanaryModel + - forward + +## CanaryForConditionalGeneration + +[[autodoc]] CanaryForConditionalGeneration + - forward diff --git a/src/transformers/audio_utils.py b/src/transformers/audio_utils.py index ece33f081799..e29ffe4e6377 100644 --- a/src/transformers/audio_utils.py +++ b/src/transformers/audio_utils.py @@ -445,6 +445,104 @@ def make_list_of_audio_chat_template( return make_list_of_audio(audio) +def make_audio_chat_template_content(audio_item) -> dict: + """ + Build a chat-template content dict for a single audio item. + + Args: + audio_item (`str` or array-like): + A single audio item. Strings are treated as local paths or URLs; other values (numpy/torch arrays) are + forwarded directly. + + Returns: + `dict`: A chat-template content dict, e.g. `{"type": "audio", "path": ...}` for strings or + `{"type": "audio", "audio": ...}` otherwise. + """ + if isinstance(audio_item, str): + return {"type": "audio", "path": audio_item} + return {"type": "audio", "audio": audio_item} + + +def resolve_language(language: str | None, code_to_name: dict[str, str], return_code: bool = True) -> str | None: + """ + Map a language code or name to its canonical form, with validation. + + Accepts either a language code (e.g. ``"zh"``, ``"en"``) or a full name (e.g. ``"Chinese"``, ``"English"``) and + returns the canonical code or name depending on ``return_code``. ``None`` passes through unchanged (auto-detect). + + Args: + language (`str` or `None`): + The language code or full name to resolve. ``None`` is returned unchanged. + code_to_name (`dict[str, str]`): + Mapping from language code to full language name for the model's supported languages. + return_code (`bool`, *optional*, defaults to `True`): + Whether to return the canonical language ``code``. If ``False``, returns the full language ``name``. + + Returns: + `str` or `None`: The canonical language code or name, or ``None`` if ``language`` is ``None``. + + Raises: + `ValueError`: If the language is not recognized. + """ + if language is None: + return None + + language_lower = language.lower() + # Try code lookup first, then full-name lookup (both case-insensitive) + for code, name in code_to_name.items(): + if language_lower == code.lower() or language_lower == name.lower(): + return code if return_code else name + + raise ValueError( + f"Unsupported language: {language!r}. Use a language code " + f"(e.g. 'en', 'zh') or full name (e.g. 'English', 'Chinese'). " + f"Supported codes: {sorted(code_to_name.keys())}. " + f"Supported names: {sorted(set(code_to_name.values()))}." + ) + + +def prepare_language_inputs( + language: str | list[str] | None, + batch_size: int, + code_to_name: dict[str, str], + allow_broadcast: bool = False, + return_code: bool = True, +) -> list[str | None]: + """ + Broadcast and validate a language argument to match ``batch_size``. + + Accepts language codes (e.g. ``"zh"``, ``"en"``) or full names (e.g. ``"Chinese"``, ``"English"``). Each value is + resolved to its canonical form via [`resolve_language`]. + + Args: + language (`str`, `list[str]`, or `None`): + The language hint(s). A single value is broadcast to the whole batch; a list must match ``batch_size`` + (unless ``allow_broadcast`` is set). ``None`` disables language hints for the whole batch. + batch_size (`int`): + The number of samples in the batch. + code_to_name (`dict[str, str]`): + Mapping from language code to full language name for the model's supported languages. + allow_broadcast (`bool`, *optional*, defaults to `False`): + Whether a single-element list may be broadcast to the whole batch. + return_code (`bool`, *optional*, defaults to `True`): + Whether to return canonical language ``code``s. If ``False``, returns full language ``name``s. + + Returns: + `list[str | None]`: The resolved language for each sample. + """ + if language is None: + return [None] * batch_size + if isinstance(language, str): + return [resolve_language(language, code_to_name, return_code)] * batch_size + if isinstance(language, (list, tuple)): + if allow_broadcast and len(language) == 1 and batch_size > 1: + return [resolve_language(language[0], code_to_name, return_code)] * batch_size + if len(language) != batch_size: + raise ValueError(f"Got {len(language)} language(s) for {batch_size} sample(s); counts must match.") + return [resolve_language(lang, code_to_name, return_code) for lang in language] + raise TypeError("`language` must be a string, a list of strings, or `None`.") + + def hertz_to_mel(freq: float | np.ndarray, mel_scale: str = "htk") -> float | np.ndarray: """ Convert frequency from hertz to mels. diff --git a/src/transformers/convert_slow_tokenizer.py b/src/transformers/convert_slow_tokenizer.py index 84037a76e0f6..ac809fa7e5c1 100644 --- a/src/transformers/convert_slow_tokenizer.py +++ b/src/transformers/convert_slow_tokenizer.py @@ -1874,6 +1874,17 @@ def tokenizer(self, proto): return tokenizer +class CanaryConverter(ParakeetConverter): + def __init__(self, vocab_file=None, *args): + super().__init__(vocab_file, *args) + # Only the `<|...|>` (type 4) control tokens are special; other type-4 pieces (digits, ``) are plain text. + self.special_tokens = { + piece.piece + for piece in self.proto.pieces + if piece.type == 4 and piece.piece.startswith("<|") and piece.piece.endswith("|>") + } + + def bytes_to_unicode(): """ Returns list of utf-8 byte and a mapping to unicode strings. We specifically avoids mapping to whitespace/control diff --git a/src/transformers/models/__init__.py b/src/transformers/models/__init__.py index 073d59ef3471..86f76b15a2b2 100644 --- a/src/transformers/models/__init__.py +++ b/src/transformers/models/__init__.py @@ -58,6 +58,7 @@ from .bros import * from .byt5 import * from .camembert import * + from .canary import * from .canine import * from .chameleon import * from .chinese_clip import * diff --git a/src/transformers/models/audioflamingo3/processing_audioflamingo3.py b/src/transformers/models/audioflamingo3/processing_audioflamingo3.py index 793b535b55a1..26d930d0ecfc 100644 --- a/src/transformers/models/audioflamingo3/processing_audioflamingo3.py +++ b/src/transformers/models/audioflamingo3/processing_audioflamingo3.py @@ -16,7 +16,7 @@ import numpy as np -from ...audio_utils import AudioInput, make_list_of_audio_chat_template +from ...audio_utils import AudioInput, make_audio_chat_template_content, make_list_of_audio_chat_template from ...feature_extraction_utils import BatchFeature from ...processing_utils import ProcessingKwargs, ProcessorMixin, Unpack from ...tokenization_utils_base import TextInput @@ -237,9 +237,7 @@ def apply_transcription_request( "role": "user", "content": [ {"type": "text", "text": prompt_text}, - {"type": "audio", "path": audio_item} - if isinstance(audio_item, str) - else {"type": "audio", "audio": audio_item}, + make_audio_chat_template_content(audio_item), ], } ] diff --git a/src/transformers/models/auto/auto_mappings.py b/src/transformers/models/auto/auto_mappings.py index b0403d8bef88..bf91da5d2c2f 100644 --- a/src/transformers/models/auto/auto_mappings.py +++ b/src/transformers/models/auto/auto_mappings.py @@ -75,6 +75,8 @@ ("bridgetower_vision_model", "BridgeTowerVisionConfig"), ("bros", "BrosConfig"), ("camembert", "CamembertConfig"), + ("canary", "CanaryConfig"), + ("canary_decoder", "CanaryDecoderConfig"), ("canine", "CanineConfig"), ("chameleon", "ChameleonConfig"), ("chameleon_vqgan", "ChameleonVQVAEConfig"), @@ -733,6 +735,7 @@ ("blt_patcher", "blt"), ("bridgetower_text_model", "bridgetower"), ("bridgetower_vision_model", "bridgetower"), + ("canary_decoder", "canary"), ("chameleon_vqgan", "chameleon"), ("chinese_clip_text_model", "chinese_clip"), ("chinese_clip_vision_model", "chinese_clip"), @@ -1026,6 +1029,7 @@ ("blip-2", "Blip2Processor"), ("bridgetower", "BridgeTowerProcessor"), ("bros", "BrosProcessor"), + ("canary", "CanaryProcessor"), ("chameleon", "ChameleonProcessor"), ("chinese_clip", "ChineseCLIPProcessor"), ("clap", "ClapProcessor"), diff --git a/src/transformers/models/auto/feature_extraction_auto.py b/src/transformers/models/auto/feature_extraction_auto.py index ec93f81ce075..6ef7967f4cd3 100644 --- a/src/transformers/models/auto/feature_extraction_auto.py +++ b/src/transformers/models/auto/feature_extraction_auto.py @@ -37,6 +37,7 @@ MISSING_FEATURE_EXTRACTOR_MAPPING_NAMES = OrderedDict( [ ("audioflamingo3", "WhisperFeatureExtractor"), + ("canary", "ParakeetFeatureExtractor"), ("csm", "EncodecFeatureExtractor"), ("data2vec-audio", "Wav2Vec2FeatureExtractor"), ("glmasr", "WhisperFeatureExtractor"), diff --git a/src/transformers/models/auto/modeling_auto.py b/src/transformers/models/auto/modeling_auto.py index f1a09446020f..8697fdb128ba 100644 --- a/src/transformers/models/auto/modeling_auto.py +++ b/src/transformers/models/auto/modeling_auto.py @@ -79,6 +79,7 @@ class _BaseModelWithGenerate(PreTrainedModel, GenerationMixin): ("bridgetower", "BridgeTowerModel"), ("bros", "BrosModel"), ("camembert", "CamembertModel"), + ("canary", "CanaryModel"), ("canine", "CanineModel"), ("chameleon", "ChameleonModel"), ("chinese_clip", "ChineseCLIPModel"), @@ -1326,6 +1327,7 @@ class _BaseModelWithGenerate(PreTrainedModel, GenerationMixin): MODEL_FOR_SPEECH_SEQ_2_SEQ_MAPPING_NAMES = OrderedDict( [ + ("canary", "CanaryForConditionalGeneration"), ("cohere_asr", "CohereAsrForConditionalGeneration"), ("dia", "DiaForConditionalGeneration"), ("granite_speech", "GraniteSpeechForConditionalGeneration"), diff --git a/src/transformers/models/auto/tokenization_auto.py b/src/transformers/models/auto/tokenization_auto.py index 272445c2128e..00416a944834 100644 --- a/src/transformers/models/auto/tokenization_auto.py +++ b/src/transformers/models/auto/tokenization_auto.py @@ -90,6 +90,7 @@ ("bros", "BertTokenizer" if is_tokenizers_available() else None), ("byt5", "ByT5Tokenizer"), ("camembert", "CamembertTokenizer" if is_tokenizers_available() else None), + ("canary", "TokenizersBackend" if is_tokenizers_available() else None), ("canine", "CanineTokenizer"), ("chinese_clip", "BertTokenizer" if is_tokenizers_available() else None), ("clap", "RobertaTokenizer"), diff --git a/src/transformers/models/canary/__init__.py b/src/transformers/models/canary/__init__.py new file mode 100644 index 000000000000..40138c0d4905 --- /dev/null +++ b/src/transformers/models/canary/__init__.py @@ -0,0 +1,28 @@ +# Copyright 2026 The HuggingFace Team. 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. +from typing import TYPE_CHECKING + +from ...utils import _LazyModule +from ...utils.import_utils import define_import_structure + + +if TYPE_CHECKING: + from .configuration_canary import * + from .modeling_canary import * + from .processing_canary import * +else: + import sys + + _file = globals()["__file__"] + sys.modules[__name__] = _LazyModule(__name__, _file, define_import_structure(_file), module_spec=__spec__) diff --git a/src/transformers/models/canary/configuration_canary.py b/src/transformers/models/canary/configuration_canary.py new file mode 100644 index 000000000000..fd9b07308ad0 --- /dev/null +++ b/src/transformers/models/canary/configuration_canary.py @@ -0,0 +1,119 @@ +# Copyright 2026 The HuggingFace Inc. team. 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. +from huggingface_hub.dataclasses import strict + +from ...configuration_utils import PreTrainedConfig +from ...utils import auto_docstring +from ..auto import CONFIG_MAPPING, AutoConfig + + +@auto_docstring(checkpoint="harshaljanjani/canary-1b-v2-hf") +@strict +class CanaryDecoderConfig(PreTrainedConfig): + model_type = "canary_decoder" + + vocab_size: int = 16384 + hidden_size: int = 1024 + num_hidden_layers: int = 8 + num_attention_heads: int = 8 + num_key_value_heads: int | None = None + intermediate_size: int = 4096 + hidden_act: str = "relu" + max_position_embeddings: int = 1024 + pad_token_id: int | None = 2 + eos_token_id: int | None = 3 + bos_token_id: int | None = 4 + is_encoder_decoder: bool = True + use_cache: bool = True + initializer_range: float = 0.02 + attention_dropout: float | int = 0.0 + attention_bias: bool = True + head_dim: int | None = None + + def __post_init__(self, **kwargs): + if self.head_dim is None: + self.head_dim = self.hidden_size // self.num_attention_heads + if self.num_key_value_heads is None: + self.num_key_value_heads = self.num_attention_heads + super().__post_init__(**kwargs) + + +@auto_docstring(checkpoint="harshaljanjani/canary-1b-v2-hf") +@strict +class CanaryConfig(PreTrainedConfig): + r""" + encoder_config (`Union[dict, ParakeetEncoderConfig]`, *optional*): + The config object or dictionary of the FastConformer encoder ([`ParakeetEncoderConfig`]). + decoder_config (`Union[dict, CanaryDecoderConfig]`, *optional*): + The config object or dictionary of the Transformer decoder ([`CanaryDecoderConfig`]). + decoder_start_token_id (`int`, *optional*, defaults to 7): + The token id that starts decoding (`<|startofcontext|>`, the first token of the multitask prompt). + + Example: + + ```python + >>> from transformers import CanaryForConditionalGeneration, CanaryConfig + + >>> # Initializing a Canary configuration + >>> configuration = CanaryConfig() + + >>> # Initializing a model from the configuration + >>> model = CanaryForConditionalGeneration(configuration) + + >>> # Accessing the model configuration + >>> configuration = model.config + ``` + """ + + model_type = "canary" + keys_to_ignore_at_inference = ["past_key_values"] + sub_configs = {"encoder_config": AutoConfig, "decoder_config": CanaryDecoderConfig} + + encoder_config: dict | PreTrainedConfig | None = None + decoder_config: CanaryDecoderConfig | dict | None = None + use_cache: bool = True + is_encoder_decoder: bool = True + tie_word_embeddings: bool = True + pad_token_id: int | None = 2 + bos_token_id: int | None = 4 + eos_token_id: int | None = 3 + decoder_start_token_id: int | None = 7 + initializer_range: float = 0.02 + + def __post_init__(self, **kwargs): + if isinstance(self.encoder_config, dict): + self.encoder_config["model_type"] = self.encoder_config.get("model_type", "parakeet_encoder") + self.encoder_config = CONFIG_MAPPING[self.encoder_config["model_type"]](**self.encoder_config) + elif self.encoder_config is None: + self.encoder_config = CONFIG_MAPPING["parakeet_encoder"]( + num_hidden_layers=32, + num_mel_bins=128, + scale_input=False, + layerdrop=0.0, + dropout_positions=0.0, + ) + + if isinstance(self.decoder_config, dict): + self.decoder_config = CanaryDecoderConfig(**self.decoder_config) + elif self.decoder_config is None: + self.decoder_config = CanaryDecoderConfig() + + self.vocab_size = self.decoder_config.vocab_size + super().__post_init__(**kwargs) + + def get_text_config(self, *args, **kwargs): + return self.decoder_config + + +__all__ = ["CanaryConfig", "CanaryDecoderConfig"] diff --git a/src/transformers/models/canary/convert_canary_nemo_to_hf.py b/src/transformers/models/canary/convert_canary_nemo_to_hf.py new file mode 100644 index 000000000000..60fdddf2671e --- /dev/null +++ b/src/transformers/models/canary/convert_canary_nemo_to_hf.py @@ -0,0 +1,249 @@ +# Copyright 2026 The HuggingFace Inc. team. 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 argparse +import gc +import os +import tarfile + +import torch +import yaml + +from transformers import ( + CanaryConfig, + CanaryDecoderConfig, + CanaryForConditionalGeneration, + CanaryProcessor, + ParakeetFeatureExtractor, + TokenizersBackend, +) +from transformers.convert_slow_tokenizer import CanaryConverter +from transformers.models.parakeet.convert_nemo_to_hf import convert_encoder_config, convert_key +from transformers.utils.hub import cached_file + + +CANARY_CHAT_TEMPLATE = ( + "{{- '<|startofcontext|><|startoftranscript|><|emo:undefined|>' -}}" + "{%- for message in messages -%}" + "{%- if message['role'] == 'user' -%}" + "{%- for content in message['content'] if content['type'] == 'text' -%}" + "{{- '<|' ~ content['source_language'] ~ '|>' -}}" + "{{- '<|' ~ content['target_language'] ~ '|>' -}}" + "{{- '<|pnc|>' if content['punctuation'] else '<|nopnc|>' -}}" + "{{- '<|noitn|><|notimestamp|><|nodiarize|>' -}}" + "{%- endfor -%}" + "{%- elif message['role'] == 'assistant' -%}" + "{{- message['content'] ~ '<|endoftext|>' -}}" + "{%- endif -%}" + "{%- endfor -%}" +) + + +NEMO_TO_HF_ENCODER_MAPPING = { + r"^encoder\.pre_encode\.conv\.": r"encoder.subsampling.layers.", + r"^encoder\.pre_encode\.out\.": r"encoder.subsampling.linear.", + r"^encoder\.pos_enc\.": r"encoder.encode_positions.", + r"^encoder\.layers\.(\d+)\.conv\.batch_norm\.": r"encoder.layers.\1.conv.norm.", + r"linear_([kv])": r"\1_proj", + r"linear_out": r"o_proj", + r"linear_q": r"q_proj", + r"pos_bias_([uv])": r"bias_\1", + r"linear_pos": r"relative_k_proj", +} + +DECODER_HIDDEN_DIM_INPUT_KEYS = ( + "embed_tokens.weight", + "proj_out.weight", + "q_proj.weight", + "self_attn.k_proj.weight", + "self_attn.v_proj.weight", + "mlp.fc1.weight", +) + +DECODER_HIDDEN_DIM_OUTPUT_KEYS = ( + "o_proj.weight", + "o_proj.bias", + "mlp.fc2.weight", + "mlp.fc2.bias", + "norm.weight", + "norm.bias", +) + +NEMO_TO_HF_DECODER_MAPPING = { + r"^transf_decoder\._embedding\.token_embedding\.": r"decoder.embed_tokens.", + r"^transf_decoder\._embedding\.layer_norm\.": r"decoder.embedding_layernorm.", + r"^transf_decoder\._decoder\.final_layer_norm\.": r"decoder.norm.", + r"^transf_decoder\._decoder\.layers\.(\d+)\.layer_norm_1\.": r"decoder.layers.\1.input_layernorm.", + r"^transf_decoder\._decoder\.layers\.(\d+)\.layer_norm_2\.": r"decoder.layers.\1.post_attention_layernorm.", + r"^transf_decoder\._decoder\.layers\.(\d+)\.layer_norm_3\.": r"decoder.layers.\1.final_layernorm.", + r"^transf_decoder\._decoder\.layers\.(\d+)\.first_sub_layer\.": r"decoder.layers.\1.self_attn.", + r"^transf_decoder\._decoder\.layers\.(\d+)\.second_sub_layer\.": r"decoder.layers.\1.encoder_attn.", + r"^transf_decoder\._decoder\.layers\.(\d+)\.third_sub_layer\.dense_in\.": r"decoder.layers.\1.mlp.fc1.", + r"^transf_decoder\._decoder\.layers\.(\d+)\.third_sub_layer\.dense_out\.": r"decoder.layers.\1.mlp.fc2.", + r"query_net": r"q_proj", + r"key_net": r"k_proj", + r"value_net": r"v_proj", + r"out_projection": r"o_proj", +} + + +def extract_nemo_archive(nemo_file_path: str, extract_dir: str) -> None: + """Extract a Canary `.nemo` (tar) archive into `extract_dir`.""" + print(f"Extracting NeMo archive: {nemo_file_path}") + with tarfile.open(nemo_file_path, "r", encoding="utf-8") as tar: + # filter="data" sanitizes members (PEP 706) so a malicious archive cannot write outside extract_dir + tar.extractall(extract_dir, filter="data") + + +def convert_decoder_config(nemo_config) -> CanaryConfig: + """Build a [`CanaryConfig`] from the NeMo `transf_decoder` and `head` config blocks.""" + decoder_config = nemo_config["transf_decoder"]["config_dict"] + head_config = nemo_config["head"] + encoder_config = convert_encoder_config(nemo_config) + + return CanaryConfig( + encoder_config=encoder_config.to_dict(), + decoder_config=CanaryDecoderConfig( + vocab_size=head_config["num_classes"], + hidden_size=decoder_config["hidden_size"], + num_hidden_layers=decoder_config["num_layers"], + num_attention_heads=decoder_config["num_attention_heads"], + intermediate_size=decoder_config["inner_size"], + hidden_act=decoder_config["hidden_act"], + max_position_embeddings=decoder_config["max_sequence_length"], + attention_dropout=decoder_config["attn_score_dropout"], + ), + ) + + +def permute_for_sinusoids(tensor, key, d_model): + """Permute the weights for the concatenated sin/cos formulation.""" + permutation = torch.empty(d_model, dtype=torch.long) + permutation[: d_model // 2] = torch.arange(0, d_model, 2) + permutation[d_model // 2 :] = torch.arange(1, d_model, 2) + if key.endswith(DECODER_HIDDEN_DIM_INPUT_KEYS): + return tensor[:, permutation] + if key.endswith(DECODER_HIDDEN_DIM_OUTPUT_KEYS): + return tensor[permutation] + return tensor + + +def load_and_convert_state_dict(model_files, d_model): + """Load the NeMo state dict and convert keys to the HF (`model.encoder.*` / `model.decoder.*`) layout.""" + state_dict = torch.load(model_files["model_weights"], map_location="cpu", weights_only=True) + converted_state_dict = {} + for key, value in state_dict.items(): + if key.startswith("preprocessor.") or key.endswith(".position_embedding.pos_enc"): + # featurizer buffers and the fixed sinusoidal table are recomputed, not loaded + continue + if key.startswith("encoder."): + converted_state_dict["model." + convert_key(key, NEMO_TO_HF_ENCODER_MAPPING)] = value + elif key.startswith("transf_decoder."): + converted_key = "model." + convert_key(key, NEMO_TO_HF_DECODER_MAPPING) + converted_state_dict[converted_key] = permute_for_sinusoids(value, converted_key, d_model) + elif key == "log_softmax.mlp.layer0.weight": + converted_state_dict["proj_out.weight"] = permute_for_sinusoids(value, "proj_out.weight", d_model) + elif key == "log_softmax.mlp.layer0.bias": + converted_state_dict["proj_out.bias"] = value + else: + raise ValueError(f"Unhandled NeMo weight key: {key}") + return converted_state_dict + + +def write_processor(nemo_config, model_files, output_dir, push_to_repo_id=None): + tokenizer_object = CanaryConverter(model_files["tokenizer_model_file"]).converted() + tokenizer = TokenizersBackend( + tokenizer_object=tokenizer_object, + clean_up_tokenization_spaces=False, + bos_token="<|startoftranscript|>", + eos_token="<|endoftext|>", + pad_token="", + unk_token="", + ) + + preprocessor = nemo_config["preprocessor"] + feature_extractor = ParakeetFeatureExtractor( + feature_size=preprocessor["features"], + sampling_rate=preprocessor["sample_rate"], + win_length=int(preprocessor["window_size"] * preprocessor["sample_rate"]), + hop_length=int(preprocessor["window_stride"] * preprocessor["sample_rate"]), + n_fft=preprocessor["n_fft"], + ) + + processor = CanaryProcessor( + feature_extractor=feature_extractor, tokenizer=tokenizer, chat_template=CANARY_CHAT_TEMPLATE + ) + processor.save_pretrained(output_dir) + if push_to_repo_id: + processor.push_to_hub(push_to_repo_id) + + +def main(hf_repo_id, output_dir, push_to_repo_id=None): + nemo_filename = f"{hf_repo_id.split('/')[-1]}.nemo" + filepath = cached_file(hf_repo_id, nemo_filename) + extract_dir = os.path.dirname(filepath) + extract_nemo_archive(filepath, extract_dir) + + nemo_config = yaml.load(open(os.path.join(extract_dir, "model_config.yaml"), "r"), Loader=yaml.FullLoader) + tokenizer_model_name = nemo_config["tokenizer"]["model_path"].split("nemo:")[-1] + model_files = { + "model_weights": os.path.join(extract_dir, "model_weights.ckpt"), + "tokenizer_model_file": os.path.join(extract_dir, tokenizer_model_name), + } + + write_processor(nemo_config, model_files, output_dir, push_to_repo_id) + + config = convert_decoder_config(nemo_config) + print(f"Converted config:\n{config}") + converted_state_dict = load_and_convert_state_dict(model_files, config.decoder_config.hidden_size) + + print("Loading the checkpoint in a Canary model.") + with torch.device("meta"): + model = CanaryForConditionalGeneration(config) + model.load_state_dict(converted_state_dict, strict=True, assign=True) + print("Checkpoint loaded successfully.") + + model.generation_config.decoder_start_token_id = config.decoder_start_token_id + model.generation_config.bos_token_id = config.bos_token_id + model.generation_config.eos_token_id = config.eos_token_id + model.generation_config.pad_token_id = config.pad_token_id + + print("Saving the model.") + model.save_pretrained(output_dir) + if push_to_repo_id: + model.push_to_hub(push_to_repo_id) + + del model + gc.collect() + print("Reloading the model to check it was saved correctly.") + CanaryForConditionalGeneration.from_pretrained(output_dir, dtype=torch.bfloat16) + print("Model reloaded successfully.") + + +""" +Conversion example: +```bash +python src/transformers/models/canary/convert_canary_nemo_to_hf.py \ + --hf_repo_id nvidia/canary-1b-v2 \ + --output_dir OUTPUT_DIR \ + --push_to_repo_id USERNAME/canary-1b-v2-hf +``` +""" +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--hf_repo_id", required=True, help="Model repo on huggingface.co") + parser.add_argument("--output_dir", required=True, help="Output directory for the HuggingFace model") + parser.add_argument("--push_to_repo_id", help="Repository ID to push the converted model to on the Hub") + args = parser.parse_args() + main(args.hf_repo_id, args.output_dir, args.push_to_repo_id) diff --git a/src/transformers/models/canary/modeling_canary.py b/src/transformers/models/canary/modeling_canary.py new file mode 100644 index 000000000000..a9cead5439c9 --- /dev/null +++ b/src/transformers/models/canary/modeling_canary.py @@ -0,0 +1,699 @@ +# 🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨 +# This file was automatically generated from src/transformers/models/canary/modular_canary.py. +# Do NOT edit this file manually as any edits will be overwritten by the generation of +# the file from the modular. If any change should be done, please apply the change to the +# modular_canary.py file directly. One of our CI enforces this. +# 🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨 +# Copyright 2026 The HuggingFace Inc. team. 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 math +from collections.abc import Callable + +import numpy as np +import torch +from torch import nn + +from ... import initialization as init +from ...activations import ACT2FN +from ...cache_utils import Cache, DynamicCache, EncoderDecoderCache +from ...generation import GenerationMixin +from ...masking_utils import create_bidirectional_mask, create_causal_mask +from ...modeling_layers import GradientCheckpointingLayer +from ...modeling_outputs import ( + BaseModelOutput, + BaseModelOutputWithPastAndCrossAttentions, + Seq2SeqLMOutput, + Seq2SeqModelOutput, +) +from ...modeling_utils import ALL_ATTENTION_FUNCTIONS, PreTrainedModel +from ...processing_utils import Unpack +from ...utils import TransformersKwargs, auto_docstring +from ...utils.generic import can_return_tuple, merge_with_config_defaults +from ...utils.output_capturing import OutputRecorder, capture_outputs +from ..auto.modeling_auto import AutoModel +from .configuration_canary import CanaryConfig, CanaryDecoderConfig + + +class CanaryPositionalEmbedding(nn.Module): + """ + Identical to [`SinusoidsPositionEmbedding`] except that the timescales and the `1 / sqrt(channels)` scaling match + NeMo's `FixedPositionalEncoding`, and it is indexed by `position_ids`. + """ + + def __init__(self, length: int, channels: int): + super().__init__() + max_timescale = 10000 ** ((channels - 2) / channels) + self.length = length + self.channels = channels + self.max_timescale = max_timescale + if channels % 2 != 0: + raise ValueError("CanaryPositionalEmbedding needs even channels input") + position_embedding = self.compute_default_singular_positional_embedding() + self.positional_embedding = nn.Buffer(position_embedding, persistent=False) + + def compute_default_singular_positional_embedding(self) -> torch.Tensor: + log_timescale_increment = np.log(self.max_timescale) / (self.channels // 2 - 1) + inv_timescales = torch.exp(-log_timescale_increment * torch.arange(self.channels // 2).float()) + scaled_time = torch.arange(self.length)[:, np.newaxis] * inv_timescales[np.newaxis, :] + emb = torch.cat([torch.sin(scaled_time), torch.cos(scaled_time)], dim=1) / math.sqrt(self.channels) + return emb.to(torch.get_default_dtype()) + + def forward(self, position_ids: torch.Tensor) -> torch.Tensor: + return self.positional_embedding[position_ids] + + +@auto_docstring +class CanaryPreTrainedModel(PreTrainedModel): + config: CanaryConfig + base_model_prefix = "model" + main_input_name = "input_features" + input_modalities = "audio" + supports_gradient_checkpointing = True + _no_split_modules = ["ParakeetEncoderBlock", "CanaryDecoderLayer"] + _supports_flash_attn = True + _supports_sdpa = True + + _can_compile_fullgraph = True + _keys_to_ignore_on_load_unexpected = [r"preprocessor\.featurizer\..*"] + + @torch.no_grad() + def _init_weights(self, module): + super()._init_weights(module) + if isinstance(module, CanaryPositionalEmbedding): + init.copy_(module.positional_embedding, module.compute_default_singular_positional_embedding()) + + +class CanaryDecoderMLP(nn.Module): + def __init__(self, config): + super().__init__() + self.config = config + self.activation_fn = ACT2FN[config.hidden_act] + self.fc1 = nn.Linear(config.hidden_size, config.intermediate_size) + self.fc2 = nn.Linear(config.intermediate_size, config.hidden_size) + + def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: + hidden_states = self.fc1(hidden_states) + hidden_states = self.activation_fn(hidden_states) + hidden_states = self.fc2(hidden_states) + return hidden_states + + +def repeat_kv(hidden_states: torch.Tensor, n_rep: int) -> torch.Tensor: + """ + This is the equivalent of torch.repeat_interleave(x, dim=1, repeats=n_rep). The hidden states go from (batch, + num_key_value_heads, seqlen, head_dim) to (batch, num_attention_heads, seqlen, head_dim) + """ + batch, num_key_value_heads, slen, head_dim = hidden_states.shape + if n_rep == 1: + return hidden_states + hidden_states = hidden_states[:, :, None, :, :].expand(batch, num_key_value_heads, n_rep, slen, head_dim) + return hidden_states.reshape(batch, num_key_value_heads * n_rep, slen, head_dim) + + +def eager_attention_forward( + module: nn.Module, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + attention_mask: torch.Tensor | None, + scaling: float, + dropout: float = 0.0, + **kwargs: Unpack[TransformersKwargs], +): + key_states = repeat_kv(key, module.num_key_value_groups) + value_states = repeat_kv(value, module.num_key_value_groups) + + attn_weights = torch.matmul(query, key_states.transpose(2, 3)) * scaling + if attention_mask is not None: + attn_weights = attn_weights + attention_mask + + attn_weights = nn.functional.softmax(attn_weights, dim=-1, dtype=torch.float32).to(query.dtype) + attn_weights = nn.functional.dropout(attn_weights, p=dropout, training=module.training) + attn_output = torch.matmul(attn_weights, value_states) + attn_output = attn_output.transpose(1, 2).contiguous() + + return attn_output, attn_weights + + +# Modular automatically inherits RoPE, hence no inheritance for now +class CanarySelfAttention(nn.Module): + def __init__(self, config: CanaryConfig, layer_idx: int): + super().__init__() + self.config = config + self.layer_idx = layer_idx + self.head_dim = getattr(config, "head_dim", config.hidden_size // config.num_attention_heads) + self.num_key_value_groups = config.num_attention_heads // config.num_key_value_heads + self.scaling = self.head_dim**-0.5 + self.attention_dropout = config.attention_dropout + self.is_causal = True + + self.q_proj = nn.Linear( + config.hidden_size, config.num_attention_heads * self.head_dim, bias=config.attention_bias + ) + self.k_proj = nn.Linear( + config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias + ) + self.v_proj = nn.Linear( + config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias + ) + self.o_proj = nn.Linear( + config.num_attention_heads * self.head_dim, config.hidden_size, bias=config.attention_bias + ) + + def forward( + self, + hidden_states: torch.Tensor, + attention_mask: torch.Tensor, + past_key_values: Cache | None = None, + **kwargs, + ): + input_shape = hidden_states.shape[:-1] + hidden_shape = (*input_shape, -1, self.head_dim) + + query_states = self.q_proj(hidden_states) + key_states = self.k_proj(hidden_states) + value_states = self.v_proj(hidden_states) + + query_states = query_states.view(hidden_shape).transpose(1, 2) + key_states = key_states.view(hidden_shape).transpose(1, 2) + value_states = value_states.view(hidden_shape).transpose(1, 2) + + if past_key_values is not None: + past_key_values = past_key_values.self_attention_cache + key_states, value_states = past_key_values.update(key_states, value_states, self.layer_idx) + + attention_interface: Callable = ALL_ATTENTION_FUNCTIONS.get_interface( + self.config._attn_implementation, eager_attention_forward + ) + + attn_output, attn_weights = attention_interface( + self, + query_states, + key_states, + value_states, + attention_mask, + dropout=0.0 if not self.training else self.attention_dropout, + scaling=self.scaling, + **kwargs, + ) + + attn_output = attn_output.reshape(*input_shape, -1).contiguous() + attn_output = self.o_proj(attn_output) + return attn_output, attn_weights + + +# Modular automatically inherits RoPE, hence no inheritance for now +class CanaryCrossAttention(nn.Module): + def __init__(self, config: CanaryConfig, layer_idx: int): + super().__init__() + self.config = config + self.layer_idx = layer_idx + self.head_dim = getattr(config, "head_dim", config.hidden_size // config.num_attention_heads) + self.num_key_value_groups = config.num_attention_heads // config.num_key_value_heads + self.scaling = self.head_dim**-0.5 + self.attention_dropout = config.attention_dropout + self.is_causal = False + + self.q_proj = nn.Linear( + config.hidden_size, config.num_attention_heads * self.head_dim, bias=config.attention_bias + ) + self.k_proj = nn.Linear( + config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias + ) + self.v_proj = nn.Linear( + config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias + ) + self.o_proj = nn.Linear( + config.num_attention_heads * self.head_dim, config.hidden_size, bias=config.attention_bias + ) + + def forward( + self, + hidden_states: torch.Tensor, + encoder_hidden_states: torch.Tensor | None = None, + attention_mask: torch.Tensor | None = None, + past_key_values: Cache | None = None, + **kwargs: Unpack[TransformersKwargs], + ): + # determine input shapes + bsz, tgt_len = hidden_states.shape[:-1] + src_len = encoder_hidden_states.shape[1] + + q_input_shape = (bsz, tgt_len, -1, self.head_dim) + kv_input_shape = (bsz, src_len, -1, self.head_dim) + + # get query proj + query_states = self.q_proj(hidden_states).view(*q_input_shape).transpose(1, 2) + + is_updated = past_key_values.is_updated.get(self.layer_idx) if past_key_values is not None else False + if past_key_values is not None and is_updated: + # reuse k,v, cross_attentions + key_states = past_key_values.cross_attention_cache.layers[self.layer_idx].keys + value_states = past_key_values.cross_attention_cache.layers[self.layer_idx].values + else: + key_states = self.k_proj(encoder_hidden_states).view(*kv_input_shape).transpose(1, 2) + value_states = self.v_proj(encoder_hidden_states).view(*kv_input_shape).transpose(1, 2) + + if past_key_values is not None: + # save all states to the cache + key_states, value_states = past_key_values.cross_attention_cache.update( + key_states, value_states, self.layer_idx + ) + # set flag that curr layer for cross-attn is already updated so we can re-use in subsequent calls + past_key_values.is_updated[self.layer_idx] = True + + attention_interface: Callable = ALL_ATTENTION_FUNCTIONS.get_interface( + self.config._attn_implementation, eager_attention_forward + ) + + attn_output, attn_weights = attention_interface( + self, + query_states, + key_states, + value_states, + attention_mask, + dropout=0.0 if not self.training else self.attention_dropout, + scaling=self.scaling, + **kwargs, + ) + attn_output = attn_output.reshape(bsz, tgt_len, -1).contiguous() + attn_output = self.o_proj(attn_output) + return attn_output, attn_weights + + +class CanaryDecoderLayer(GradientCheckpointingLayer): + def __init__(self, config, layer_idx=None): + super().__init__() + self.self_attn = CanarySelfAttention(config=config, layer_idx=layer_idx) + self.encoder_attn = CanaryCrossAttention(config=config, layer_idx=layer_idx) + + self.mlp = CanaryDecoderMLP(config) + self.input_layernorm = nn.LayerNorm(config.hidden_size) + self.post_attention_layernorm = nn.LayerNorm(config.hidden_size) + self.final_layernorm = nn.LayerNorm(config.hidden_size) + + def forward( + self, + hidden_states: torch.Tensor, + attention_mask: torch.Tensor | None = None, + encoder_hidden_states: torch.Tensor | None = None, + encoder_attention_mask: torch.Tensor | None = None, + position_ids: torch.LongTensor | None = None, + encoder_position_ids: torch.LongTensor | None = None, + past_key_values: Cache | None = None, + **kwargs: Unpack[TransformersKwargs], + ) -> tuple[torch.FloatTensor, tuple[torch.FloatTensor, torch.FloatTensor] | None]: + residual = hidden_states + hidden_states = self.input_layernorm(hidden_states) + + hidden_states, _ = self.self_attn( + hidden_states=hidden_states, + attention_mask=attention_mask, + position_ids=position_ids, + past_key_values=past_key_values, + **kwargs, + ) + hidden_states = residual + hidden_states + + if encoder_hidden_states is not None: + residual = hidden_states + hidden_states = self.post_attention_layernorm(hidden_states) + hidden_states, _ = self.encoder_attn( + hidden_states=hidden_states, + encoder_hidden_states=encoder_hidden_states, + attention_mask=encoder_attention_mask, + past_key_values=past_key_values, + ) + hidden_states = residual + hidden_states + + residual = hidden_states + hidden_states = self.final_layernorm(hidden_states) + hidden_states = self.mlp(hidden_states) + hidden_states = residual + hidden_states + return hidden_states + + +@auto_docstring +class CanaryDecoder(CanaryPreTrainedModel): + main_input_name = "input_ids" + _can_record_outputs = { + "attentions": OutputRecorder(CanarySelfAttention, index=1, layer_name="self_attn"), + "hidden_states": CanaryDecoderLayer, + "cross_attentions": OutputRecorder(CanaryCrossAttention, index=1, layer_name="encoder_attn"), + } + config: CanaryDecoderConfig + + def __init__(self, config: CanaryDecoderConfig): + super().__init__(config) + self.padding_idx = config.pad_token_id + self.vocab_size = config.vocab_size + + self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size, self.padding_idx) + self.layers = nn.ModuleList([CanaryDecoderLayer(config, idx) for idx in range(config.num_hidden_layers)]) + self.norm = nn.LayerNorm(config.hidden_size) + self.gradient_checkpointing = False + self.pos_emb = CanaryPositionalEmbedding(config.max_position_embeddings, config.hidden_size) + self.embedding_layernorm = nn.LayerNorm(config.hidden_size) + self.proj = nn.Identity() + + # Initialize weights and apply final processing + self.post_init() + + @merge_with_config_defaults + @capture_outputs + def forward( + self, + input_ids: torch.LongTensor | None = None, + attention_mask: torch.Tensor | None = None, + position_ids: torch.LongTensor | None = None, + past_key_values: Cache | None = None, + inputs_embeds: torch.FloatTensor | None = None, + use_cache: bool | None = None, + encoder_hidden_states: torch.FloatTensor | None = None, + encoder_attention_mask: torch.Tensor | None = None, + **kwargs: Unpack[TransformersKwargs], + ) -> tuple | BaseModelOutputWithPastAndCrossAttentions: + r""" + encoder_hidden_states (`torch.FloatTensor` of shape `(batch_size, encoder_sequence_length, hidden_size)`, *optional*): + Sequence of hidden-states at the output of the last layer of the encoder. Used in the cross-attention + of the decoder. + encoder_attention_mask (`torch.Tensor` of shape `(batch_size, sequence_length)`, *optional*): + Mask to avoid performing attention on padding indices in `encoder_hidden_states`. Mask values selected in `[0, 1]`: + - 1 for tokens that are **not masked**, + - 0 for tokens that are **masked**. + [What are attention masks?](../glossary#attention-mask) + """ + encoder_hidden_states = self.proj(encoder_hidden_states) + if (input_ids is None) ^ (inputs_embeds is not None): + raise ValueError("You must specify exactly one of input_ids or inputs_embeds") + + if inputs_embeds is None: + inputs_embeds = self.embed_tokens(input_ids) + + if use_cache and past_key_values is None: + past_key_values = EncoderDecoderCache(DynamicCache(config=self.config), DynamicCache(config=self.config)) + + if position_ids is None: + past_seen_tokens = past_key_values.get_seq_length() if past_key_values is not None else 0 + position_ids = torch.arange(inputs_embeds.shape[1], device=inputs_embeds.device) + past_seen_tokens + position_ids = position_ids.unsqueeze(0) + + # Fixed sinusoidal position embedding added to token embeddings, then layernorm + pos_emb = self.pos_emb(position_ids.squeeze(0)) + pos_emb = pos_emb.to(inputs_embeds.device) + inputs_embeds = self.embedding_layernorm(inputs_embeds + pos_emb) + + causal_mask = create_causal_mask( + config=self.config, + inputs_embeds=inputs_embeds, + attention_mask=attention_mask, + past_key_values=past_key_values, + position_ids=position_ids, + ) + encoder_attention_mask = create_bidirectional_mask( + config=self.config, + inputs_embeds=inputs_embeds, + attention_mask=encoder_attention_mask, + encoder_hidden_states=encoder_hidden_states, + ) + + hidden_states = inputs_embeds + for decoder_layer in self.layers: + hidden_states = decoder_layer( + hidden_states, + causal_mask, + encoder_hidden_states, # as a positional argument for gradient checkpointing + encoder_attention_mask=encoder_attention_mask, + position_ids=position_ids, + past_key_values=past_key_values, + **kwargs, + ) + + hidden_states = self.norm(hidden_states) + + return BaseModelOutputWithPastAndCrossAttentions( + last_hidden_state=hidden_states, + past_key_values=past_key_values if use_cache else None, + ) + + +@auto_docstring( + custom_intro=""" + The bare Canary model (FastConformer encoder + Transformer decoder) outputting raw hidden-states without any + specific head on top. + """ +) +class CanaryModel(CanaryPreTrainedModel): + def __init__(self, config: CanaryConfig): + super().__init__(config) + self.encoder = AutoModel.from_config(config.encoder_config) + self.decoder = CanaryDecoder(config.decoder_config) + # Initialize weights and apply final processing + self.post_init() + + def get_input_embeddings(self): + return self.decoder.embed_tokens + + def set_input_embeddings(self, value): + self.decoder.embed_tokens = value + + def freeze_encoder(self): + """ + Calling this function will disable the gradient computation for the Canary encoder so that its parameters will + not be updated during training. + """ + self.encoder._freeze_parameters() + + def _mask_input_features(self): + """ + Masks extracted features along time axis and/or along feature axis according to + [SpecAugment](https://huggingface.co/papers/1904.08779). + """ + raise AttributeError("Not needed for Canary") + + @can_return_tuple + @auto_docstring + def forward( + self, + input_features: torch.FloatTensor | None = None, + attention_mask: torch.LongTensor | None = None, + decoder_input_ids: torch.LongTensor | None = None, + decoder_attention_mask: torch.LongTensor | None = None, + encoder_outputs: tuple[tuple[torch.FloatTensor]] | None = None, + past_key_values: EncoderDecoderCache | None = None, + decoder_inputs_embeds: tuple[torch.FloatTensor] | None = None, + decoder_position_ids: tuple[torch.LongTensor] | None = None, + use_cache: bool | None = None, + **kwargs: Unpack[TransformersKwargs], + ) -> Seq2SeqModelOutput: + r""" + input_features (`torch.FloatTensor` of shape `(batch_size, audio_length)`): + Float values of the raw speech waveform. Raw speech waveform can be + obtained by loading a `.flac` or `.wav` audio file into an array of type `list[float]`, a + `numpy.ndarray` or a `torch.Tensor`, *e.g.* via the torchcodec library (`pip install torchcodec`) or + the soundfile library (`pip install soundfile`). To prepare the array into + `input_features`, the [`AutoFeatureExtractor`] should be used for padding + and conversion into a tensor of type `torch.FloatTensor`. + decoder_position_ids (`torch.LongTensor` of shape `(batch_size, target_sequence_length)`): + Indices of positions of each input sequence tokens in the position embeddings. + Used to calculate the position embeddings up to `config.decoder_config.max_position_embeddings` + + Example: + + ```python + >>> import torch + >>> from transformers import AutoFeatureExtractor, CanaryModel + >>> from datasets import load_dataset + + >>> model = CanaryModel.from_pretrained("UsefulSensors/canary-tiny") + >>> feature_extractor = AutoFeatureExtractor.from_pretrained("UsefulSensors/canary-tiny") + >>> ds = load_dataset("hf-internal-testing/librispeech_asr_dummy", "clean", split="validation") + >>> inputs = feature_extractor(ds[0]["audio"]["array"], return_tensors="pt") + >>> input_features = inputs.input_features + >>> decoder_input_ids = torch.tensor([[1, 1]]) * model.config.decoder_start_token_id + >>> last_hidden_state = model(input_features, decoder_input_ids=decoder_input_ids).last_hidden_state + >>> list(last_hidden_state.shape) + [1, 2, 288] + ``` + """ + # Main difference: uses `input_features` instead of `input_values` + if encoder_outputs is None: + encoder_outputs: BaseModelOutput = self.encoder(input_features, attention_mask=attention_mask, **kwargs) + + decoder_outputs: BaseModelOutputWithPastAndCrossAttentions = self.decoder( + input_ids=decoder_input_ids, + attention_mask=decoder_attention_mask, + encoder_hidden_states=encoder_outputs.last_hidden_state, + encoder_attention_mask=encoder_outputs.attention_mask, + past_key_values=past_key_values, + inputs_embeds=decoder_inputs_embeds, + position_ids=decoder_position_ids, + use_cache=use_cache, + **kwargs, + ) + + return Seq2SeqModelOutput( + last_hidden_state=decoder_outputs.last_hidden_state, + past_key_values=decoder_outputs.past_key_values, + decoder_hidden_states=decoder_outputs.hidden_states, + decoder_attentions=decoder_outputs.attentions, + cross_attentions=decoder_outputs.cross_attentions, + encoder_last_hidden_state=encoder_outputs.last_hidden_state, + encoder_hidden_states=encoder_outputs.hidden_states, + encoder_attentions=encoder_outputs.attentions, + ) + + +def shift_tokens_right(input_ids: torch.Tensor, pad_token_id: int, decoder_start_token_id: int): + """ + Shift input ids one token to the right. + """ + shifted_input_ids = input_ids.new_zeros(input_ids.shape) + shifted_input_ids[:, 1:] = input_ids[:, :-1].clone() + shifted_input_ids[:, 0] = decoder_start_token_id + + if pad_token_id is None: + raise ValueError("self.model.config.pad_token_id has to be defined.") + # replace possible -100 values in labels by `pad_token_id` + shifted_input_ids.masked_fill_(shifted_input_ids == -100, pad_token_id) + + return shifted_input_ids + + +@auto_docstring( + custom_intro=""" + The Canary model with a language modeling head. Can be used for multilingual automatic speech recognition and + speech-to-text translation. + """ +) +class CanaryForConditionalGeneration(CanaryPreTrainedModel, GenerationMixin): + _tied_weights_keys = {"proj_out.weight": "model.decoder.embed_tokens.weight"} + + def __init__(self, config: CanaryConfig): + super().__init__(config) + self.model = CanaryModel(config) + self.proj_out = nn.Linear(config.decoder_config.hidden_size, config.decoder_config.vocab_size, bias=True) + + # Initialize weights and apply final processing + self.post_init() + + def get_output_embeddings(self): + return self.proj_out + + def set_output_embeddings(self, new_embeddings): + self.proj_out = new_embeddings + + def get_input_embeddings(self) -> nn.Module: + return self.model.get_input_embeddings() + + @can_return_tuple + @auto_docstring + def forward( + self, + input_features: torch.FloatTensor | None = None, + attention_mask: torch.LongTensor | None = None, + decoder_input_ids: torch.LongTensor | None = None, + decoder_attention_mask: torch.LongTensor | None = None, + encoder_outputs: tuple[tuple[torch.FloatTensor]] | None = None, + past_key_values: EncoderDecoderCache | None = None, + decoder_inputs_embeds: tuple[torch.FloatTensor] | None = None, + decoder_position_ids: tuple[torch.LongTensor] | None = None, + use_cache: bool | None = None, + labels: torch.LongTensor | None = None, + **kwargs: Unpack[TransformersKwargs], + ) -> Seq2SeqLMOutput: + r""" + input_features (`torch.FloatTensor` of shape `(batch_size, audio_length)`): + Float values of the raw speech waveform. Raw speech waveform can be + obtained by loading a `.flac` or `.wav` audio file into an array of type `list[float]`, a + `numpy.ndarray` or a `torch.Tensor`, *e.g.* via the torchcodec library (`pip install torchcodec`) or + the soundfile library (`pip install soundfile`). To prepare the array into + `input_features`, the [`AutoFeatureExtractor`] should be used for padding + and conversion into a tensor of type `torch.FloatTensor`. + decoder_position_ids (`torch.LongTensor` of shape `(batch_size, target_sequence_length)`): + Indices of positions of each input sequence tokens in the position embeddings. + Used to calculate the position embeddings up to `config.decoder_config.max_position_embeddings` + + Example: + + ```python + >>> import torch + >>> from transformers import AutoProcessor, CanaryForConditionalGeneration + >>> from datasets import load_dataset + + >>> processor = AutoProcessor.from_pretrained("UsefulSensors/canary-tiny") + >>> model = CanaryForConditionalGeneration.from_pretrained("UsefulSensors/canary-tiny") + + >>> ds = load_dataset("hf-internal-testing/librispeech_asr_dummy", "clean", split="validation") + + >>> inputs = processor(ds[0]["audio"]["array"], return_tensors="pt") + >>> input_features = inputs.input_features + + >>> generated_ids = model.generate(input_features, max_new_tokens=100) + + >>> transcription = processor.batch_decode(generated_ids, skip_special_tokens=True)[0] + >>> transcription + 'Mr. Quilter is the apostle of the middle classes, and we are glad to welcome his gospel.' + ```""" + # Main difference: uses `input_features` instead of `input_values` + if labels is not None: + if decoder_input_ids is None and decoder_inputs_embeds is None: + decoder_input_ids = shift_tokens_right( + labels, self.config.pad_token_id, self.config.decoder_start_token_id + ) + + outputs: Seq2SeqModelOutput = self.model( + input_features, + attention_mask=attention_mask, + decoder_input_ids=decoder_input_ids, + encoder_outputs=encoder_outputs, + decoder_attention_mask=decoder_attention_mask, + past_key_values=past_key_values, + decoder_inputs_embeds=decoder_inputs_embeds, + decoder_position_ids=decoder_position_ids, + use_cache=use_cache, + **kwargs, + ) + logits = self.proj_out(outputs.last_hidden_state) + + loss = None + if labels is not None: + shift_labels = kwargs.pop("shift_labels", labels) + loss = self.loss_function( + logits=logits, + labels=labels, + vocab_size=self.config.vocab_size, + shift_labels=shift_labels, + **kwargs, + ) + + return Seq2SeqLMOutput( + loss=loss, + logits=logits, + past_key_values=outputs.past_key_values, + decoder_hidden_states=outputs.decoder_hidden_states, + decoder_attentions=outputs.decoder_attentions, + cross_attentions=outputs.cross_attentions, + encoder_last_hidden_state=outputs.encoder_last_hidden_state, + encoder_hidden_states=outputs.encoder_hidden_states, + encoder_attentions=outputs.encoder_attentions, + ) + + def prepare_inputs_for_generation(self, *args, audio_chunk_index=None, **kwargs): + # audio_chunk_index is returned by the processor but not used by the model, absorb it here + return super().prepare_inputs_for_generation(*args, **kwargs) + + +__all__ = ["CanaryForConditionalGeneration", "CanaryModel", "CanaryPreTrainedModel"] diff --git a/src/transformers/models/canary/modular_canary.py b/src/transformers/models/canary/modular_canary.py new file mode 100644 index 000000000000..e6b7272c7e8c --- /dev/null +++ b/src/transformers/models/canary/modular_canary.py @@ -0,0 +1,107 @@ +# Copyright 2026 The HuggingFace Inc. team. 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. +"""PyTorch Canary model.""" + +import math + +import numpy as np +import torch +from torch import nn + +from ... import initialization as init +from ...modeling_utils import PreTrainedModel +from ...utils import auto_docstring, logging +from ..cohere_asr.modeling_cohere_asr import ( + CohereAsrDecoder, + CohereAsrForConditionalGeneration, + CohereAsrModel, + CohereAsrPreTrainedModel, +) +from ..qwen2_5_omni.modeling_qwen2_5_omni import SinusoidsPositionEmbedding +from .configuration_canary import CanaryConfig, CanaryDecoderConfig + + +logger = logging.get_logger(__name__) + + +class CanaryPositionalEmbedding(SinusoidsPositionEmbedding): + """ + Identical to [`SinusoidsPositionEmbedding`] except that the timescales and the `1 / sqrt(channels)` scaling match + NeMo's `FixedPositionalEncoding`, and it is indexed by `position_ids`. + """ + + def __init__(self, length: int, channels: int): + max_timescale = 10000 ** ((channels - 2) / channels) + super().__init__(length, channels, max_timescale) + + def compute_default_singular_positional_embedding(self) -> torch.Tensor: + log_timescale_increment = np.log(self.max_timescale) / (self.channels // 2 - 1) + inv_timescales = torch.exp(-log_timescale_increment * torch.arange(self.channels // 2).float()) + scaled_time = torch.arange(self.length)[:, np.newaxis] * inv_timescales[np.newaxis, :] + emb = torch.cat([torch.sin(scaled_time), torch.cos(scaled_time)], dim=1) / math.sqrt(self.channels) + return emb.to(torch.get_default_dtype()) + + def forward(self, position_ids: torch.Tensor) -> torch.Tensor: + return self.positional_embedding[position_ids] + + +@auto_docstring +class CanaryPreTrainedModel(CohereAsrPreTrainedModel): + config: CanaryConfig + _no_split_modules = ["ParakeetEncoderBlock", "CanaryDecoderLayer"] + + def _get_feat_extract_output_lengths(self): + raise AttributeError("Not needed for Canary") + + @torch.no_grad() + def _init_weights(self, module): + PreTrainedModel._init_weights(self, module) + if isinstance(module, CanaryPositionalEmbedding): + init.copy_(module.positional_embedding, module.compute_default_singular_positional_embedding()) + + +class CanaryDecoder(CohereAsrDecoder): + config: CanaryDecoderConfig + + def __init__(self, config: CanaryDecoderConfig): + super().__init__(config) + self.pos_emb = CanaryPositionalEmbedding(config.max_position_embeddings, config.hidden_size) + self.proj = nn.Identity() + + +@auto_docstring( + custom_intro=""" + The bare Canary model (FastConformer encoder + Transformer decoder) outputting raw hidden-states without any + specific head on top. + """ +) +class CanaryModel(CohereAsrModel): + def __init__(self, config: CanaryConfig): + super().__init__(config) + self.decoder = CanaryDecoder(config.decoder_config) + + +@auto_docstring( + custom_intro=""" + The Canary model with a language modeling head. Can be used for multilingual automatic speech recognition and + speech-to-text translation. + """ +) +class CanaryForConditionalGeneration(CohereAsrForConditionalGeneration): + def __init__(self, config: CanaryConfig): + super().__init__(config) + self.proj_out = nn.Linear(config.decoder_config.hidden_size, config.decoder_config.vocab_size, bias=True) + + +__all__ = ["CanaryForConditionalGeneration", "CanaryModel", "CanaryPreTrainedModel"] diff --git a/src/transformers/models/canary/processing_canary.py b/src/transformers/models/canary/processing_canary.py new file mode 100644 index 000000000000..9ab992a84e32 --- /dev/null +++ b/src/transformers/models/canary/processing_canary.py @@ -0,0 +1,178 @@ +# Copyright 2026 The HuggingFace Inc. team. 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. + +from ...audio_utils import ( + AudioInput, + make_audio_chat_template_content, + make_list_of_audio_chat_template, + prepare_language_inputs, +) +from ...feature_extraction_utils import BatchFeature +from ...processing_utils import ProcessingKwargs, ProcessorMixin, Unpack +from ...tokenization_utils_base import PreTokenizedInput, TextInput +from ...utils import auto_docstring, logging +from ...utils.import_utils import requires + + +logger = logging.get_logger(__name__) + + +# fmt: off +# Languages supported by Canary. See https://huggingface.co/nvidia/canary-1b-v2 for details. +LANGUAGE_CODE_TO_NAME = { + "bg": "Bulgarian", + "hr": "Croatian", + "cs": "Czech", + "da": "Danish", + "nl": "Dutch", + "en": "English", + "et": "Estonian", + "fi": "Finnish", + "fr": "French", + "de": "German", + "el": "Greek", + "hu": "Hungarian", + "it": "Italian", + "lv": "Latvian", + "lt": "Lithuanian", + "mt": "Maltese", + "pl": "Polish", + "pt": "Portuguese", + "ro": "Romanian", + "sk": "Slovak", + "sl": "Slovenian", + "es": "Spanish", + "sv": "Swedish", + "ru": "Russian", + "uk": "Ukrainian", +} +# fmt: on + + +class CanaryProcessorKwargs(ProcessingKwargs, total=False): # trf-ignore: TRF019 + _defaults = { + "audio_kwargs": { + "sampling_rate": 16000, + }, + } + + +@requires(backends=("torch",)) +@auto_docstring +class CanaryProcessor(ProcessorMixin): + valid_processor_kwargs = CanaryProcessorKwargs + + def __init__(self, feature_extractor=None, tokenizer=None, chat_template=None): + super().__init__(feature_extractor, tokenizer, chat_template=chat_template) + + @auto_docstring + def __call__( + self, + audio: AudioInput, + text: TextInput | PreTokenizedInput | list[TextInput] | list[PreTokenizedInput] | None = None, + output_labels: bool = False, + **kwargs: Unpack[CanaryProcessorKwargs], + ) -> BatchFeature: + r""" + text (`str`, `list[str]`, *optional*): + The decoder prompt(s) produced by the chat template. It is tokenized into `decoder_input_ids`. + output_labels (`bool`, *optional*, defaults to `False`): + Whether to return labels for training. + """ + + if "return_tensors" in kwargs and kwargs["return_tensors"] != "pt": + raise ValueError(f"{self.__class__.__name__} only supports `return_tensors='pt'`.") + + model_inputs = super().__call__(audio=audio, text=text, **kwargs) + model_inputs = BatchFeature(data=model_inputs, tensor_type="pt") + if text is not None: + input_ids = model_inputs.pop("input_ids") + if output_labels: + # the decoder inputs are already right-shifted with respect to `labels` + model_inputs["decoder_input_ids"] = input_ids[..., :-1] + labels = input_ids[..., 1:].clone() + labels[labels == self.tokenizer.pad_token_id] = -100 + model_inputs["labels"] = labels + else: + model_inputs["decoder_input_ids"] = input_ids + return model_inputs + + def apply_transcription_request( + self, + audio: AudioInput | list[AudioInput], + source_language: str | list[str] = "en", + target_language: str | list[str] | None = None, + punctuation: bool = True, + **kwargs: Unpack[CanaryProcessorKwargs], + ) -> BatchFeature: + r""" + Prepare inputs for transcription or translation without manually writing the chat template. + + Args: + audio (`AudioInput` or `list[AudioInput]`): + Audio to transcribe or translate. Can be a URL string, local path, numpy array, or a list of these. + source_language (`str` or `list[str]`, *optional*, defaults to `"en"`): + The language of the input speech. Accepts ISO codes (e.g. `"en"`, `"de"`, `"fr"`) or full names + (e.g. `"English"`, `"German"`, `"French"`). + target_language (`str` or `list[str]`, *optional*): + The language of the output text. Accepts ISO codes or full names. Defaults to `source_language` + (transcription); set it to a different language for speech-to-text translation. + punctuation (`bool`, *optional*, defaults to `True`): + Whether to request punctuation and capitalization in the output. + **kwargs: + Additional keyword arguments forwarded to [`~CanaryProcessor.apply_chat_template`]. + + Returns: + [`BatchFeature`]: Processor outputs ready to be passed to + [`CanaryForConditionalGeneration.generate`]. + """ + audio_items = make_list_of_audio_chat_template(audio) + batch_size = len(audio_items) + if batch_size == 0: + raise ValueError("`audio` must contain at least one sample.") + + source_languages = prepare_language_inputs(source_language, batch_size, LANGUAGE_CODE_TO_NAME) + if target_language is None: + target_languages = list(source_languages) + else: + target_languages = prepare_language_inputs(target_language, batch_size, LANGUAGE_CODE_TO_NAME) + + conversations = [] + for source, target, audio_item in zip(source_languages, target_languages, audio_items): + content = [ + make_audio_chat_template_content(audio_item), + { + "type": "text", + "source_language": source, + "target_language": target, + "punctuation": punctuation, + }, + ] + conversations.append([{"role": "user", "content": content}]) + + return self.apply_chat_template( + conversations, + tokenize=True, + add_generation_prompt=True, + return_dict=True, + **kwargs, + ) + + @property + def model_input_names(self): + feature_extractor_input_names = self.feature_extractor.model_input_names + return feature_extractor_input_names + ["decoder_input_ids", "labels"] + + +__all__ = ["CanaryProcessor"] diff --git a/src/transformers/models/glmasr/modular_glmasr.py b/src/transformers/models/glmasr/modular_glmasr.py index 0027dd5992f6..8dd1bcf9591b 100644 --- a/src/transformers/models/glmasr/modular_glmasr.py +++ b/src/transformers/models/glmasr/modular_glmasr.py @@ -17,7 +17,7 @@ import numpy as np from ...activations import ACT2FN -from ...audio_utils import AudioInput, make_list_of_audio_chat_template +from ...audio_utils import AudioInput, make_audio_chat_template_content, make_list_of_audio_chat_template from ...cache_utils import Cache from ...feature_extraction_utils import BatchFeature from ...modeling_layers import GradientCheckpointingLayer @@ -146,9 +146,7 @@ def apply_transcription_request( { "role": "user", "content": [ - {"type": "audio", "path": audio_item} - if isinstance(audio_item, str) - else {"type": "audio", "audio": audio_item}, + make_audio_chat_template_content(audio_item), {"type": "text", "text": prompt_text}, ], } diff --git a/src/transformers/models/glmasr/processing_glmasr.py b/src/transformers/models/glmasr/processing_glmasr.py index 61366a5c47ec..4eb2c3d9fb9a 100644 --- a/src/transformers/models/glmasr/processing_glmasr.py +++ b/src/transformers/models/glmasr/processing_glmasr.py @@ -21,7 +21,7 @@ import numpy as np -from ...audio_utils import AudioInput, make_list_of_audio_chat_template +from ...audio_utils import AudioInput, make_audio_chat_template_content, make_list_of_audio_chat_template from ...feature_extraction_utils import BatchFeature from ...processing_utils import ProcessingKwargs, ProcessorMixin, Unpack from ...tokenization_utils_base import TextInput @@ -245,9 +245,7 @@ def apply_transcription_request( { "role": "user", "content": [ - {"type": "audio", "path": audio_item} - if isinstance(audio_item, str) - else {"type": "audio", "audio": audio_item}, + make_audio_chat_template_content(audio_item), {"type": "text", "text": prompt_text}, ], } diff --git a/src/transformers/models/qwen3_asr/processing_qwen3_asr.py b/src/transformers/models/qwen3_asr/processing_qwen3_asr.py index 4fc4e0ab64ad..0ef083f55fb0 100644 --- a/src/transformers/models/qwen3_asr/processing_qwen3_asr.py +++ b/src/transformers/models/qwen3_asr/processing_qwen3_asr.py @@ -16,7 +16,12 @@ import numpy as np -from ...audio_utils import AudioInput, make_list_of_audio_chat_template +from ...audio_utils import ( + AudioInput, + make_audio_chat_template_content, + make_list_of_audio_chat_template, + prepare_language_inputs, +) from ...feature_extraction_utils import BatchFeature from ...processing_utils import ProcessingKwargs, ProcessorMixin, Unpack, prepare_prompt_input from ...tokenization_utils_base import TextInput @@ -67,63 +72,6 @@ } # fmt: on -SUPPORTED_LANGUAGE_NAMES = set(LANGUAGE_CODE_TO_NAME.values()) - - -def resolve_language(language: str | None) -> str | None: - """Map a language code or name to the canonical full name, with validation. - - Accepts language codes (e.g. ``"zh"``, ``"en"``) or full names - (e.g. ``"Chinese"``, ``"English"``). Returns the full name. - Raises ``ValueError`` if the language is not recognized. - ``None`` passes through unchanged (auto-detect). - """ - if language is None: - return None - # Try code lookup first - resolved = LANGUAGE_CODE_TO_NAME.get(language.lower()) - if resolved is not None: - return resolved - # Check if it's already a valid full name (case-insensitive) - for name in SUPPORTED_LANGUAGE_NAMES: - if language.lower() == name.lower(): - return name - raise ValueError( - f"Unsupported language: {language!r}. Use a language code " - f"(e.g. 'en', 'zh') or full name (e.g. 'English', 'Chinese'). " - f"Supported codes: {sorted(LANGUAGE_CODE_TO_NAME.keys())}. " - f"Supported names: {sorted(SUPPORTED_LANGUAGE_NAMES)}." - ) - - -def _prepare_language_inputs( - language: str | list[str] | None, batch_size: int, allow_broadcast: bool = False -) -> list[str | None]: - """Broadcast / validate a language argument to match batch_size. - - Accepts language codes (e.g. ``"zh"``, ``"en"``) or full names - (e.g. ``"Chinese"``, ``"English"``). Each value is resolved to the - canonical full language name via :func:`resolve_language`. - """ - if language is None: - return [None] * batch_size - if isinstance(language, str): - return [resolve_language(language)] * batch_size - if isinstance(language, (list, tuple)): - if allow_broadcast and len(language) == 1 and batch_size > 1: - return [resolve_language(language[0])] * batch_size - if len(language) != batch_size: - raise ValueError(f"Got {len(language)} language(s) for {batch_size} sample(s); counts must match.") - return [resolve_language(lang) for lang in language] - raise TypeError("`language` must be a string, a list of strings, or `None`.") - - -def _audio_content_item(audio_item) -> dict: - """Build a chat-template content dict for a single audio item.""" - if isinstance(audio_item, str): - return {"type": "audio", "path": audio_item} - return {"type": "audio", "audio": audio_item} - def _is_cjk_char(char: str) -> bool: """ @@ -527,7 +475,7 @@ def apply_transcription_request( batch_size = len(audio_items) if batch_size == 0: raise ValueError("`audio` must contain at least one sample.") - languages = _prepare_language_inputs(language, batch_size) + languages = prepare_language_inputs(language, batch_size, LANGUAGE_CODE_TO_NAME, return_code=False) prompts = prepare_prompt_input(prompt, batch_size, input_name="prompt") @@ -536,7 +484,7 @@ def apply_transcription_request( messages = [] if prompt_text is not None: messages.append({"role": "system", "content": [{"type": "text", "text": prompt_text}]}) - messages.append({"role": "user", "content": [_audio_content_item(audio_item)]}) + messages.append({"role": "user", "content": [make_audio_chat_template_content(audio_item)]}) conversations.append(messages) # The language is forced by prefilling the assistant turn with "language " @@ -726,7 +674,9 @@ def prepare_forced_aligner_inputs( if len(transcript) != batch_size: raise ValueError(f"Got {len(transcript)} transcript(s) but {batch_size} audio(s); they must match 1:1.") - languages = _prepare_language_inputs(language, batch_size, allow_broadcast=True) + languages = prepare_language_inputs( + language, batch_size, LANGUAGE_CODE_TO_NAME, allow_broadcast=True, return_code=False + ) # Validate that all languages are supported by the forced aligner for lang in languages: @@ -744,7 +694,7 @@ def prepare_forced_aligner_inputs( conversations = [] for wl, audio_item in zip(word_lists, audio_items): - content = [_audio_content_item(audio_item)] + content = [make_audio_chat_template_content(audio_item)] content.extend({"type": "text", "text": word} for word in wl) conversations.append([{"role": "user", "content": content}]) diff --git a/src/transformers/models/vibevoice_asr/processing_vibevoice_asr.py b/src/transformers/models/vibevoice_asr/processing_vibevoice_asr.py index 01880f3a526f..8eed3485fa7d 100644 --- a/src/transformers/models/vibevoice_asr/processing_vibevoice_asr.py +++ b/src/transformers/models/vibevoice_asr/processing_vibevoice_asr.py @@ -17,7 +17,7 @@ import numpy as np -from ...audio_utils import AudioInput, make_list_of_audio_chat_template +from ...audio_utils import AudioInput, make_audio_chat_template_content, make_list_of_audio_chat_template from ...feature_extraction_utils import BatchFeature from ...processing_utils import ProcessingKwargs, ProcessorMixin, Unpack, prepare_prompt_input from ...tokenization_utils_base import TextInput @@ -210,11 +210,7 @@ def apply_transcription_request( conversations = [] for prompt_text, audio_item in zip(prompts, audio_items): - content = [] - if isinstance(audio_item, str): - content.append({"type": "audio", "path": audio_item}) - else: - content.append({"type": "audio", "audio": audio_item}) + content = [make_audio_chat_template_content(audio_item)] if prompt_text is not None: content.append({"type": "text", "text": prompt_text}) diff --git a/tests/fixtures/canary/expected_results_batch.json b/tests/fixtures/canary/expected_results_batch.json new file mode 100644 index 000000000000..2fcbd261ab45 --- /dev/null +++ b/tests/fixtures/canary/expected_results_batch.json @@ -0,0 +1,6 @@ +{ + "transcriptions": [ + "mister Quilter is the apostle of the middle classes, and we are glad to welcome his gospel.", + "Auch Mister Quilters Manner ist nicht weniger interessant als sein Material." + ] +} \ No newline at end of file diff --git a/tests/fixtures/canary/expected_results_transcription.json b/tests/fixtures/canary/expected_results_transcription.json new file mode 100644 index 000000000000..c57f34bd12ad --- /dev/null +++ b/tests/fixtures/canary/expected_results_transcription.json @@ -0,0 +1,5 @@ +{ + "transcriptions": [ + "mister Quilter is the apostle of the middle classes, and we are glad to welcome his gospel." + ] +} \ No newline at end of file diff --git a/tests/fixtures/canary/expected_results_translation.json b/tests/fixtures/canary/expected_results_translation.json new file mode 100644 index 000000000000..323383b6174d --- /dev/null +++ b/tests/fixtures/canary/expected_results_translation.json @@ -0,0 +1,5 @@ +{ + "transcriptions": [ + "Mister Quilter ist der Apostel der Mittelschicht, und wir freuen uns, sein Evangelium willkommen zu hei\u00dfen." + ] +} \ No newline at end of file diff --git a/tests/models/canary/__init__.py b/tests/models/canary/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/models/canary/test_modeling_canary.py b/tests/models/canary/test_modeling_canary.py new file mode 100644 index 000000000000..b22a06db4fc6 --- /dev/null +++ b/tests/models/canary/test_modeling_canary.py @@ -0,0 +1,558 @@ +# Copyright 2026 The HuggingFace Inc. team. 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. +"""Testing suite for the PyTorch Canary model.""" + +import copy +import json +import math +import unittest +from pathlib import Path + +from transformers import CanaryConfig, CanaryDecoderConfig, ParakeetEncoderConfig, is_torch_available +from transformers.testing_utils import is_flaky, require_torch, slow, torch_device + +from ...generation.test_utils import GenerationTesterMixin +from ...test_configuration_common import ConfigTester +from ...test_modeling_common import ModelTesterMixin, floats_tensor, ids_tensor +from ...test_pipeline_mixin import PipelineTesterMixin + + +if is_torch_available(): + import torch + + from transformers import CanaryForConditionalGeneration, CanaryModel, StaticCache + + +class CanaryModelTester: + def __init__( + self, + parent, + batch_size=3, # need batch_size != num_hidden_layers + seq_length=80, + is_training=False, + use_labels=False, + num_mel_bins=80, + hidden_size=16, + intermediate_size=32, + num_hidden_layers=2, + num_attention_heads=2, + subsampling_factor=8, + subsampling_conv_channels=16, + decoder_seq_length=4, + vocab_size=99, + max_position_embeddings=40, + decoder_start_token_id=7, + pad_token_id=2, + bos_token_id=4, + eos_token_id=3, + ): + self.parent = parent + self.batch_size = batch_size + self.seq_length = seq_length + self.decoder_seq_length = decoder_seq_length + self.decoder_key_length = decoder_seq_length + self.is_training = is_training + self.use_labels = use_labels + self.num_mel_bins = num_mel_bins + self.hidden_size = hidden_size + self.intermediate_size = intermediate_size + self.num_hidden_layers = num_hidden_layers + self.num_attention_heads = num_attention_heads + self.subsampling_factor = subsampling_factor + self.subsampling_conv_channels = subsampling_conv_channels + self.vocab_size = vocab_size + self.max_position_embeddings = max_position_embeddings + self.decoder_start_token_id = decoder_start_token_id + self.pad_token_id = pad_token_id + self.bos_token_id = bos_token_id + self.eos_token_id = eos_token_id + + def get_config(self): + encoder_config = ParakeetEncoderConfig( + hidden_size=self.hidden_size, + num_hidden_layers=self.num_hidden_layers, + num_attention_heads=self.num_attention_heads, + intermediate_size=self.intermediate_size, + num_mel_bins=self.num_mel_bins, + subsampling_factor=self.subsampling_factor, + subsampling_conv_channels=self.subsampling_conv_channels, + scale_input=False, + ) + decoder_config = CanaryDecoderConfig( + vocab_size=self.vocab_size, + hidden_size=self.hidden_size, + intermediate_size=self.intermediate_size, + num_hidden_layers=self.num_hidden_layers, + num_attention_heads=self.num_attention_heads, + max_position_embeddings=self.max_position_embeddings, + pad_token_id=self.pad_token_id, + ) + return CanaryConfig( + encoder_config=encoder_config, + decoder_config=decoder_config, + decoder_start_token_id=self.decoder_start_token_id, + pad_token_id=self.pad_token_id, + bos_token_id=self.bos_token_id, + eos_token_id=self.eos_token_id, + ) + + def prepare_config_and_inputs_for_common(self): + config = self.get_config() + # `seq_length` is in mel frames; keep it a multiple of `subsampling_factor` so 8x subsampling does not collapse it. + input_features = floats_tensor([self.batch_size, self.seq_length, self.num_mel_bins], scale=1.0) + attention_mask = torch.ones([self.batch_size, self.seq_length], dtype=torch.long, device=torch_device) + decoder_input_ids = ids_tensor([self.batch_size, self.decoder_seq_length], self.vocab_size) + decoder_attention_mask = decoder_input_ids.ne(self.pad_token_id) + inputs_dict = { + "input_features": input_features, + "attention_mask": attention_mask, + "decoder_input_ids": decoder_input_ids, + "decoder_attention_mask": decoder_attention_mask, + } + return config, inputs_dict + + def get_subsampled_output_lengths(self, input_lengths): + """Computes the FastConformer subsampled length, used by the generation test mixin for encoder shapes.""" + kernel_size, stride = 3, 2 + padding = (kernel_size - 1) // 2 * 2 - kernel_size + for _ in range(int(math.log2(self.subsampling_factor))): + input_lengths = (input_lengths + padding) // stride + 1 + return input_lengths + + +@require_torch +class CanaryModelTest(ModelTesterMixin, GenerationTesterMixin, PipelineTesterMixin, unittest.TestCase): + all_model_classes = (CanaryModel, CanaryForConditionalGeneration) if is_torch_available() else () + all_generative_model_classes = (CanaryForConditionalGeneration,) if is_torch_available() else () + pipeline_model_mapping = ( + { + "automatic-speech-recognition": CanaryForConditionalGeneration, + "feature-extraction": CanaryModel, + } + if is_torch_available() + else {} + ) + is_encoder_decoder = True + test_pruning = False + test_resize_embeddings = True + test_headmasking = False + + def setUp(self): + self.model_tester = CanaryModelTester(self) + self.config_tester = ConfigTester(self, has_text_modality=False, config_class=CanaryConfig) + + def test_config(self): + self.config_tester.run_common_tests() + + # Overridden because the FastConformer encoder subsamples the input, so encoder shapes use the subsampled length. + def test_hidden_states_output(self): + def check_hidden_states_output(inputs_dict, config, model_class): + model = model_class(config) + model.to(torch_device) + model.eval() + + with torch.no_grad(): + outputs = model(**self._prepare_for_class(inputs_dict, model_class)) + + hidden_states = outputs.encoder_hidden_states if config.is_encoder_decoder else outputs.hidden_states + + expected_num_layers = getattr( + self.model_tester, "expected_num_hidden_layers", self.model_tester.num_hidden_layers + 1 + ) + self.assertEqual(len(hidden_states), expected_num_layers) + + subsampled_seq_length = self.model_tester.get_subsampled_output_lengths(self.model_tester.seq_length) + self.assertListEqual( + list(hidden_states[0].shape[-2:]), + [subsampled_seq_length, self.model_tester.hidden_size], + ) + + if config.is_encoder_decoder: + hidden_states = outputs.decoder_hidden_states + self.assertIsInstance(hidden_states, (list, tuple)) + self.assertEqual(len(hidden_states), expected_num_layers) + self.assertListEqual( + list(hidden_states[0].shape[-2:]), + [self.model_tester.decoder_seq_length, self.model_tester.hidden_size], + ) + + config, inputs_dict = self.model_tester.prepare_config_and_inputs_for_common() + + for model_class in self.all_model_classes: + inputs_dict["output_hidden_states"] = True + check_hidden_states_output(inputs_dict, config, model_class) + + del inputs_dict["output_hidden_states"] + config.output_hidden_states = True + self._set_subconfig_attributes(config, "output_hidden_states", True) + check_hidden_states_output(inputs_dict, config, model_class) + + # Overridden for the same subsampling reason as `test_hidden_states_output` (mirrors `WhisperModelTest`). + def test_attention_outputs(self): + config, inputs_dict = self.model_tester.prepare_config_and_inputs_for_common() + config.return_dict = True + + # force eager attention to support output attentions + config._attn_implementation = "eager" + + seq_len = getattr(self.model_tester, "seq_length", None) + decoder_seq_length = getattr(self.model_tester, "decoder_seq_length", 1) + encoder_seq_length = getattr(self.model_tester, "encoder_seq_length", seq_len) + decoder_key_length = getattr(self.model_tester, "decoder_key_length", 1) + encoder_key_length = getattr(self.model_tester, "key_length", encoder_seq_length) + + for model_class in self.all_model_classes: + inputs_dict["output_attentions"] = True + inputs_dict["output_hidden_states"] = False + config.return_dict = True + model = model_class._from_config(config, attn_implementation="eager") + config = model.config + model.to(torch_device) + model.eval() + + subsampled_encoder_seq_length = self.model_tester.get_subsampled_output_lengths(encoder_seq_length) + subsampled_encoder_key_length = self.model_tester.get_subsampled_output_lengths(encoder_key_length) + + with torch.no_grad(): + outputs = model(**self._prepare_for_class(inputs_dict, model_class)) + attentions = outputs.encoder_attentions if config.is_encoder_decoder else outputs.attentions + self.assertEqual(len(attentions), self.model_tester.num_hidden_layers) + + # check that output_attentions also work using config + del inputs_dict["output_attentions"] + config.output_attentions = True + self._set_subconfig_attributes(config, "output_attentions", True) + model = model_class(config) + model.to(torch_device) + model.eval() + with torch.no_grad(): + outputs = model(**self._prepare_for_class(inputs_dict, model_class)) + attentions = outputs.encoder_attentions if config.is_encoder_decoder else outputs.attentions + self.assertEqual(len(attentions), self.model_tester.num_hidden_layers) + + self.assertListEqual( + list(attentions[0].shape[-3:]), + [self.model_tester.num_attention_heads, subsampled_encoder_seq_length, subsampled_encoder_key_length], + ) + out_len = len(outputs) + + correct_outlen = 5 + + # loss is at first position + if "labels" in inputs_dict: + correct_outlen += 1 # loss is added to beginning + if "past_key_values" in outputs: + correct_outlen += 1 # past_key_values have been returned + + self.assertEqual(out_len, correct_outlen) + + # decoder attentions + decoder_attentions = outputs.decoder_attentions + self.assertIsInstance(decoder_attentions, (list, tuple)) + self.assertEqual(len(decoder_attentions), self.model_tester.num_hidden_layers) + self.assertListEqual( + list(decoder_attentions[0].shape[-3:]), + [self.model_tester.num_attention_heads, decoder_seq_length, decoder_key_length], + ) + + # cross attentions + cross_attentions = outputs.cross_attentions + self.assertIsInstance(cross_attentions, (list, tuple)) + self.assertEqual(len(cross_attentions), self.model_tester.num_hidden_layers) + self.assertListEqual( + list(cross_attentions[0].shape[-3:]), + [self.model_tester.num_attention_heads, decoder_seq_length, subsampled_encoder_key_length], + ) + + # Check attention is always last and order is fine + inputs_dict["output_attentions"] = True + inputs_dict["output_hidden_states"] = True + model = model_class(config) + model.to(torch_device) + model.eval() + with torch.no_grad(): + outputs = model(**self._prepare_for_class(inputs_dict, model_class)) + + added_hidden_states = 2 + self.assertEqual(out_len + added_hidden_states, len(outputs)) + + self_attentions = outputs.encoder_attentions if config.is_encoder_decoder else outputs.attentions + self.assertEqual(len(self_attentions), self.model_tester.num_hidden_layers) + self.assertListEqual( + list(self_attentions[0].shape[-3:]), + [self.model_tester.num_attention_heads, subsampled_encoder_seq_length, subsampled_encoder_key_length], + ) + + # Overridden because Canary takes `input_features` + `decoder_input_ids`, not `input_ids` (like Whisper). + def test_resize_tokens_embeddings(self): + original_config, inputs_dict = self.model_tester.prepare_config_and_inputs_for_common() + if not self.test_resize_embeddings: + self.skipTest(reason="test_resize_embeddings is False") + + for model_class in self.all_model_classes: + config = copy.deepcopy(original_config) + model = model_class(config) + model.to(torch_device) + if self.model_tester.is_training is False: + model.eval() + + # Retrieve the embeddings and clone theme + model_vocab_size = config.get_text_config().vocab_size + model_embed = model.resize_token_embeddings(model_vocab_size) + cloned_embeddings = model_embed.weight.clone() + + # Check that resizing the token embeddings with a larger vocab size increases the model's vocab size + model_embed = model.resize_token_embeddings(model_vocab_size + 10) + self.assertEqual(model.config.get_text_config().vocab_size, model_vocab_size + 10) + # Check that it actually resizes the embeddings matrix + self.assertEqual(model_embed.weight.shape[0], cloned_embeddings.shape[0] + 10) + # Check that the model can still do a forward pass successfully (every parameter should be resized) + model(**self._prepare_for_class(inputs_dict, model_class)) + + # Check that resizing the token embeddings with a smaller vocab size decreases the model's vocab size + model_embed = model.resize_token_embeddings(model_vocab_size - 15) + self.assertEqual(model.config.get_text_config().vocab_size, model_vocab_size - 15) + # Check that it actually resizes the embeddings matrix + self.assertEqual(model_embed.weight.shape[0], cloned_embeddings.shape[0] - 15) + + # make sure that decoder_input_ids are resized + if "decoder_input_ids" in inputs_dict: + inputs_dict["decoder_input_ids"].clamp_(max=model_vocab_size - 15 - 1) + model(**self._prepare_for_class(inputs_dict, model_class)) + + # Check that adding and removing tokens has not modified the first part of the embedding matrix. + models_equal = True + for p1, p2 in zip(cloned_embeddings, model_embed.weight): + if p1.data.ne(p2.data).sum() > 0: + models_equal = False + self.assertTrue(models_equal) + + # Overridden for the same audio-model reason as `test_resize_tokens_embeddings` (mirrors `WhisperModelTest`). + def test_resize_embeddings_untied(self): + original_config, inputs_dict = self.model_tester.prepare_config_and_inputs_for_common() + if not self.test_resize_embeddings: + self.skipTest(reason="test_resize_embeddings is False") + + original_config.tie_word_embeddings = False + + # if model cannot untied embeddings -> leave test + if original_config.tie_word_embeddings: + self.skipTest(reason="Model cannot untie embeddings") + + for model_class in self.all_model_classes: + config = copy.deepcopy(original_config) + model = model_class(config).to(torch_device) + model.eval() + + # if no output embeddings -> leave test + if model.get_output_embeddings() is None: + continue + + # Check that resizing the token embeddings with a larger vocab size increases the model's vocab size + model_vocab_size = config.get_text_config().vocab_size + model.resize_token_embeddings(model_vocab_size + 10) + self.assertEqual(model.config.get_text_config().vocab_size, model_vocab_size + 10) + output_embeds = model.get_output_embeddings() + self.assertEqual(output_embeds.weight.shape[0], model_vocab_size + 10) + # Check bias if present + if output_embeds.bias is not None: + self.assertEqual(output_embeds.bias.shape[0], model_vocab_size + 10) + # Check that the model can still do a forward pass successfully (every parameter should be resized) + model(**self._prepare_for_class(inputs_dict, model_class)) + + # Check that resizing the token embeddings with a smaller vocab size decreases the model's vocab size + model.resize_token_embeddings(model_vocab_size - 15) + self.assertEqual(model.config.get_text_config().vocab_size, model_vocab_size - 15) + # Check that it actually resizes the embeddings matrix + output_embeds = model.get_output_embeddings() + self.assertEqual(output_embeds.weight.shape[0], model_vocab_size - 15) + # Check bias if present + if output_embeds.bias is not None: + self.assertEqual(output_embeds.bias.shape[0], model_vocab_size - 15) + if "decoder_input_ids" in inputs_dict: + inputs_dict["decoder_input_ids"].clamp_(max=model_vocab_size - 15 - 1) + # Check that the model can still do a forward pass successfully (every parameter should be resized) + model(**self._prepare_for_class(inputs_dict, model_class)) + + @unittest.skip( + reason="Canary is an encoder-decoder ASR model that requires audio features and cannot generate from input ids only." + ) + def test_generate_without_input_ids(self): + pass + + @is_flaky(description="Large difference with A10. Still flaky after setting larger tolerance") + def test_generate_continue_from_past_key_values(self): + super().test_generate_continue_from_past_key_values() + + @unittest.skip(reason="Decoder can't keep attention grads") + def test_retain_grad_hidden_states_attentions(self): + pass + + # Overridden because the head count comes from the decoder sub-config (mirrors `DiaModelTest`). + def _check_attentions_for_generate( + self, batch_size, attentions, prompt_length, output_length, config, decoder_past_key_values + ): + self.assertIsInstance(attentions, tuple) + self.assertListEqual( + [isinstance(iter_attentions, tuple) for iter_attentions in attentions], [True] * len(attentions) + ) + self.assertEqual(len(attentions), (output_length - prompt_length)) + + use_cache = decoder_past_key_values is not None + has_static_cache = isinstance(decoder_past_key_values, StaticCache) + + # When `output_attentions=True`, each iteration of generate appends the attentions corresponding to the new + # token(s) + for generated_length, iter_attentions in enumerate(attentions): + # regardless of using cache, the first forward pass will have the full prompt as input + if use_cache and generated_length > 0: + model_input_length = 1 + else: + model_input_length = prompt_length + generated_length + if has_static_cache: + # hybrid caches have layers with no fixed max, so pick the first layer reporting a real length + query_length = next( + ( + decoder_past_key_values.get_max_length(i) + for i in range(len(decoder_past_key_values)) + if decoder_past_key_values.get_max_length(i) != -1 + ), + prompt_length + generated_length, + ) + else: + query_length = prompt_length + generated_length + + expected_shape = ( + batch_size, + config.decoder_config.num_attention_heads, # Decoder config + model_input_length, + query_length, + ) + # check attn size + self.assertListEqual( + [layer_attention.shape for layer_attention in iter_attentions], [expected_shape] * len(iter_attentions) + ) + + # Overridden for the same sub-config reason as `_check_attentions_for_generate` (mirrors `DiaModelTest`). + def _check_encoder_attention_for_generate(self, attentions, batch_size, config, prompt_length): + # Encoder config + encoder_expected_shape = (batch_size, config.encoder_config.num_attention_heads, prompt_length, prompt_length) + self.assertIsInstance(attentions, tuple) + self.assertListEqual( + [layer_attentions.shape for layer_attentions in attentions], + [encoder_expected_shape] * len(attentions), + ) + + # Overridden for the same sub-config reason as `_check_attentions_for_generate` (mirrors `DiaModelTest`). + def _check_hidden_states_for_generate( + self, batch_size, hidden_states, prompt_length, output_length, config, use_cache=False + ): + self.assertIsInstance(hidden_states, tuple) + self.assertListEqual( + [isinstance(iter_hidden_states, tuple) for iter_hidden_states in hidden_states], + [True] * len(hidden_states), + ) + self.assertEqual(len(hidden_states), (output_length - prompt_length)) + + # When `output_hidden_states=True`, each iteration of generate appends the hidden states corresponding to the + # new token(s) + # NOTE: `StaticCache` may have different lengths on different layers, if this test starts failing add more + # elaborate checks + for generated_length, iter_hidden_states in enumerate(hidden_states): + # regardless of using cache, the first forward pass will have the full prompt as input + if use_cache and generated_length > 0: + model_input_length = 1 + else: + model_input_length = prompt_length + generated_length + expected_shape = (batch_size, model_input_length, config.decoder_config.hidden_size) # Decoder config + # check hidden size + self.assertListEqual( + [layer_hidden_states.shape for layer_hidden_states in iter_hidden_states], + [expected_shape] * len(iter_hidden_states), + ) + + # Overridden for the same sub-config reason as `_check_attentions_for_generate` (mirrors `DiaModelTest`). + def _check_encoder_hidden_states_for_generate(self, hidden_states, batch_size, config, prompt_length): + # Encoder config + encoder_expected_shape = (batch_size, prompt_length, config.encoder_config.hidden_size) + self.assertIsInstance(hidden_states, tuple) + self.assertListEqual( + [layer_hidden_states.shape for layer_hidden_states in hidden_states], + [encoder_expected_shape] * len(hidden_states), + ) + + +@require_torch +@slow +class CanaryIntegrationTest(unittest.TestCase): + checkpoint = "harshaljanjani/canary-1b-v2-hf" + + @classmethod + def setUp(cls): + from transformers import AutoProcessor + + cls.fixtures_path = Path(__file__).parent.parent.parent / "fixtures/canary" + cls.processor = AutoProcessor.from_pretrained(cls.checkpoint) + cls.model = CanaryForConditionalGeneration.from_pretrained(cls.checkpoint).to(torch_device).eval() + + def _load_datasamples(self, processor, num_samples): + from datasets import Audio, load_dataset + + ds = load_dataset("hf-internal-testing/librispeech_asr_dummy", "clean", split="validation") + ds = ds.cast_column("audio", Audio(sampling_rate=processor.feature_extractor.sampling_rate)) + speech_samples = ds.sort("id")[:num_samples]["audio"] + return [x["array"] for x in speech_samples] + + def test_transcription_en(self): + """ + reproducer: https://gist.github.com/harshaljanjani/ff11260652a115da61037ecfc288c74f#file-reproducer_transcription-py + """ + with open(self.fixtures_path / "expected_results_transcription.json") as f: + expected_transcriptions = json.load(f)["transcriptions"] + + inputs = self._load_datasamples(self.processor, 1) + features = self.processor.apply_transcription_request(audio=inputs, source_language="en").to(torch_device) + generated = self.model.generate(**features, max_new_tokens=128) + transcriptions = [text.strip() for text in self.processor.decode(generated, skip_special_tokens=True)] + self.assertListEqual(transcriptions, expected_transcriptions) + + def test_transcription_en_batched(self): + """ + reproducer: https://gist.github.com/harshaljanjani/d93abd784d09a7f25291080ebcdf805d#file-reproducer_batch-py + """ + with open(self.fixtures_path / "expected_results_batch.json") as f: + expected_transcriptions = json.load(f)["transcriptions"] + + inputs = self._load_datasamples(self.processor, 2) + features = self.processor.apply_transcription_request( + audio=inputs, source_language="en", target_language=["en", "de"] + ).to(torch_device) + generated = self.model.generate(**features, max_new_tokens=128) + transcriptions = [text.strip() for text in self.processor.decode(generated, skip_special_tokens=True)] + self.assertListEqual(transcriptions, expected_transcriptions) + + def test_translation_en_to_de(self): + """ + reproducer: https://gist.github.com/harshaljanjani/5b093d7fc25507694b7b6ada08fa7988#file-reproducer_translation-py + """ + with open(self.fixtures_path / "expected_results_translation.json") as f: + expected_transcriptions = json.load(f)["transcriptions"] + + inputs = self._load_datasamples(self.processor, 1) + features = self.processor.apply_transcription_request( + audio=inputs, source_language="en", target_language="de" + ).to(torch_device) + generated = self.model.generate(**features, max_new_tokens=128) + transcriptions = [text.strip() for text in self.processor.decode(generated, skip_special_tokens=True)] + self.assertListEqual(transcriptions, expected_transcriptions) diff --git a/tests/models/canary/test_processing_canary.py b/tests/models/canary/test_processing_canary.py new file mode 100644 index 000000000000..2b6b4fbe9188 --- /dev/null +++ b/tests/models/canary/test_processing_canary.py @@ -0,0 +1,94 @@ +# Copyright 2026 the HuggingFace Team. 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 shutil +import tempfile +import unittest + +import numpy as np + +from transformers import AutoProcessor, CanaryProcessor +from transformers.testing_utils import require_torch + + +def _get_prompt(source: str, target: str, pnc: bool = True) -> str: + return ( + "<|startofcontext|><|startoftranscript|><|emo:undefined|>" + f"<|{source}|><|{target}|>" + f"{'<|pnc|>' if pnc else '<|nopnc|>'}<|noitn|><|notimestamp|><|nodiarize|>" + ) + + +@require_torch +class CanaryProcessorTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.checkpoint = "harshaljanjani/canary-1b-v2-hf" + cls.tmpdirname = tempfile.mkdtemp() + CanaryProcessor.from_pretrained(cls.checkpoint).save_pretrained(cls.tmpdirname) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmpdirname, ignore_errors=True) + + def get_processor(self): + return AutoProcessor.from_pretrained(self.tmpdirname) + + def _audio(self, num_samples: int = 16000): + return np.zeros(num_samples, dtype=np.float32) + + def _decode_prompt(self, processor, inputs, index: int = 0) -> str: + return processor.tokenizer.decode(inputs["decoder_input_ids"][index], skip_special_tokens=False) + + def test_chat_template_is_loaded(self): + self.assertIsNotNone(self.get_processor().chat_template) + + def test_apply_transcription_request_transcription(self): + processor = self.get_processor() + inputs = processor.apply_transcription_request(audio=self._audio(), source_language="en") + self.assertIn("input_features", inputs) + self.assertEqual(self._decode_prompt(processor, inputs), _get_prompt("en", "en")) + + def test_apply_transcription_request_translation(self): + processor = self.get_processor() + inputs = processor.apply_transcription_request(audio=self._audio(), source_language="en", target_language="de") + self.assertEqual(self._decode_prompt(processor, inputs), _get_prompt("en", "de")) + + def test_punctuation_flag(self): + processor = self.get_processor() + inputs = processor.apply_transcription_request(audio=self._audio(), source_language="en", punctuation=False) + self.assertEqual(self._decode_prompt(processor, inputs), _get_prompt("en", "en", pnc=False)) + + def test_batch_broadcast_and_per_sample(self): + processor = self.get_processor() + inputs = processor.apply_transcription_request( + audio=[self._audio(), self._audio()], source_language="en", target_language=["en", "es"] + ) + self.assertEqual(len(inputs["decoder_input_ids"]), 2) + self.assertEqual(self._decode_prompt(processor, inputs, 0), _get_prompt("en", "en")) + self.assertEqual(self._decode_prompt(processor, inputs, 1), _get_prompt("en", "es")) + + def test_batch_length_mismatch_raises(self): + processor = self.get_processor() + with self.assertRaises(ValueError): + processor.apply_transcription_request(audio=[self._audio()], source_language=["en", "de"]) + + def test_call_output_labels(self): + processor = self.get_processor() + outputs = processor(audio=self._audio(), text="hello world", output_labels=True) + self.assertIn("input_features", outputs) + self.assertIn("decoder_input_ids", outputs) + self.assertIn("labels", outputs) + # the decoder inputs are already right-shifted with respect to `labels` + self.assertListEqual(outputs["decoder_input_ids"][..., 1:].tolist(), outputs["labels"][..., :-1].tolist()) diff --git a/tests/test_modeling_common.py b/tests/test_modeling_common.py index f70d69de89c2..cd58a107d90d 100644 --- a/tests/test_modeling_common.py +++ b/tests/test_modeling_common.py @@ -3834,6 +3834,7 @@ def test_sdpa_can_dispatch_on_flash(self): "kosmos-2", "mllama", "lighton_ocr", + "canary", "parakeet_encoder", "parakeet_ctc", "pi0",