first commit
Some checks failed
Self-hosted runner (nightly-past-ci-caller) / Get number (push) Has been cancelled
Self-hosted runner (nightly-past-ci-caller) / TensorFlow 2.11 (push) Has been cancelled
Self-hosted runner (nightly-past-ci-caller) / TensorFlow 2.10 (push) Has been cancelled
Self-hosted runner (nightly-past-ci-caller) / TensorFlow 2.9 (push) Has been cancelled
Self-hosted runner (nightly-past-ci-caller) / TensorFlow 2.8 (push) Has been cancelled
Self-hosted runner (nightly-past-ci-caller) / TensorFlow 2.7 (push) Has been cancelled
Self-hosted runner (nightly-past-ci-caller) / TensorFlow 2.6 (push) Has been cancelled
Self-hosted runner (nightly-past-ci-caller) / TensorFlow 2.5 (push) Has been cancelled
Self-hosted runner (benchmark) / Benchmark (aws-g5-4xlarge-cache) (push) Has been cancelled
Build documentation / build (push) Has been cancelled
Build documentation / build_other_lang (push) Has been cancelled
CodeQL Security Analysis / CodeQL Analysis (push) Has been cancelled
New model PR merged notification / Notify new model (push) Has been cancelled
PR CI / pr-ci (push) Has been cancelled
Slow tests on important models (on Push - A10) / Get all modified files (push) Has been cancelled
Secret Leaks / trufflehog (push) Has been cancelled
Update Transformers metadata / build_and_package (push) Has been cancelled
Slow tests on important models (on Push - A10) / Model CI (push) Has been cancelled
Check Tiny Models / Check tiny models (push) Has been cancelled
Self-hosted runner (Intel Gaudi3 scheduled CI caller) / Model CI (push) Has been cancelled
Self-hosted runner (Intel Gaudi3 scheduled CI caller) / Pipeline CI (push) Has been cancelled
Self-hosted runner (Intel Gaudi3 scheduled CI caller) / Example CI (push) Has been cancelled
Self-hosted runner (Intel Gaudi3 scheduled CI caller) / DeepSpeed CI (push) Has been cancelled
Self-hosted runner (Intel Gaudi3 scheduled CI caller) / Trainer/FSDP CI (push) Has been cancelled
Nvidia CI - Flash Attn / Setup (push) Has been cancelled
Nvidia CI - Flash Attn / Model CI (push) Has been cancelled
Nvidia CI / Setup (push) Has been cancelled
Nvidia CI / Model CI (push) Has been cancelled
Nvidia CI / Torch pipeline CI (push) Has been cancelled
Nvidia CI / Example CI (push) Has been cancelled
Nvidia CI / Trainer/FSDP CI (push) Has been cancelled
Nvidia CI / DeepSpeed CI (push) Has been cancelled
Nvidia CI / Quantization CI (push) Has been cancelled
Nvidia CI / Kernels CI (push) Has been cancelled
Doctests / Setup (push) Has been cancelled
Doctests / Call doctest jobs (push) Has been cancelled
Doctests / Send results to webhook (push) Has been cancelled
Extras Smoke Test / Get supported Python versions (push) Has been cancelled
Extras Smoke Test / Test extras on Python ${{ matrix.python-version }} (push) Has been cancelled
Extras Smoke Test / Check Slack token availability (push) Has been cancelled
Extras Smoke Test / Notify failures to Slack (push) Has been cancelled
Self-hosted runner (AMD scheduled CI caller) / Trigger Scheduled AMD CI (push) Has been cancelled
Stale Bot / Close Stale Issues (push) Has been cancelled
Some checks failed
Self-hosted runner (nightly-past-ci-caller) / Get number (push) Has been cancelled
Self-hosted runner (nightly-past-ci-caller) / TensorFlow 2.11 (push) Has been cancelled
Self-hosted runner (nightly-past-ci-caller) / TensorFlow 2.10 (push) Has been cancelled
Self-hosted runner (nightly-past-ci-caller) / TensorFlow 2.9 (push) Has been cancelled
Self-hosted runner (nightly-past-ci-caller) / TensorFlow 2.8 (push) Has been cancelled
Self-hosted runner (nightly-past-ci-caller) / TensorFlow 2.7 (push) Has been cancelled
Self-hosted runner (nightly-past-ci-caller) / TensorFlow 2.6 (push) Has been cancelled
Self-hosted runner (nightly-past-ci-caller) / TensorFlow 2.5 (push) Has been cancelled
Self-hosted runner (benchmark) / Benchmark (aws-g5-4xlarge-cache) (push) Has been cancelled
Build documentation / build (push) Has been cancelled
Build documentation / build_other_lang (push) Has been cancelled
CodeQL Security Analysis / CodeQL Analysis (push) Has been cancelled
New model PR merged notification / Notify new model (push) Has been cancelled
PR CI / pr-ci (push) Has been cancelled
Slow tests on important models (on Push - A10) / Get all modified files (push) Has been cancelled
Secret Leaks / trufflehog (push) Has been cancelled
Update Transformers metadata / build_and_package (push) Has been cancelled
Slow tests on important models (on Push - A10) / Model CI (push) Has been cancelled
Check Tiny Models / Check tiny models (push) Has been cancelled
Self-hosted runner (Intel Gaudi3 scheduled CI caller) / Model CI (push) Has been cancelled
Self-hosted runner (Intel Gaudi3 scheduled CI caller) / Pipeline CI (push) Has been cancelled
Self-hosted runner (Intel Gaudi3 scheduled CI caller) / Example CI (push) Has been cancelled
Self-hosted runner (Intel Gaudi3 scheduled CI caller) / DeepSpeed CI (push) Has been cancelled
Self-hosted runner (Intel Gaudi3 scheduled CI caller) / Trainer/FSDP CI (push) Has been cancelled
Nvidia CI - Flash Attn / Setup (push) Has been cancelled
Nvidia CI - Flash Attn / Model CI (push) Has been cancelled
Nvidia CI / Setup (push) Has been cancelled
Nvidia CI / Model CI (push) Has been cancelled
Nvidia CI / Torch pipeline CI (push) Has been cancelled
Nvidia CI / Example CI (push) Has been cancelled
Nvidia CI / Trainer/FSDP CI (push) Has been cancelled
Nvidia CI / DeepSpeed CI (push) Has been cancelled
Nvidia CI / Quantization CI (push) Has been cancelled
Nvidia CI / Kernels CI (push) Has been cancelled
Doctests / Setup (push) Has been cancelled
Doctests / Call doctest jobs (push) Has been cancelled
Doctests / Send results to webhook (push) Has been cancelled
Extras Smoke Test / Get supported Python versions (push) Has been cancelled
Extras Smoke Test / Test extras on Python ${{ matrix.python-version }} (push) Has been cancelled
Extras Smoke Test / Check Slack token availability (push) Has been cancelled
Extras Smoke Test / Notify failures to Slack (push) Has been cancelled
Self-hosted runner (AMD scheduled CI caller) / Trigger Scheduled AMD CI (push) Has been cancelled
Stale Bot / Close Stale Issues (push) Has been cancelled
This commit is contained in:
0
tests/models/gemma4_unified/__init__.py
Normal file
0
tests/models/gemma4_unified/__init__.py
Normal file
@@ -0,0 +1,176 @@
|
||||
# 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 itertools
|
||||
import random
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from transformers import Gemma4UnifiedAudioFeatureExtractor
|
||||
from transformers.testing_utils import require_torch
|
||||
from transformers.utils.import_utils import is_torch_available
|
||||
|
||||
from ...test_sequence_feature_extraction_common import SequenceFeatureExtractionTestMixin
|
||||
|
||||
|
||||
if is_torch_available():
|
||||
import torch
|
||||
|
||||
global_rng = random.Random()
|
||||
|
||||
|
||||
# Copied from tests.models.whisper.test_feature_extraction_whisper.floats_list
|
||||
def floats_list(shape, scale=1.0, rng=None, name=None):
|
||||
"""Creates a random float32 tensor"""
|
||||
if rng is None:
|
||||
rng = global_rng
|
||||
|
||||
values = []
|
||||
for batch_idx in range(shape[0]):
|
||||
values.append([])
|
||||
for _ in range(shape[1]):
|
||||
values[-1].append(rng.random() * scale)
|
||||
|
||||
return values
|
||||
|
||||
|
||||
class Gemma4UnifiedAudioFeatureExtractionTester:
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
batch_size=7,
|
||||
min_seq_length=400,
|
||||
max_seq_length=2000,
|
||||
feature_size=80,
|
||||
sampling_rate=16_000,
|
||||
padding_value=0.0,
|
||||
audio_samples_per_token=80,
|
||||
):
|
||||
self.parent = parent
|
||||
self.batch_size = batch_size
|
||||
self.min_seq_length = min_seq_length
|
||||
self.max_seq_length = max_seq_length
|
||||
self.seq_length_diff = (self.max_seq_length - self.min_seq_length) // (self.batch_size - 1)
|
||||
self.feature_size = feature_size
|
||||
self.sampling_rate = sampling_rate
|
||||
self.padding_value = padding_value
|
||||
self.audio_samples_per_token = audio_samples_per_token
|
||||
|
||||
def prepare_feat_extract_dict(self):
|
||||
return {
|
||||
"feature_size": self.feature_size,
|
||||
"sampling_rate": self.sampling_rate,
|
||||
"padding_value": self.padding_value,
|
||||
"audio_samples_per_token": self.audio_samples_per_token,
|
||||
}
|
||||
|
||||
# Copied from tests.models.whisper.test_feature_extraction_whisper.WhisperFeatureExtractionTester.prepare_inputs_for_common
|
||||
def prepare_inputs_for_common(self, equal_length=False, numpify=False):
|
||||
def _flatten(list_of_lists):
|
||||
return list(itertools.chain(*list_of_lists))
|
||||
|
||||
if equal_length:
|
||||
speech_inputs = [floats_list((self.max_seq_length, self.feature_size)) for _ in range(self.batch_size)]
|
||||
else:
|
||||
# make sure that inputs increase in size
|
||||
speech_inputs = [
|
||||
floats_list((x, self.feature_size))
|
||||
for x in range(self.min_seq_length, self.max_seq_length, self.seq_length_diff)
|
||||
]
|
||||
if numpify:
|
||||
speech_inputs = [np.asarray(x) for x in speech_inputs]
|
||||
return speech_inputs
|
||||
|
||||
|
||||
@require_torch
|
||||
class Gemma4UnifiedAudioFeatureExtractionTest(SequenceFeatureExtractionTestMixin, unittest.TestCase):
|
||||
feature_extraction_class = Gemma4UnifiedAudioFeatureExtractor
|
||||
|
||||
def setUp(self):
|
||||
self.feat_extract_tester = Gemma4UnifiedAudioFeatureExtractionTester(self)
|
||||
|
||||
def test_chunking_shape(self):
|
||||
"""A 1-D waveform is chunked into ceil(len / audio_samples_per_token) frames."""
|
||||
feature_extractor = self.feature_extraction_class(**self.feat_extract_tester.prepare_feat_extract_dict())
|
||||
samples_per_token = self.feat_extract_tester.audio_samples_per_token
|
||||
|
||||
waveform = np.random.rand(1000).astype(np.float32)
|
||||
result = feature_extractor(waveform, return_tensors="np")
|
||||
|
||||
expected_num_tokens = -(-1000 // samples_per_token)
|
||||
self.assertEqual(result.input_features.shape, (1, expected_num_tokens, samples_per_token))
|
||||
self.assertEqual(result.input_features_mask.shape, (1, expected_num_tokens))
|
||||
self.assertTrue(result.input_features_mask.all())
|
||||
|
||||
def test_chunking_preserves_values(self):
|
||||
"""Chunking is a pure reshape: values are preserved and the last frame is zero-padded."""
|
||||
feature_extractor = self.feature_extraction_class(**self.feat_extract_tester.prepare_feat_extract_dict())
|
||||
samples_per_token = self.feat_extract_tester.audio_samples_per_token
|
||||
|
||||
length = samples_per_token * 3 + 17
|
||||
waveform = np.random.rand(length).astype(np.float32)
|
||||
result = feature_extractor(waveform, return_tensors="np")
|
||||
|
||||
flattened = result.input_features[0].flatten()
|
||||
self.assertTrue(np.array_equal(flattened[:length], waveform))
|
||||
self.assertTrue((flattened[length:] == 0).all())
|
||||
|
||||
def test_batch_padding_longest(self):
|
||||
"""Batched waveforms of different lengths are padded to the longest, with a matching mask."""
|
||||
feature_extractor = self.feature_extraction_class(**self.feat_extract_tester.prepare_feat_extract_dict())
|
||||
samples_per_token = self.feat_extract_tester.audio_samples_per_token
|
||||
|
||||
short = np.random.rand(samples_per_token * 5).astype(np.float32)
|
||||
long = np.random.rand(samples_per_token * 10).astype(np.float32)
|
||||
result = feature_extractor([short, long], padding="longest", return_tensors="np")
|
||||
|
||||
self.assertEqual(result.input_features.shape, (2, 10, samples_per_token))
|
||||
self.assertEqual(result.input_features_mask.shape, (2, 10))
|
||||
self.assertTrue(result.input_features_mask[0, :5].all())
|
||||
self.assertFalse(result.input_features_mask[0, 5:].any())
|
||||
self.assertTrue(result.input_features_mask[1].all())
|
||||
self.assertTrue((result.input_features[0, 5:] == 0).all())
|
||||
|
||||
def test_max_length_truncation(self):
|
||||
feature_extractor = self.feature_extraction_class(**self.feat_extract_tester.prepare_feat_extract_dict())
|
||||
samples_per_token = self.feat_extract_tester.audio_samples_per_token
|
||||
|
||||
waveform = np.random.rand(samples_per_token * 10).astype(np.float32)
|
||||
result = feature_extractor(waveform, padding="max_length", max_length=4, truncation=True, return_tensors="np")
|
||||
|
||||
self.assertEqual(result.input_features.shape, (1, 4, samples_per_token))
|
||||
|
||||
def test_return_tensors_pt(self):
|
||||
feature_extractor = self.feature_extraction_class(**self.feat_extract_tester.prepare_feat_extract_dict())
|
||||
samples_per_token = self.feat_extract_tester.audio_samples_per_token
|
||||
|
||||
waveform = np.random.rand(samples_per_token * 4).astype(np.float32)
|
||||
result = feature_extractor(waveform, return_tensors="pt")
|
||||
|
||||
self.assertIsInstance(result.input_features, torch.Tensor)
|
||||
self.assertEqual(result.input_features.dtype, torch.float32)
|
||||
self.assertIsInstance(result.input_features_mask, torch.Tensor)
|
||||
self.assertEqual(result.input_features_mask.dtype, torch.bool)
|
||||
|
||||
def test_numpy_and_pt_outputs_match(self):
|
||||
feature_extractor = self.feature_extraction_class(**self.feat_extract_tester.prepare_feat_extract_dict())
|
||||
samples_per_token = self.feat_extract_tester.audio_samples_per_token
|
||||
|
||||
waveform = np.random.rand(samples_per_token * 6 + 13).astype(np.float32)
|
||||
result_np = feature_extractor(waveform, return_tensors="np")
|
||||
result_pt = feature_extractor(waveform, return_tensors="pt")
|
||||
|
||||
self.assertTrue(np.array_equal(result_np.input_features, result_pt.input_features.numpy()))
|
||||
self.assertTrue(np.array_equal(result_np.input_features_mask, result_pt.input_features_mask.numpy()))
|
||||
@@ -0,0 +1,247 @@
|
||||
# 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 unittest
|
||||
|
||||
import numpy as np
|
||||
from parameterized import parameterized
|
||||
|
||||
from transformers.models.gemma4.image_processing_pil_gemma4 import get_aspect_ratio_preserving_size
|
||||
from transformers.testing_utils import require_torch, require_vision
|
||||
from transformers.utils import is_torch_available, is_torchvision_available, is_vision_available
|
||||
|
||||
from ...test_image_processing_common import ImageProcessingTestMixin, prepare_image_inputs
|
||||
|
||||
|
||||
if is_torch_available():
|
||||
import torch
|
||||
|
||||
if is_vision_available():
|
||||
from PIL import Image
|
||||
|
||||
if is_torchvision_available():
|
||||
pass
|
||||
|
||||
|
||||
class Gemma4UnifiedImageProcessingTester:
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
batch_size=7,
|
||||
num_channels=3,
|
||||
min_resolution=30,
|
||||
max_resolution=400,
|
||||
do_resize=True,
|
||||
do_normalize=False,
|
||||
image_mean=None,
|
||||
image_std=None,
|
||||
do_convert_rgb=True,
|
||||
patch_size=6,
|
||||
max_soft_tokens=70,
|
||||
pooling_kernel_size=1,
|
||||
):
|
||||
super().__init__()
|
||||
image_mean = image_mean if image_mean is not None else [0.0, 0.0, 0.0]
|
||||
image_std = image_std if image_std is not None else [1.0, 1.0, 1.0]
|
||||
self.parent = parent
|
||||
self.batch_size = batch_size
|
||||
self.num_channels = num_channels
|
||||
self.min_resolution = min_resolution
|
||||
self.max_resolution = max_resolution
|
||||
self.do_resize = do_resize
|
||||
self.do_normalize = do_normalize
|
||||
self.image_mean = image_mean
|
||||
self.image_std = image_std
|
||||
self.do_convert_rgb = do_convert_rgb
|
||||
self.patch_size = patch_size
|
||||
self.max_soft_tokens = max_soft_tokens
|
||||
self.pooling_kernel_size = pooling_kernel_size
|
||||
|
||||
def prepare_image_processor_dict(self):
|
||||
return {
|
||||
"do_resize": self.do_resize,
|
||||
"do_normalize": self.do_normalize,
|
||||
"image_mean": self.image_mean,
|
||||
"image_std": self.image_std,
|
||||
"do_convert_rgb": self.do_convert_rgb,
|
||||
"patch_size": self.patch_size,
|
||||
"max_soft_tokens": self.max_soft_tokens,
|
||||
"pooling_kernel_size": self.pooling_kernel_size,
|
||||
}
|
||||
|
||||
# Copied from tests.models.clip.test_image_processing_clip.CLIPImageProcessingTester.prepare_image_inputs
|
||||
def prepare_image_inputs(self, equal_resolution=False, numpify=False, torchify=False):
|
||||
return prepare_image_inputs(
|
||||
batch_size=self.batch_size,
|
||||
num_channels=self.num_channels,
|
||||
min_resolution=self.min_resolution,
|
||||
max_resolution=self.max_resolution,
|
||||
equal_resolution=equal_resolution,
|
||||
numpify=numpify,
|
||||
torchify=torchify,
|
||||
)
|
||||
|
||||
def expected_output_image_shape(self, images=None):
|
||||
"""Return the expected per-image output shape: (max_soft_tokens, model_patch_size² * 3)."""
|
||||
model_patch_size = self.patch_size * self.pooling_kernel_size
|
||||
patch_pixels = model_patch_size**2 * 3
|
||||
return self.max_soft_tokens, patch_pixels
|
||||
|
||||
|
||||
@require_torch
|
||||
@require_vision
|
||||
class Gemma4UnifiedImageProcessingTest(ImageProcessingTestMixin, unittest.TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.image_processor_tester = Gemma4UnifiedImageProcessingTester(self)
|
||||
|
||||
@unittest.skip("Gemma4Unified patchification requires RGB (3-channel) images; 4-channel inputs are unsupported.")
|
||||
def test_call_numpy_4_channels(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
def image_processor_dict(self):
|
||||
return self.image_processor_tester.prepare_image_processor_dict()
|
||||
|
||||
def test_image_processor_properties(self):
|
||||
"""Test that all expected attributes are present."""
|
||||
for image_processing_class in self.image_processing_classes.values():
|
||||
image_processing = image_processing_class(**self.image_processor_dict)
|
||||
self.assertTrue(hasattr(image_processing, "do_resize"))
|
||||
self.assertTrue(hasattr(image_processing, "do_normalize"))
|
||||
self.assertTrue(hasattr(image_processing, "image_mean"))
|
||||
self.assertTrue(hasattr(image_processing, "image_std"))
|
||||
self.assertTrue(hasattr(image_processing, "do_convert_rgb"))
|
||||
self.assertTrue(hasattr(image_processing, "patch_size"))
|
||||
self.assertTrue(hasattr(image_processing, "max_soft_tokens"))
|
||||
self.assertTrue(hasattr(image_processing, "pooling_kernel_size"))
|
||||
|
||||
def test_image_processor_defaults(self):
|
||||
"""Test default parameter values for Gemma4Unified matching VARASP_SL280_K3."""
|
||||
for image_processing_class in self.image_processing_classes.values():
|
||||
proc = image_processing_class()
|
||||
self.assertEqual(proc.patch_size, 16)
|
||||
self.assertEqual(proc.max_soft_tokens, 280)
|
||||
self.assertEqual(proc.pooling_kernel_size, 3)
|
||||
self.assertFalse(proc.do_normalize)
|
||||
self.assertEqual(list(proc.image_mean), [0.0, 0.0, 0.0])
|
||||
self.assertEqual(list(proc.image_std), [1.0, 1.0, 1.0])
|
||||
self.assertEqual(proc.resample, 3)
|
||||
|
||||
def test_image_processor_from_dict_with_kwargs(self):
|
||||
for image_processing_class in self.image_processing_classes.values():
|
||||
image_processor = image_processing_class.from_dict(self.image_processor_dict)
|
||||
self.assertEqual(image_processor.patch_size, 6)
|
||||
self.assertEqual(image_processor.max_soft_tokens, 70)
|
||||
|
||||
image_processor = image_processing_class.from_dict(self.image_processor_dict, patch_size=18)
|
||||
self.assertEqual(image_processor.patch_size, 18)
|
||||
|
||||
def test_output_keys(self):
|
||||
"""Test that the output contains pixel_values, image_position_ids, and num_soft_tokens_per_image."""
|
||||
for image_processing_class in self.image_processing_classes.values():
|
||||
image_processing = image_processing_class(**self.image_processor_dict)
|
||||
image = Image.fromarray(np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8))
|
||||
result = image_processing(image, return_tensors="pt")
|
||||
self.assertIn("pixel_values", result)
|
||||
self.assertIn("image_position_ids", result)
|
||||
self.assertIn("num_soft_tokens_per_image", result)
|
||||
|
||||
def test_aspect_ratio_preserving_resize_dimensions(self):
|
||||
"""Test resize dimension calculations match C++ source of truth VisionAspectRatioTests."""
|
||||
for patch_size, max_patches, pooling_kernel_size, height, width, expectation in [
|
||||
(16, 256, 1, 256, 256, (256, 256)),
|
||||
(16, 256, 1, 512, 512, (256, 256)),
|
||||
(10, 200, 1, 50, 10000, (10, 2000)),
|
||||
(10, 200, 1, 25, 10000, (10, 2000)),
|
||||
(16, 2304, 6, 2785, 34, (6144, 96)),
|
||||
(10, 200, 1, 25, 20000, (10, 2000)),
|
||||
(4, 64, 2, 50, 1000, (8, 128)),
|
||||
(5, 100, 3, 100, 100, (45, 45)),
|
||||
(5, 20, 3, 5, 100, (15, 30)),
|
||||
]:
|
||||
target_h, target_w = get_aspect_ratio_preserving_size(
|
||||
height=height,
|
||||
width=width,
|
||||
patch_size=patch_size,
|
||||
max_patches=max_patches,
|
||||
pooling_kernel_size=pooling_kernel_size,
|
||||
)
|
||||
side_mult = patch_size * pooling_kernel_size
|
||||
|
||||
self.assertEqual((target_h, target_w), expectation)
|
||||
self.assertEqual(target_h % side_mult, 0, f"Resized height {target_h} not divisible by {side_mult}")
|
||||
self.assertEqual(target_w % side_mult, 0, f"Resized width {target_w} not divisible by {side_mult}")
|
||||
|
||||
@parameterized.expand([(70), (140), (280), (560), (1120)])
|
||||
def test_max_soft_tokens_values(self, max_soft_tokens):
|
||||
"""Test that the processor produces valid patchified output for each supported max_soft_tokens value."""
|
||||
for image_processing_class in self.image_processing_classes.values():
|
||||
processor = image_processing_class(patch_size=16, max_soft_tokens=max_soft_tokens, pooling_kernel_size=3)
|
||||
image = Image.fromarray(np.random.randint(0, 255, (200, 300, 3), dtype=np.uint8))
|
||||
result = processor(image, return_tensors="pt")
|
||||
|
||||
model_patch_size = 16 * 3
|
||||
patch_pixels = model_patch_size**2 * 3
|
||||
self.assertEqual(result.pixel_values.shape, (1, max_soft_tokens, patch_pixels))
|
||||
self.assertEqual(result.image_position_ids.shape, (1, max_soft_tokens, 2))
|
||||
|
||||
# Verify real patches don't exceed the budget
|
||||
max_patches = max_soft_tokens * 3**2
|
||||
real_mask = result.image_position_ids[0, :, 0] >= 0
|
||||
num_real = real_mask.sum().item()
|
||||
self.assertLessEqual(num_real, max_patches)
|
||||
|
||||
def test_position_ids_structure(self):
|
||||
"""Test that image_position_ids has correct real and padding structure."""
|
||||
for image_processing_class in self.image_processing_classes.values():
|
||||
image_processing = image_processing_class(**self.image_processor_dict)
|
||||
image = Image.fromarray(np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8))
|
||||
result = image_processing(image, return_tensors="pt")
|
||||
|
||||
position_ids = result.image_position_ids[0] # (max_patches, 2)
|
||||
max_patches = (
|
||||
self.image_processor_tester.max_soft_tokens * self.image_processor_tester.pooling_kernel_size**2
|
||||
)
|
||||
|
||||
# Real positions should be non-negative
|
||||
real_mask = position_ids[:, 0] >= 0
|
||||
num_real = real_mask.sum().item()
|
||||
self.assertGreater(num_real, 0)
|
||||
self.assertLessEqual(num_real, max_patches)
|
||||
|
||||
# Padding positions should be (-1, -1)
|
||||
pad_mask = ~real_mask
|
||||
if pad_mask.any():
|
||||
pad_positions = position_ids[pad_mask]
|
||||
self.assertTrue((pad_positions == -1).all())
|
||||
|
||||
# Real positions should come before padding positions
|
||||
if pad_mask.any():
|
||||
last_real_idx = torch.where(real_mask)[0][-1].item()
|
||||
first_pad_idx = torch.where(pad_mask)[0][0].item()
|
||||
self.assertEqual(last_real_idx + 1, first_pad_idx)
|
||||
|
||||
def test_padding_patches_are_zero(self):
|
||||
"""Test that padding patches in pixel_values are filled with zeros."""
|
||||
for image_processing_class in self.image_processing_classes.values():
|
||||
image_processing = image_processing_class(**self.image_processor_dict)
|
||||
image = Image.fromarray(np.random.randint(1, 255, (100, 100, 3), dtype=np.uint8))
|
||||
result = image_processing(image, return_tensors="pt")
|
||||
|
||||
position_ids = result.image_position_ids[0]
|
||||
pad_mask = position_ids[:, 0] < 0
|
||||
if pad_mask.any():
|
||||
pad_patches = result.pixel_values[0, pad_mask]
|
||||
self.assertTrue((pad_patches == 0).all())
|
||||
966
tests/models/gemma4_unified/test_modeling_gemma4_unified.py
Normal file
966
tests/models/gemma4_unified/test_modeling_gemma4_unified.py
Normal file
@@ -0,0 +1,966 @@
|
||||
# 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.
|
||||
"""Testing suite for the PyTorch Gemma4Unified model."""
|
||||
|
||||
import copy
|
||||
import string
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from parameterized import parameterized
|
||||
|
||||
from transformers import (
|
||||
AutoTokenizer,
|
||||
Gemma4UnifiedConfig,
|
||||
Gemma4UnifiedTextConfig,
|
||||
is_torch_available,
|
||||
)
|
||||
from transformers.testing_utils import (
|
||||
Expectations,
|
||||
cleanup,
|
||||
require_torch,
|
||||
require_torch_accelerator,
|
||||
require_torch_multi_gpu,
|
||||
slow,
|
||||
torch_device,
|
||||
)
|
||||
|
||||
from ...causal_lm_tester import CausalLMModelTest, CausalLMModelTester
|
||||
from ...generation.test_utils import GenerationTesterMixin
|
||||
from ...test_configuration_common import ConfigTester
|
||||
from ...test_modeling_common import ModelTesterMixin, floats_tensor, ids_tensor
|
||||
from ...test_processing_common import url_to_local_path
|
||||
|
||||
|
||||
if is_torch_available():
|
||||
import torch
|
||||
|
||||
from transformers import (
|
||||
AutoModelForCausalLM,
|
||||
Gemma4UnifiedForCausalLM,
|
||||
Gemma4UnifiedForConditionalGeneration,
|
||||
Gemma4UnifiedModel,
|
||||
Gemma4UnifiedProcessor,
|
||||
Gemma4UnifiedTextModel,
|
||||
PreTrainedModel,
|
||||
set_seed,
|
||||
)
|
||||
from transformers.models.gemma4_unified.modeling_gemma4_unified import Gemma4UnifiedRMSNorm
|
||||
|
||||
|
||||
def _normalize_text(text):
|
||||
text = text.lower()
|
||||
translator = str.maketrans("", "", string.punctuation)
|
||||
text = text.translate(translator)
|
||||
return text
|
||||
|
||||
|
||||
class Gemma4UnifiedTextModelTester(CausalLMModelTester):
|
||||
if is_torch_available():
|
||||
config_class = Gemma4UnifiedTextConfig
|
||||
base_model_class = Gemma4UnifiedTextModel
|
||||
causal_lm_class = Gemma4UnifiedForCausalLM
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.num_hidden_layers = 4 # override to correctly test sharing cache pattern
|
||||
self.num_kv_shared_layers = 2 # important to override
|
||||
self.layer_types = [
|
||||
"sliding_attention",
|
||||
"full_attention",
|
||||
"sliding_attention",
|
||||
"full_attention",
|
||||
] # similarly we want to test sharing on both types
|
||||
self.global_head_dim = self.head_dim # gemma4 use a different head_dim for full and sliding layers
|
||||
|
||||
# Test if bidirectional image mask path works
|
||||
self.use_bidirectional_attention = "vision"
|
||||
|
||||
|
||||
@require_torch
|
||||
class Gemma4UnifiedTextModelTest(CausalLMModelTest, unittest.TestCase):
|
||||
model_tester_class = Gemma4UnifiedTextModelTester
|
||||
# used in `test_torch_compile_for_training`
|
||||
_torch_compile_train_cls = Gemma4UnifiedForCausalLM if is_torch_available() else None
|
||||
|
||||
@unittest.skip("We need 4 layers to correctly test cache sharing.")
|
||||
def test_num_layers_is_small(self):
|
||||
pass
|
||||
|
||||
@unittest.skip("Gemma4Unified uses different rope per layer type, which is not compatible with this test")
|
||||
def test_model_rope_scaling_frequencies(self):
|
||||
pass
|
||||
|
||||
@parameterized.expand([("linear",), ("dynamic",), ("yarn",)])
|
||||
@unittest.skip("Gemma4Unified uses different rope per layer type, which is not compatible with this test")
|
||||
def test_model_rope_scaling_from_config(self):
|
||||
pass
|
||||
|
||||
@unittest.skip(
|
||||
"Flaky on CI, but not locally on Mac. If model is set to fp32 instead of bf16, not flaky anymore."
|
||||
"TODO Cyril/Anton: investigate where the loss of precision between bf16 and fp32 comes from."
|
||||
)
|
||||
def test_sdpa_padding_matches_padding_free_with_position_ids(self):
|
||||
pass
|
||||
|
||||
@unittest.skip(
|
||||
"Same as gemma4: Fails after fully removing the unused weights, even if `forward` is exactly the same. Investigate why."
|
||||
)
|
||||
def test_tp_generation_quantized(self):
|
||||
pass
|
||||
|
||||
def test_flash_attn_2_equivalence(self):
|
||||
"""Override: Exchange RMS norm with identity as it creates too big shifts otherwise"""
|
||||
|
||||
def identity_forward(self, hidden_states):
|
||||
return hidden_states
|
||||
|
||||
with patch.object(Gemma4UnifiedRMSNorm, "forward", identity_forward):
|
||||
super().test_flash_attn_2_equivalence()
|
||||
|
||||
def flash_attn_inference_equivalence(
|
||||
self, attn_implementation: str, padding_side: str, atol: float = 4e-2, rtol: float = 4e-2
|
||||
) -> None:
|
||||
"""Override: Exchange RMS norm with identity as it creates too big shifts otherwise"""
|
||||
|
||||
def identity_forward(self, hidden_states):
|
||||
return hidden_states
|
||||
|
||||
with patch.object(Gemma4UnifiedRMSNorm, "forward", identity_forward):
|
||||
super().flash_attn_inference_equivalence(attn_implementation, padding_side, atol, rtol)
|
||||
|
||||
|
||||
class Gemma4UnifiedAudio2TextModelTester:
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
image_token_id=4,
|
||||
boi_token_id=5,
|
||||
eoi_token_id=6,
|
||||
audio_token_id=7,
|
||||
boa_token_id=8,
|
||||
eoa_token_index=9,
|
||||
video_token_id=10,
|
||||
seq_length=50,
|
||||
audio_seq_length=50,
|
||||
audio_num_channels=32,
|
||||
is_training=True,
|
||||
audio_config={"audio_embed_dim": 32},
|
||||
):
|
||||
self.parent = parent
|
||||
self.image_token_id = image_token_id
|
||||
self.boi_token_id = boi_token_id
|
||||
self.eoi_token_id = eoi_token_id
|
||||
self.audio_token_id = audio_token_id
|
||||
self.boa_token_id = boa_token_id
|
||||
self.eoa_token_index = eoa_token_index
|
||||
self.video_token_id = video_token_id
|
||||
self.llm_tester = Gemma4UnifiedTextModelTester(self.parent)
|
||||
self.llm_tester.use_bidirectional_attention = None
|
||||
self.text_config = self.llm_tester.get_config()
|
||||
self.audio_config = audio_config
|
||||
self.seq_length = seq_length
|
||||
self.audio_seq_length = audio_seq_length
|
||||
self.audio_num_channels = audio_num_channels
|
||||
self.pad_token_id = self.text_config.pad_token_id
|
||||
|
||||
self.num_hidden_layers = self.text_config.num_hidden_layers
|
||||
self.vocab_size = self.text_config.vocab_size
|
||||
self.hidden_size = self.text_config.hidden_size
|
||||
self.num_attention_heads = self.text_config.num_attention_heads
|
||||
self.is_training = is_training
|
||||
|
||||
self.batch_size = 3
|
||||
self.encoder_seq_length = seq_length
|
||||
|
||||
def get_config(self):
|
||||
return Gemma4UnifiedConfig(
|
||||
text_config=self.text_config,
|
||||
vision_config=None,
|
||||
audio_config=self.audio_config,
|
||||
image_token_id=self.image_token_id,
|
||||
boi_token_id=self.boi_token_id,
|
||||
eoi_token_id=self.eoi_token_id,
|
||||
audio_token_id=self.audio_token_id,
|
||||
boa_token_id=self.boa_token_id,
|
||||
eoa_token_index=self.eoa_token_index,
|
||||
video_token_id=self.video_token_id,
|
||||
)
|
||||
|
||||
def prepare_config_and_inputs(self):
|
||||
input_features = floats_tensor([self.batch_size, self.audio_seq_length, self.audio_num_channels])
|
||||
input_features_mask = torch.ones(self.batch_size, self.audio_seq_length, dtype=torch.bool)
|
||||
config = self.get_config()
|
||||
return config, input_features, input_features_mask
|
||||
|
||||
def prepare_config_and_inputs_for_common(self):
|
||||
config, input_features, input_features_mask = self.prepare_config_and_inputs()
|
||||
input_ids = ids_tensor([self.batch_size, self.seq_length], config.text_config.vocab_size - 1) + 1
|
||||
attention_mask = input_ids.ne(self.pad_token_id).to(torch_device)
|
||||
|
||||
# Ensure no tokens accidentally match special token IDs
|
||||
for token_id in [config.image_token_id, config.video_token_id, config.audio_token_id]:
|
||||
input_ids[input_ids == token_id] = self.pad_token_id
|
||||
|
||||
# For the unified model, there is no subsampling.
|
||||
# We need as many placeholder tokens as audio features.
|
||||
num_audio_tokens = self.audio_seq_length
|
||||
input_ids[:, :num_audio_tokens] = config.audio_token_id
|
||||
|
||||
inputs_dict = {
|
||||
"input_features": input_features,
|
||||
"input_features_mask": input_features_mask,
|
||||
"input_ids": input_ids,
|
||||
"attention_mask": attention_mask,
|
||||
"mm_token_type_ids": torch.zeros_like(input_ids),
|
||||
}
|
||||
return config, inputs_dict
|
||||
|
||||
|
||||
@require_torch
|
||||
class Gemma4UnifiedAudio2TextModelTest(ModelTesterMixin, GenerationTesterMixin, unittest.TestCase):
|
||||
all_model_classes = (Gemma4UnifiedModel, Gemma4UnifiedForConditionalGeneration) if is_torch_available() else ()
|
||||
all_generative_model_classes = (Gemma4UnifiedForConditionalGeneration,) if is_torch_available() else ()
|
||||
test_resize_embeddings = False
|
||||
|
||||
def setUp(self):
|
||||
self.model_tester = Gemma4UnifiedAudio2TextModelTester(self)
|
||||
self.config_tester = ConfigTester(self, config_class=Gemma4UnifiedConfig, hidden_size=37)
|
||||
self.skip_mm_output_format()
|
||||
|
||||
def skip_mm_output_format(self):
|
||||
skippable_tests = [
|
||||
"test_get_image_features_hidden_states",
|
||||
"test_get_image_features_attentions",
|
||||
"test_get_video_features_hidden_states",
|
||||
"test_get_video_features_attentions",
|
||||
"test_get_audio_features_hidden_states",
|
||||
"test_get_audio_features_attentions",
|
||||
"test_get_image_features_output",
|
||||
"test_get_video_features_output",
|
||||
"test_get_audio_features_output",
|
||||
]
|
||||
|
||||
for test in skippable_tests:
|
||||
if self._testMethodName.startswith(test):
|
||||
self.skipTest(reason="Gemma4 unified does not collect any hidden states or attentions (no mm tower)")
|
||||
|
||||
@unittest.skip("We need 4 layers to correctly test cache sharing.")
|
||||
def test_num_layers_is_small(self):
|
||||
pass
|
||||
|
||||
@unittest.skip("Conversions only happen when a vision embedder is included!")
|
||||
def test_reverse_loading_mapping(self, check_keys_were_modified=False, skip_base_model=False):
|
||||
pass
|
||||
|
||||
def flash_attn_inference_equivalence(
|
||||
self, attn_implementation: str, padding_side: str, atol: float = 4e-2, rtol: float = 4e-2
|
||||
) -> None:
|
||||
"""Override: Exchange RMS norm with identity as it creates too big shifts otherwise"""
|
||||
|
||||
def identity_forward(self, hidden_states):
|
||||
return hidden_states
|
||||
|
||||
with patch.object(Gemma4UnifiedRMSNorm, "forward", identity_forward):
|
||||
super().flash_attn_inference_equivalence(attn_implementation, padding_side, atol, rtol)
|
||||
|
||||
|
||||
class Gemma4UnifiedVision2TextModelTester:
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
mm_tokens_per_image=2,
|
||||
image_token_id=4,
|
||||
video_token_id=7,
|
||||
audio_token_id=8,
|
||||
boi_token_id=5,
|
||||
eoi_token_id=6,
|
||||
seq_length=25,
|
||||
is_training=True,
|
||||
vision_config={
|
||||
"use_labels": True,
|
||||
"mm_embed_dim": 64,
|
||||
"output_proj_dims": 64,
|
||||
"image_size": 20,
|
||||
"patch_size": 5,
|
||||
"num_channels": 3,
|
||||
"is_training": True,
|
||||
"initializer_range": 0.02,
|
||||
"pooling_kernel_size": 2,
|
||||
},
|
||||
):
|
||||
self.parent = parent
|
||||
# `image_token_id` is set to 0 to pass "resize_embeddings" test, do not modify
|
||||
self.mm_tokens_per_image = mm_tokens_per_image
|
||||
self.image_token_id = image_token_id
|
||||
self.video_token_id = video_token_id
|
||||
self.audio_token_id = audio_token_id
|
||||
self.boi_token_id = boi_token_id
|
||||
self.eoi_token_id = eoi_token_id
|
||||
self.llm_tester = Gemma4UnifiedTextModelTester(self.parent)
|
||||
self.text_config = self.llm_tester.get_config()
|
||||
self.vision_config = vision_config
|
||||
self.seq_length = seq_length
|
||||
self.pad_token_id = self.text_config.pad_token_id
|
||||
|
||||
self.num_hidden_layers = self.text_config.num_hidden_layers
|
||||
self.vocab_size = self.text_config.vocab_size
|
||||
self.hidden_size = self.text_config.hidden_size
|
||||
self.num_attention_heads = self.text_config.num_attention_heads
|
||||
self.is_training = is_training
|
||||
|
||||
self.batch_size = 3
|
||||
self.num_channels = vision_config["num_channels"]
|
||||
self.image_size = vision_config["image_size"]
|
||||
self.encoder_seq_length = seq_length
|
||||
|
||||
def get_config(self):
|
||||
return Gemma4UnifiedConfig(
|
||||
text_config=self.text_config,
|
||||
vision_config=self.vision_config,
|
||||
image_token_id=self.image_token_id,
|
||||
video_token_id=self.video_token_id,
|
||||
audio_token_id=self.audio_token_id,
|
||||
boi_token_id=self.boi_token_id,
|
||||
eoi_token_id=self.eoi_token_id,
|
||||
mm_tokens_per_image=self.mm_tokens_per_image,
|
||||
)
|
||||
|
||||
def prepare_config_and_inputs(self):
|
||||
config = self.get_config()
|
||||
|
||||
# (num_images, max_num_patches, model_patch_size * model_patch_size * num_channels)
|
||||
pixel_values = floats_tensor(
|
||||
[
|
||||
self.batch_size,
|
||||
self.vision_config["image_size"],
|
||||
config.vision_config.model_patch_size
|
||||
* config.vision_config.model_patch_size
|
||||
* self.vision_config["num_channels"],
|
||||
]
|
||||
)
|
||||
# (num_images, max_num_patches, 2) for height/width positions. Let it be all ones for testign
|
||||
pixel_position_ids = torch.ones(self.vision_config["image_size"], device=torch_device, dtype=torch.long)
|
||||
pixel_position_ids = pixel_position_ids[None, :, None].repeat(self.batch_size, 1, 2)
|
||||
|
||||
return config, pixel_values, pixel_position_ids
|
||||
|
||||
def prepare_config_and_inputs_for_common(self):
|
||||
config_and_inputs = self.prepare_config_and_inputs()
|
||||
config, pixel_values, pixel_position_ids = config_and_inputs
|
||||
input_ids = ids_tensor([self.batch_size, self.seq_length], config.text_config.vocab_size - 1) + 1
|
||||
attention_mask = input_ids.ne(self.pad_token_id).to(torch_device)
|
||||
|
||||
# Ensure no tokens accidentally match special token IDs
|
||||
for token_id in [config.image_token_id, config.video_token_id, config.audio_token_id]:
|
||||
input_ids[input_ids == token_id] = self.pad_token_id
|
||||
|
||||
num_image_tokens = 1
|
||||
pixel_values = pixel_values[:, :num_image_tokens, :]
|
||||
pixel_position_ids = pixel_position_ids[:, :num_image_tokens, :]
|
||||
input_ids[:, :num_image_tokens] = config.image_token_id
|
||||
|
||||
mm_token_type_ids = torch.zeros_like(input_ids)
|
||||
mm_token_type_ids[input_ids == config.image_token_id] = 1
|
||||
|
||||
inputs_dict = {
|
||||
"pixel_values": pixel_values,
|
||||
"image_position_ids": pixel_position_ids,
|
||||
"input_ids": input_ids,
|
||||
"attention_mask": attention_mask,
|
||||
"mm_token_type_ids": mm_token_type_ids,
|
||||
}
|
||||
return config, inputs_dict
|
||||
|
||||
|
||||
@require_torch
|
||||
class Gemma4UnifiedVision2TextModelTest(ModelTesterMixin, GenerationTesterMixin, unittest.TestCase):
|
||||
all_model_classes = (Gemma4UnifiedModel, Gemma4UnifiedForConditionalGeneration) if is_torch_available() else ()
|
||||
all_generative_model_classes = (Gemma4UnifiedForConditionalGeneration,) if is_torch_available() else ()
|
||||
additional_model_inputs = ["mm_token_type_ids"]
|
||||
test_resize_embeddings = False
|
||||
|
||||
def setUp(self):
|
||||
self.model_tester = Gemma4UnifiedVision2TextModelTester(self)
|
||||
self.config_tester = ConfigTester(self, config_class=Gemma4UnifiedConfig, hidden_size=37)
|
||||
self.skip_mm_output_format()
|
||||
|
||||
def skip_mm_output_format(self):
|
||||
skippable_tests = [
|
||||
"test_get_image_features_hidden_states",
|
||||
"test_get_image_features_attentions",
|
||||
"test_get_video_features_hidden_states",
|
||||
"test_get_video_features_attentions",
|
||||
"test_get_audio_features_hidden_states",
|
||||
"test_get_audio_features_attentions",
|
||||
"test_get_image_features_output",
|
||||
"test_get_video_features_output",
|
||||
"test_get_audio_features_output",
|
||||
]
|
||||
|
||||
for test in skippable_tests:
|
||||
if self._testMethodName.startswith(test):
|
||||
self.skipTest(reason="Gemma4 unified does not collect any hidden states or attentions (no mm tower)")
|
||||
|
||||
@unittest.skip("We need 4 layers to correctly test cache sharing.")
|
||||
def test_num_layers_is_small(self):
|
||||
pass
|
||||
|
||||
@unittest.skip("We use vision based masks that use `block_sequence_ids` which force mask materialization")
|
||||
def test_sdpa_can_dispatch_on_flash(self):
|
||||
pass
|
||||
|
||||
def flash_attn_inference_equivalence(
|
||||
self, attn_implementation: str, padding_side: str, atol: float = 4e-2, rtol: float = 4e-2
|
||||
) -> None:
|
||||
"""
|
||||
Overriden to allow passing image position ids as it's mandatory in the vision portion
|
||||
#
|
||||
Exchange RMS norm with identity as it creates too big shifts otherwise
|
||||
"""
|
||||
|
||||
def identity_forward(self, hidden_states):
|
||||
return hidden_states
|
||||
|
||||
with patch.object(Gemma4UnifiedRMSNorm, "forward", identity_forward):
|
||||
if not self.has_attentions:
|
||||
self.skipTest(reason="Model architecture does not support attentions")
|
||||
|
||||
# This flag is used to know if the test was skipped for all `self.all_model_classes` or not
|
||||
_has_run_at_least_one_model = False
|
||||
|
||||
for model_class in self.all_model_classes:
|
||||
# Custom kernel which needs the mask interface to be properly usable on these models
|
||||
if not model_class._supports_attention_backend and not attn_implementation.startswith(
|
||||
"flash_attention"
|
||||
):
|
||||
continue
|
||||
|
||||
# Set seed for deterministic test - ensures reproducible model initialization and inputs
|
||||
set_seed(42)
|
||||
config, inputs_dict = self.model_tester.prepare_config_and_inputs_for_common()
|
||||
|
||||
# flash attention variants does not always support arbitrary headim
|
||||
config = self._prepare_config_headdim(config, 16)
|
||||
|
||||
# forcing the prefill size to go over sliding window size to check for SWA correctness
|
||||
if getattr(config, "sliding_window", None):
|
||||
config.sliding_window = 2
|
||||
|
||||
model = model_class(config)
|
||||
if not all(
|
||||
submodel._supports_flash_attn
|
||||
for submodel in model.modules()
|
||||
if isinstance(submodel, PreTrainedModel)
|
||||
):
|
||||
continue
|
||||
|
||||
# Some models only support a sub set of all FA implementations
|
||||
valid_fa_implementations = model._compatible_flash_implementations
|
||||
if valid_fa_implementations is not None and attn_implementation not in valid_fa_implementations:
|
||||
continue
|
||||
|
||||
# If we end up here, at least one model class was not skipped
|
||||
_has_run_at_least_one_model = True
|
||||
with tempfile.TemporaryDirectory() as tmpdirname:
|
||||
# Save the model so we can reload with correct attention
|
||||
model.save_pretrained(tmpdirname)
|
||||
|
||||
# Create first inputs without attention mask
|
||||
main_input = inputs_dict[model.main_input_name]
|
||||
# Only keep first batch sequence
|
||||
if isinstance(main_input, torch.Tensor):
|
||||
main_input = main_input[:1]
|
||||
# Fix the dtype
|
||||
if torch.is_floating_point(main_input):
|
||||
main_input = main_input.to(torch.bfloat16)
|
||||
first_inputs = {model.main_input_name: main_input, "output_hidden_states": True}
|
||||
# Some models have main input name which is different from input_ids, but require input_ids... e.g. BarkFine
|
||||
if model.main_input_name != "input_ids" and "input_ids" in inputs_dict:
|
||||
first_inputs["input_ids"] = inputs_dict["input_ids"][:1]
|
||||
# If we have some pixel values, use them as well
|
||||
if model.main_input_name != "pixel_values" and "pixel_values" in inputs_dict:
|
||||
# NOTE: this fixes qwen2_5_vl/omni because test break w/ pixel values
|
||||
if "image_grid_thw" in inputs_dict:
|
||||
continue
|
||||
first_inputs["pixel_values"] = inputs_dict["pixel_values"][:1].to(torch.bfloat16)
|
||||
# Some VLMs require image_sizes alongside pixel_values, e.g. lighton_ocr, llava_onevision
|
||||
if "image_sizes" in inputs_dict:
|
||||
first_inputs["image_sizes"] = inputs_dict["image_sizes"][:1]
|
||||
# Key change: Allow image position ids to be passed as well
|
||||
if "image_position_ids" in inputs_dict:
|
||||
first_inputs["image_position_ids"] = inputs_dict["image_position_ids"][:1]
|
||||
if model.config.is_encoder_decoder:
|
||||
decoder_input_ids = inputs_dict.get("decoder_input_ids", first_inputs.get("input_ids"))
|
||||
if decoder_input_ids is not None:
|
||||
first_inputs["decoder_input_ids"] = decoder_input_ids[:1]
|
||||
|
||||
# Create attention mask with padding
|
||||
dummy_attention_mask = inputs_dict.get("attention_mask", None)
|
||||
if dummy_attention_mask is not None:
|
||||
dummy_attention_mask = dummy_attention_mask[:1]
|
||||
if padding_side == "left":
|
||||
dummy_attention_mask[:, 1:] = 1
|
||||
dummy_attention_mask[:, 0] = 0
|
||||
else:
|
||||
dummy_attention_mask[:, :-1] = 1
|
||||
dummy_attention_mask[:, -1] = 0
|
||||
|
||||
# Create second inputs with attention mask and padding
|
||||
second_inputs = copy.deepcopy(first_inputs)
|
||||
if dummy_attention_mask is not None:
|
||||
second_inputs["attention_mask"] = dummy_attention_mask
|
||||
if model.config.is_encoder_decoder:
|
||||
second_inputs["decoder_attention_mask"] = dummy_attention_mask
|
||||
|
||||
# Use prepare for class to account for special attributes (e.g. in QnA models)
|
||||
first_inputs = self._prepare_for_class(first_inputs, model_class)
|
||||
first_inputs = {
|
||||
k: v.to(torch_device) if isinstance(v, torch.Tensor) else v for k, v in first_inputs.items()
|
||||
}
|
||||
second_inputs = self._prepare_for_class(second_inputs, model_class)
|
||||
second_inputs = {
|
||||
k: v.to(torch_device) if isinstance(v, torch.Tensor) else v for k, v in second_inputs.items()
|
||||
}
|
||||
|
||||
model = model_class.from_pretrained(
|
||||
tmpdirname, dtype=torch.bfloat16, attn_implementation="eager", device_map=torch_device
|
||||
)
|
||||
|
||||
def _get_output_logits(outputs):
|
||||
if "hidden_states" in outputs:
|
||||
return outputs.hidden_states[-1]
|
||||
elif model.config.is_encoder_decoder:
|
||||
return outputs.decoder_hidden_states[-1]
|
||||
elif "logits_per_image" in outputs:
|
||||
return outputs.logits_per_image
|
||||
elif "logits_per_video" in outputs:
|
||||
return outputs.logits_per_video
|
||||
else:
|
||||
return outputs.logits
|
||||
|
||||
# First run without attention mask
|
||||
outputs = model(**first_inputs)
|
||||
logits_1_eager = _get_output_logits(outputs)
|
||||
# Second run with attention mask and padding
|
||||
outputs = model(**second_inputs)
|
||||
logits_2_eager = _get_output_logits(outputs)
|
||||
|
||||
# Switch to FA
|
||||
del model
|
||||
model = model_class.from_pretrained(
|
||||
tmpdirname,
|
||||
dtype=torch.bfloat16,
|
||||
attn_implementation=attn_implementation,
|
||||
device_map=torch_device,
|
||||
)
|
||||
outputs = model(**first_inputs)
|
||||
logits_1_fa = _get_output_logits(outputs)
|
||||
# Second run with attention mask and padding
|
||||
outputs = model(**second_inputs)
|
||||
logits_2_fa = _get_output_logits(outputs)
|
||||
|
||||
# Check the results
|
||||
torch.testing.assert_close(logits_1_eager, logits_1_fa, atol=atol, rtol=rtol)
|
||||
if padding_side == "left":
|
||||
torch.testing.assert_close(logits_2_eager[1:], logits_2_fa[1:], atol=atol, rtol=rtol)
|
||||
else:
|
||||
torch.testing.assert_close(logits_2_eager[:-1], logits_2_fa[:-1], atol=atol, rtol=rtol)
|
||||
|
||||
# In this case, the test should appear as skipped, not successful
|
||||
if not _has_run_at_least_one_model:
|
||||
self.skipTest(
|
||||
f"Model architecture does not support {attn_implementation}, or setting its attention dynamically"
|
||||
)
|
||||
|
||||
|
||||
@slow
|
||||
@require_torch_accelerator
|
||||
@unittest.skip(reason="Update after release") # TODO(vasqu)
|
||||
class Gemma4UnifiedIntegrationTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.model_name = "gg-hf-gu/gemma-4-12B-it"
|
||||
self.processor = Gemma4UnifiedProcessor.from_pretrained(self.model_name)
|
||||
|
||||
self.url1 = url_to_local_path(
|
||||
"https://huggingface.co/datasets/hf-internal-testing/fixtures-captioning/resolve/main/cow_beach_1.png"
|
||||
)
|
||||
self.url2 = url_to_local_path(
|
||||
"https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/transformers/tasks/australia.jpg"
|
||||
)
|
||||
self.messages = [
|
||||
{"role": "system", "content": [{"type": "text", "text": "You are a helpful assistant."}]},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image", "url": self.url1},
|
||||
{"type": "text", "text": "What is shown in this image?"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
def tearDown(self):
|
||||
cleanup(torch_device, gc_collect=True)
|
||||
|
||||
def test_model_with_image(self):
|
||||
model = Gemma4UnifiedForConditionalGeneration.from_pretrained(self.model_name, device_map=torch_device)
|
||||
|
||||
inputs = self.processor.apply_chat_template(
|
||||
self.messages,
|
||||
tokenize=True,
|
||||
return_dict=True,
|
||||
return_tensors="pt",
|
||||
add_generation_prompt=True,
|
||||
).to(torch_device)
|
||||
|
||||
output = model.generate(**inputs, max_new_tokens=30, do_sample=False)
|
||||
input_size = inputs.input_ids.shape[-1]
|
||||
output_text = self.processor.batch_decode(output[:, input_size:], skip_special_tokens=True)
|
||||
|
||||
EXPECTED_TEXTS = Expectations(
|
||||
{
|
||||
("cuda", 8): ['This image shows a **brown and white cow** standing on a **sandy beach** with the **ocean and a blue sky** in the background'],
|
||||
}
|
||||
) # fmt: skip
|
||||
EXPECTED_TEXT = EXPECTED_TEXTS.get_expectation()
|
||||
self.assertEqual(output_text, EXPECTED_TEXT)
|
||||
|
||||
def test_model_with_image_batch(self):
|
||||
model = Gemma4UnifiedForConditionalGeneration.from_pretrained(self.model_name, device_map=torch_device)
|
||||
|
||||
messages_2 = [
|
||||
{"role": "system", "content": [{"type": "text", "text": "You are a helpful assistant."}]},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"url": self.url1,
|
||||
},
|
||||
{"type": "image", "url": self.url2},
|
||||
{"type": "text", "text": "Are these images identical?"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
inputs = self.processor.apply_chat_template(
|
||||
[self.messages, messages_2],
|
||||
tokenize=True,
|
||||
return_dict=True,
|
||||
return_tensors="pt",
|
||||
add_generation_prompt=True,
|
||||
processor_kwargs={"padding": True},
|
||||
).to(torch_device)
|
||||
|
||||
output = model.generate(**inputs, max_new_tokens=30, do_sample=False)
|
||||
input_size = inputs.input_ids.shape[-1]
|
||||
output_text = self.processor.batch_decode(output[:, input_size:], skip_special_tokens=True)
|
||||
|
||||
EXPECTED_TEXTS = Expectations(
|
||||
{
|
||||
("cuda", (8, 0)): [
|
||||
"This image shows a **brown and white cow** standing on a **sandy beach** with the **ocean and a blue sky** in the background",
|
||||
"No, these images are not identical.\n\nThe first image is a photograph of a **cow** standing on a beach under a blue sky.\n\n",
|
||||
],
|
||||
("cuda", (8, 6)): [
|
||||
"This image shows a **brown and white cow** standing on a **sandy beach** with the **ocean and a blue sky** in the background",
|
||||
"No, these images are not identical.\n\nThe first image is a photograph of a **brown and white cow standing on a beach** under a blue",
|
||||
],
|
||||
}
|
||||
)
|
||||
EXPECTED_TEXT = EXPECTED_TEXTS.get_expectation()
|
||||
self.assertEqual(output_text, EXPECTED_TEXT)
|
||||
|
||||
def test_model_multiimage(self):
|
||||
model = Gemma4UnifiedForConditionalGeneration.from_pretrained(self.model_name, device_map=torch_device)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": [{"type": "text", "text": "You are a helpful assistant."}]},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image", "url": self.url2},
|
||||
{"type": "text", "text": "What do you see here?"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
inputs = self.processor.apply_chat_template(
|
||||
messages,
|
||||
tokenize=True,
|
||||
return_dict=True,
|
||||
return_tensors="pt",
|
||||
add_generation_prompt=True,
|
||||
processor_kwargs={"padding": True},
|
||||
).to(torch_device)
|
||||
|
||||
output = model.generate(**inputs, max_new_tokens=30, do_sample=False)
|
||||
input_size = inputs.input_ids.shape[-1]
|
||||
output_text = self.processor.batch_decode(output[:, input_size:], skip_special_tokens=True)
|
||||
EXPECTED_TEXTS = Expectations(
|
||||
{
|
||||
("cuda", 8): ['Based on the image, here is a description of what I see:\n\n**Foreground & Street Scene:**\n* **Traffic Sign:** The most prominent'],
|
||||
}
|
||||
) # fmt: skip
|
||||
EXPECTED_TEXT = EXPECTED_TEXTS.get_expectation()
|
||||
self.assertEqual(output_text, EXPECTED_TEXT)
|
||||
|
||||
@require_torch_multi_gpu
|
||||
def test_model_text_only_multigpu(self):
|
||||
"""Accelerate destroys the input dict `shared_kv_states` if it's not passed as kwarg and part of
|
||||
`_skip_keys_device_placement`, so test this to avoid regresions.
|
||||
"""
|
||||
model = AutoModelForCausalLM.from_pretrained(self.model_name, device_map="auto")
|
||||
tokenizer = AutoTokenizer.from_pretrained(self.model_name, padding_side="left")
|
||||
inputs = tokenizer.apply_chat_template(
|
||||
[{"role": "user", "content": "Write a poem about Machine Learning."}],
|
||||
tokenize=True,
|
||||
return_dict=True,
|
||||
return_tensors="pt",
|
||||
add_generation_prompt=True,
|
||||
).to(model.device)
|
||||
|
||||
output = model.generate(**inputs, max_new_tokens=30, do_sample=False)
|
||||
input_size = inputs.input_ids.shape[-1]
|
||||
output_text = self.processor.batch_decode(output[:, input_size:], skip_special_tokens=True)
|
||||
|
||||
EXPECTED_TEXTS = Expectations(
|
||||
{
|
||||
("cuda", (8, 0)): ['## The Algorithmic Mind\n\nA whisper starts, a seed unseen,\nOf data vast, a vibrant sheen.\nA sea of numbers,'],
|
||||
("cuda", (8, 6)): ['## The Algorithmic Mind\n\nA tapestry of data, vast and deep,\nWhere silent numbers in their slumber sleep.\nA sea of text'],
|
||||
}
|
||||
) # fmt: skip
|
||||
EXPECTED_TEXT = EXPECTED_TEXTS.get_expectation()
|
||||
self.assertEqual(output_text, EXPECTED_TEXT)
|
||||
|
||||
def test_model_text_only(self):
|
||||
model = AutoModelForCausalLM.from_pretrained(self.model_name, device_map=torch_device)
|
||||
tokenizer = AutoTokenizer.from_pretrained(self.model_name, padding_side="left")
|
||||
inputs = tokenizer.apply_chat_template(
|
||||
[{"role": "user", "content": "Write a poem about Machine Learning."}],
|
||||
tokenize=True,
|
||||
return_dict=True,
|
||||
return_tensors="pt",
|
||||
add_generation_prompt=True,
|
||||
).to(torch_device)
|
||||
|
||||
output = model.generate(**inputs, max_new_tokens=30, do_sample=False)
|
||||
input_size = inputs.input_ids.shape[-1]
|
||||
output_text = self.processor.batch_decode(output[:, input_size:], skip_special_tokens=True)
|
||||
|
||||
EXPECTED_TEXTS = Expectations(
|
||||
{
|
||||
("cuda", (8, 0)): ['## The Algorithmic Mind\n\nA whisper starts, a seed unseen,\nOf data vast, a vibrant sheen.\nA sea of numbers,'],
|
||||
("cuda", (8, 6)): ['## The Algorithmic Mind\n\nA tapestry of data, vast and deep,\nWhere silent numbers in their slumber sleep.\nA sea of text'],
|
||||
}
|
||||
) # fmt: skip
|
||||
EXPECTED_TEXT = EXPECTED_TEXTS.get_expectation()
|
||||
self.assertEqual(output_text, EXPECTED_TEXT)
|
||||
|
||||
def test_states_sharing_with_and_without_cache(self):
|
||||
model = AutoModelForCausalLM.from_pretrained(self.model_name, device_map=torch_device)
|
||||
tokenizer = AutoTokenizer.from_pretrained(self.model_name, padding_side="left")
|
||||
inputs = tokenizer.apply_chat_template(
|
||||
[{"role": "user", "content": "Who are you? What can you do?"}],
|
||||
tokenize=True,
|
||||
return_dict=True,
|
||||
return_tensors="pt",
|
||||
add_generation_prompt=True,
|
||||
).to(torch_device)
|
||||
input_size = inputs.input_ids.shape[-1]
|
||||
|
||||
# With and without cache generatiom should share kv states the same way
|
||||
output_with_cache = model.generate(**inputs, max_new_tokens=30, do_sample=False, use_cache=True)
|
||||
output_without_cache = model.generate(**inputs, max_new_tokens=30, do_sample=False, use_cache=False)
|
||||
|
||||
output_text_with_cache = tokenizer.batch_decode(output_with_cache[:, input_size:], skip_special_tokens=True)
|
||||
output_text_without_cache = tokenizer.batch_decode(
|
||||
output_without_cache[:, input_size:], skip_special_tokens=True
|
||||
)
|
||||
|
||||
self.assertEqual(output_text_with_cache, output_text_without_cache)
|
||||
|
||||
# Note: we do not test FA2 as the head dim is 512 on some layers, which is not compatible with the kernels
|
||||
@parameterized.expand([("sdpa",), ("eager",)])
|
||||
def test_generation_beyond_sliding_window(self, attn_implementation: str):
|
||||
"""Test that we can correctly generate beyond the sliding window. Outputs for every attention functions
|
||||
should be coherent and identical.
|
||||
"""
|
||||
|
||||
input_text = [
|
||||
"This is a nice place. " * 800 + "I really enjoy the scenery,", # This is larger than 4096 tokens
|
||||
"A list of colors: red, blue", # This will almost all be padding tokens
|
||||
]
|
||||
tokenizer = AutoTokenizer.from_pretrained(self.model_name, padding="left")
|
||||
input_text = [
|
||||
tokenizer.apply_chat_template(
|
||||
[{"role": "user", "content": item}],
|
||||
tokenize=False,
|
||||
add_generation_prompt=True,
|
||||
)
|
||||
for item in input_text
|
||||
]
|
||||
inputs = tokenizer(input_text, padding=True, return_tensors="pt").to(torch_device)
|
||||
|
||||
model = Gemma4UnifiedForConditionalGeneration.from_pretrained(
|
||||
self.model_name,
|
||||
device_map=torch_device,
|
||||
attn_implementation=attn_implementation,
|
||||
)
|
||||
|
||||
# Make sure prefill is larger than sliding window
|
||||
input_size = inputs.input_ids.shape[-1]
|
||||
self.assertTrue(input_size > model.config.get_text_config().sliding_window)
|
||||
|
||||
out = model.generate(**inputs, max_new_tokens=16, do_sample=False, cache_implementation="static")
|
||||
output_text = tokenizer.batch_decode(out[:, input_size:])
|
||||
|
||||
EXPECTED_COMPLETIONS = Expectations(
|
||||
{
|
||||
("cuda", 8): [
|
||||
"That sounds lovely! It seems like you're really enjoying the place you'",
|
||||
"Here are a few ways you could use or expand upon that list, depending on",
|
||||
]
|
||||
}
|
||||
)
|
||||
self.assertEqual(output_text, EXPECTED_COMPLETIONS.get_expectation())
|
||||
|
||||
def test_model_with_audio(self):
|
||||
model = Gemma4UnifiedForConditionalGeneration.from_pretrained(self.model_name, device_map=torch_device)
|
||||
|
||||
audio_url = url_to_local_path(
|
||||
"https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/dude_where_is_my_car.wav"
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": [{"type": "text", "text": "You are a helpful assistant."}]},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Transcribe this:"},
|
||||
{"type": "audio", "url": audio_url},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
inputs = self.processor.apply_chat_template(
|
||||
messages,
|
||||
tokenize=True,
|
||||
return_dict=True,
|
||||
return_tensors="pt",
|
||||
add_generation_prompt=True,
|
||||
).to(torch_device)
|
||||
|
||||
output = model.generate(**inputs, max_new_tokens=10, do_sample=False)
|
||||
input_size = inputs.input_ids.shape[-1]
|
||||
output_text = self.processor.batch_decode(output[:, input_size:], skip_special_tokens=True)
|
||||
|
||||
EXPECTED_TEXTS = Expectations(
|
||||
{
|
||||
("cuda", 8): ["come on dude you got a tattoo"],
|
||||
}
|
||||
)
|
||||
EXPECTED_TEXT = EXPECTED_TEXTS.get_expectation()
|
||||
self.assertEqual(_normalize_text(output_text[0]), EXPECTED_TEXT[0])
|
||||
|
||||
def test_model_with_audio_batch(self):
|
||||
model = Gemma4UnifiedForConditionalGeneration.from_pretrained(self.model_name, device_map=torch_device)
|
||||
|
||||
audio_url1 = url_to_local_path(
|
||||
"https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/dude_where_is_my_car.wav"
|
||||
)
|
||||
audio_url2 = url_to_local_path(
|
||||
"https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/obama.mp3"
|
||||
)
|
||||
messages_1 = [
|
||||
{"role": "system", "content": [{"type": "text", "text": "You are a helpful assistant."}]},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Transcribe this:"},
|
||||
{"type": "audio", "url": audio_url1},
|
||||
],
|
||||
},
|
||||
]
|
||||
messages_2 = [
|
||||
{"role": "system", "content": [{"type": "text", "text": "You are a helpful assistant."}]},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Are these audio clips saying the same thing?"},
|
||||
{"type": "audio", "url": audio_url2},
|
||||
{"type": "audio", "url": audio_url1},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
inputs = self.processor.apply_chat_template(
|
||||
[messages_1, messages_2],
|
||||
tokenize=True,
|
||||
return_dict=True,
|
||||
return_tensors="pt",
|
||||
add_generation_prompt=True,
|
||||
processor_kwargs={"padding": True},
|
||||
).to(torch_device)
|
||||
|
||||
output = model.generate(**inputs, max_new_tokens=10, do_sample=False)
|
||||
input_size = inputs.input_ids.shape[-1]
|
||||
output_text = self.processor.batch_decode(output[:, input_size:], skip_special_tokens=True)
|
||||
|
||||
EXPECTED_TEXTS = Expectations(
|
||||
{
|
||||
("cuda", 8): ["come on dude you got a tattoo", "the first audio clip is a speech and the"],
|
||||
}
|
||||
)
|
||||
EXPECTED_TEXT = EXPECTED_TEXTS.get_expectation()
|
||||
self.assertEqual(_normalize_text(output_text[0]), EXPECTED_TEXT[0])
|
||||
self.assertEqual(_normalize_text(output_text[1]), EXPECTED_TEXT[1])
|
||||
|
||||
@pytest.mark.torch_export_test
|
||||
def test_export_text_only(self):
|
||||
from transformers.integrations.executorch import TorchExportableModuleForDecoderOnlyLM
|
||||
|
||||
model = Gemma4UnifiedForConditionalGeneration.from_pretrained(self.model_name, device_map=torch_device)
|
||||
tokenizer = AutoTokenizer.from_pretrained(self.model_name)
|
||||
|
||||
exportable_module = TorchExportableModuleForDecoderOnlyLM(
|
||||
model, batch_size=1, max_cache_len=1024, device=torch_device
|
||||
)
|
||||
exported_program = exportable_module.export(
|
||||
input_ids=torch.tensor([[1]], device=torch_device, dtype=torch.long),
|
||||
)
|
||||
|
||||
# Test generation with the exported model
|
||||
prompt = tokenizer.apply_chat_template(
|
||||
[{"role": "user", "content": "What is the capital of France?"}],
|
||||
tokenize=False,
|
||||
add_generation_prompt=True,
|
||||
)
|
||||
|
||||
max_new_tokens_to_generate = 20
|
||||
# Generate text with the exported model
|
||||
export_generated_text = TorchExportableModuleForDecoderOnlyLM.generate(
|
||||
exported_program, tokenizer, prompt, max_new_tokens=max_new_tokens_to_generate, device=torch_device
|
||||
)
|
||||
|
||||
input_text = tokenizer(prompt, return_tensors="pt").to(torch_device)
|
||||
eager_outputs = model.generate(
|
||||
**input_text,
|
||||
max_new_tokens=max_new_tokens_to_generate,
|
||||
do_sample=False, # Use greedy decoding to match the exported model
|
||||
)
|
||||
|
||||
eager_generated_text = tokenizer.decode(eager_outputs[0], skip_special_tokens=True)
|
||||
self.assertEqual(export_generated_text, eager_generated_text)
|
||||
206
tests/models/gemma4_unified/test_processing_gemma4_unified.py
Normal file
206
tests/models/gemma4_unified/test_processing_gemma4_unified.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# 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 unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from transformers import Gemma4UnifiedProcessor
|
||||
from transformers.testing_utils import get_tests_dir, require_vision
|
||||
from transformers.utils import is_vision_available
|
||||
|
||||
from ...test_processing_common import ProcessorTesterMixin
|
||||
|
||||
|
||||
if is_vision_available():
|
||||
pass
|
||||
|
||||
SAMPLE_VOCAB = get_tests_dir("fixtures/test_sentencepiece.model")
|
||||
|
||||
|
||||
@require_vision
|
||||
class Gemma4UnifiedProcessorTest(ProcessorTesterMixin, unittest.TestCase):
|
||||
processor_class = Gemma4UnifiedProcessor
|
||||
video_unstructured_max_length = 570
|
||||
video_text_kwargs_max_length = 570
|
||||
video_text_kwargs_override_max_length = 570
|
||||
|
||||
@classmethod
|
||||
def _setup_test_attributes(cls, processor):
|
||||
cls.image_token = processor.image_token
|
||||
cls.video_token = processor.video_token
|
||||
|
||||
@classmethod
|
||||
def _setup_video_processor(cls):
|
||||
video_processor_class = cls._get_component_class_from_processor("video_processor")
|
||||
gemma4_video_processor_kwargs = {
|
||||
"patch_size": 28,
|
||||
"max_soft_tokens": 70,
|
||||
"pooling_kernel_size": 3,
|
||||
"num_frames": 2,
|
||||
}
|
||||
return video_processor_class(**gemma4_video_processor_kwargs)
|
||||
|
||||
@classmethod
|
||||
def _setup_feature_extractor(cls):
|
||||
feature_extractor_class = cls._get_component_class_from_processor("feature_extractor")
|
||||
gemma4_feature_extractor_kwargs = {}
|
||||
return feature_extractor_class(**gemma4_feature_extractor_kwargs)
|
||||
|
||||
@classmethod
|
||||
def _setup_image_processor(cls):
|
||||
image_processor_class = cls._get_component_class_from_processor("image_processor")
|
||||
gemma4_image_processor_kwargs = {
|
||||
"patch_size": 28,
|
||||
"max_soft_tokens": 70,
|
||||
"pooling_kernel_size": 3,
|
||||
}
|
||||
return image_processor_class(**gemma4_image_processor_kwargs)
|
||||
|
||||
@classmethod
|
||||
def _setup_tokenizer(cls):
|
||||
tokenizer_class = cls._get_component_class_from_processor("tokenizer")
|
||||
extra_special_tokens = {
|
||||
"image_token": "<image_soft_token>",
|
||||
"boi_token": "<start_of_image>",
|
||||
"eoi_token": "<end_of_image>",
|
||||
"audio_token": "<audio_soft_token>",
|
||||
"boa_token": "<start_of_audio>",
|
||||
"eoa_token": "<end_of_audio>",
|
||||
}
|
||||
tokenizer = tokenizer_class.from_pretrained(
|
||||
SAMPLE_VOCAB, keep_accents=True, extra_special_tokens=extra_special_tokens
|
||||
)
|
||||
tokenizer.pad_token_id = tokenizer.eos_token_id
|
||||
return tokenizer
|
||||
|
||||
# Copied from tests.models.llava.test_processing_llava.LlavaProcessorTest.test_get_num_vision_tokens
|
||||
def test_get_num_vision_tokens(self):
|
||||
"Tests general functionality of the helper used internally in vLLM"
|
||||
|
||||
processor = self.get_processor()
|
||||
|
||||
output = processor._get_num_multimodal_tokens(image_sizes=[(100, 100), (300, 100), (500, 30)])
|
||||
self.assertTrue("num_image_tokens" in output)
|
||||
self.assertEqual(len(output["num_image_tokens"]), 3)
|
||||
|
||||
self.assertTrue("num_image_patches" in output)
|
||||
self.assertEqual(len(output["num_image_patches"]), 3)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
shutil.rmtree(cls.tmpdirname, ignore_errors=True)
|
||||
|
||||
@staticmethod
|
||||
def prepare_processor_dict():
|
||||
return {
|
||||
"chat_template": "{{ bos_token }}\n{%- if messages[0]['role'] == 'system' -%}\n {%- set first_user_prefix = messages[0]['content'][0]['text'] + '\n\n' -%}\n {%- set loop_messages = messages[1:] -%}\n{%- else -%}\n {%- set first_user_prefix = \"\" -%}\n {%- set loop_messages = messages -%}\n{%- endif -%}\n{%- for message in loop_messages -%}\n {%- if (message['role'] == 'user') != (loop.index0 % 2 == 0) -%}\n {{ raise_exception(\"Conversation roles must alternate user/assistant/user/assistant/...\") }}\n {%- endif -%}\n {%- if (message['role'] == 'assistant') -%}\n {%- set role = \"model\" -%}\n {%- else -%}\n {%- set role = message['role'] -%}\n {%- endif -%}\n {{ '<start_of_turn>' + role + '\n' + (first_user_prefix if loop.first else \"\") }}\n {%- if message['content'] is string -%}\n {{ message['content'] | trim }}\n {%- elif message['content'] is iterable -%}\n {%- for item in message['content'] -%}\n {%- if item['type'] == 'image' -%}\n {{ '<image_soft_token>' }}\n {%- elif item['type'] == 'video' -%}\n{{ '<video_soft_token>' }}\n {%- elif item['type'] == 'text' -%}\n {{ item['text'] | trim }}\n {%- endif -%}\n {%- endfor -%}\n {%- else -%}\n {{ raise_exception(\"Invalid content type\") }}\n {%- endif -%}\n {{ '<end_of_turn>\n' }}\n{%- endfor -%}\n{%- if add_generation_prompt -%}\n {{'<start_of_turn>model\n'}}\n{%- endif -%}\n", "image_seq_length": 3,
|
||||
} # fmt: skip
|
||||
|
||||
# Override as Gemma4Unified needs images to be an explicitly nested batch
|
||||
def prepare_image_inputs(self, batch_size: int | None = None):
|
||||
"""This function prepares a list of PIL images for testing"""
|
||||
images = super().prepare_image_inputs(batch_size)
|
||||
if isinstance(images, (list, tuple)):
|
||||
images = [[image] for image in images]
|
||||
return images
|
||||
|
||||
def test_text_with_image_tokens(self):
|
||||
feature_extractor = self.get_component("feature_extractor")
|
||||
image_processor = self.get_component("image_processor")
|
||||
video_processor = self.get_component("video_processor")
|
||||
tokenizer = self.get_component("tokenizer")
|
||||
|
||||
processor = self.processor_class(
|
||||
feature_extractor=feature_extractor,
|
||||
tokenizer=tokenizer,
|
||||
image_processor=image_processor,
|
||||
video_processor=video_processor,
|
||||
)
|
||||
text_multi_images = f"{processor.image_token}{processor.image_token}Dummy text!"
|
||||
text_single_image = f"{processor.image_token}Dummy text!"
|
||||
|
||||
image = self.prepare_image_inputs()
|
||||
|
||||
# We can't be sure what is users intention: if user wants one image per text OR two images for first text and no image for second text
|
||||
with self.assertRaises(ValueError):
|
||||
_ = processor(text=[text_single_image, text_single_image], images=[image, image], return_tensors="np")
|
||||
|
||||
# The users is expected to be explicit about which image belong to which text by nesting the images list
|
||||
out_multiimages = processor(text=text_multi_images, images=[image, image], return_tensors="np")
|
||||
out_batch_oneimage = processor(
|
||||
text=[text_single_image, text_single_image], images=[[image], [image]], return_tensors="np"
|
||||
)
|
||||
self.assertListEqual(
|
||||
out_batch_oneimage[self.images_input_name].tolist(), out_multiimages[self.images_input_name].tolist()
|
||||
)
|
||||
|
||||
def test_special_mm_token_truncation(self):
|
||||
"""Tests that special vision tokens do not get truncated when `truncation=True` is set."""
|
||||
|
||||
processor = self.get_processor()
|
||||
|
||||
input_str = self.prepare_text_inputs(batch_size=2, modalities="image")
|
||||
image_input = self.prepare_image_inputs(batch_size=2)
|
||||
_ = processor(
|
||||
text=input_str,
|
||||
images=image_input,
|
||||
return_tensors="pt",
|
||||
truncation=None,
|
||||
padding=True,
|
||||
)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
_ = processor(
|
||||
text=input_str,
|
||||
images=image_input,
|
||||
return_tensors="pt",
|
||||
truncation=True,
|
||||
padding=True,
|
||||
max_length=5,
|
||||
)
|
||||
|
||||
def test_get_num_multimodal_tokens_matches_processor_call(self):
|
||||
"Tests that the helper used internally in vLLM works correctly"
|
||||
|
||||
processor = self.get_processor()
|
||||
if processor.tokenizer.pad_token_id is None:
|
||||
processor.tokenizer.pad_token_id = processor.tokenizer.eos_token_id
|
||||
|
||||
if not hasattr(processor, "_get_num_multimodal_tokens"):
|
||||
self.skipTest("Processor doesn't support `_get_num_multimodal_tokens` yet")
|
||||
|
||||
image_sizes = [(100, 100), (300, 100), (500, 30), (213, 167)]
|
||||
|
||||
# Overwritten because Gemma3 needs nested image inputs
|
||||
image_inputs = []
|
||||
for h, w in image_sizes:
|
||||
image_inputs.append([np.random.randint(255, size=(h, w, 3), dtype=np.uint8)])
|
||||
|
||||
text = [f"This is an image {getattr(self, 'image_token', '')}"] * len(image_inputs)
|
||||
inputs = processor(
|
||||
text=text, images=image_inputs, padding=True, return_mm_token_type_ids=True, return_tensors="pt"
|
||||
)
|
||||
|
||||
if "mm_token_type_ids" not in inputs:
|
||||
self.skipTest("Processor doesn't support `mm_token_type_ids`")
|
||||
|
||||
num_image_tokens_from_call = inputs.mm_token_type_ids.sum(-1).tolist()
|
||||
num_image_tokens_from_helper = processor._get_num_multimodal_tokens(image_sizes=image_sizes)
|
||||
self.assertListEqual(num_image_tokens_from_call, num_image_tokens_from_helper["num_image_tokens"])
|
||||
|
||||
@unittest.skip("This test seems to be loading a different video, check for all models and fix")
|
||||
def test_apply_chat_template_video_frame_sampling(self):
|
||||
pass
|
||||
@@ -0,0 +1,208 @@
|
||||
# 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 unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from transformers.testing_utils import require_torch, require_vision
|
||||
from transformers.utils import is_torch_available, is_torchvision_available, is_vision_available
|
||||
|
||||
from ...test_video_processing_common import VideoProcessingTestMixin, prepare_video_inputs
|
||||
|
||||
|
||||
if is_torch_available():
|
||||
import torch
|
||||
|
||||
if is_vision_available() and is_torchvision_available():
|
||||
from transformers import Gemma4UnifiedVideoProcessor
|
||||
|
||||
|
||||
class Gemma4UnifiedVideoProcessingTester:
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
batch_size=5,
|
||||
num_frames=8,
|
||||
num_channels=3,
|
||||
min_resolution=30,
|
||||
max_resolution=80,
|
||||
do_resize=True,
|
||||
do_normalize=True,
|
||||
image_mean=None,
|
||||
image_std=None,
|
||||
do_convert_rgb=True,
|
||||
patch_size=6,
|
||||
max_soft_tokens=70,
|
||||
pooling_kernel_size=1,
|
||||
):
|
||||
image_mean = image_mean if image_mean is not None else [0.0, 0.0, 0.0]
|
||||
image_std = image_std if image_std is not None else [1.0, 1.0, 1.0]
|
||||
self.parent = parent
|
||||
self.batch_size = batch_size
|
||||
self.num_frames = num_frames
|
||||
self.num_channels = num_channels
|
||||
self.min_resolution = min_resolution
|
||||
self.max_resolution = max_resolution
|
||||
self.do_resize = do_resize
|
||||
self.do_normalize = do_normalize
|
||||
self.image_mean = image_mean
|
||||
self.image_std = image_std
|
||||
self.do_convert_rgb = do_convert_rgb
|
||||
self.patch_size = patch_size
|
||||
self.max_soft_tokens = max_soft_tokens
|
||||
self.pooling_kernel_size = pooling_kernel_size
|
||||
|
||||
def prepare_video_processor_dict(self):
|
||||
return {
|
||||
"do_resize": self.do_resize,
|
||||
"do_normalize": self.do_normalize,
|
||||
"image_mean": self.image_mean,
|
||||
"image_std": self.image_std,
|
||||
"do_convert_rgb": self.do_convert_rgb,
|
||||
"patch_size": self.patch_size,
|
||||
"max_soft_tokens": self.max_soft_tokens,
|
||||
"pooling_kernel_size": self.pooling_kernel_size,
|
||||
"do_sample_frames": True,
|
||||
"num_frames": self.num_frames,
|
||||
}
|
||||
|
||||
def expected_output_video_shape(self, videos=None):
|
||||
"""Encoder-free output is padded to max_soft_tokens: shape does not depend on input resolution."""
|
||||
model_patch_size = self.patch_size * self.pooling_kernel_size
|
||||
return [self.num_frames, self.max_soft_tokens, model_patch_size**2 * 3]
|
||||
|
||||
# Copied from tests.models.llava_onevision.test_video_processing_llava_onevision.LlavaOnevisionVideoProcessingTester.prepare_video_inputs
|
||||
def prepare_video_inputs(self, equal_resolution=False, return_tensors="pil"):
|
||||
videos = prepare_video_inputs(
|
||||
batch_size=self.batch_size,
|
||||
num_frames=self.num_frames,
|
||||
num_channels=self.num_channels,
|
||||
min_resolution=self.min_resolution,
|
||||
max_resolution=self.max_resolution,
|
||||
equal_resolution=equal_resolution,
|
||||
return_tensors=return_tensors,
|
||||
)
|
||||
return videos
|
||||
|
||||
|
||||
@require_torch
|
||||
@require_vision
|
||||
class Gemma4UnifiedVideoProcessingTest(VideoProcessingTestMixin, unittest.TestCase):
|
||||
fast_video_processing_class = Gemma4UnifiedVideoProcessor if is_torchvision_available() else None
|
||||
input_name = "pixel_values_videos"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.video_processor_tester = Gemma4UnifiedVideoProcessingTester(self)
|
||||
|
||||
@property
|
||||
def video_processor_dict(self):
|
||||
return self.video_processor_tester.prepare_video_processor_dict()
|
||||
|
||||
@unittest.skip("Gemma4Unified patchification requires RGB (3-channel) videos; 4-channel inputs are unsupported.")
|
||||
def test_call_numpy_4_channels(self):
|
||||
pass
|
||||
|
||||
def test_call_sample_frames(self):
|
||||
"""Gemma4Unified sets a class-level `num_frames` default, so `fps`-only sampling resolves
|
||||
`num_frames=self.num_frames` and never reaches the metadata-required path; test `num_frames` sampling only."""
|
||||
for video_processing_class in self.video_processor_list:
|
||||
video_processing = video_processing_class(**self.video_processor_dict)
|
||||
video_inputs = self.video_processor_tester.prepare_video_inputs(
|
||||
equal_resolution=False, return_tensors="torch"
|
||||
)
|
||||
|
||||
video_processing.do_sample_frames = False
|
||||
encoded = video_processing(video_inputs[0], return_tensors="pt", num_frames=3)[self.input_name]
|
||||
self.assertEqual(encoded.shape[1], self.video_processor_tester.num_frames)
|
||||
|
||||
video_processing.do_sample_frames = True
|
||||
encoded = video_processing(video_inputs[0], return_tensors="pt", num_frames=3)[self.input_name]
|
||||
encoded_batched = video_processing(video_inputs, return_tensors="pt", num_frames=3)[self.input_name]
|
||||
self.assertEqual(encoded.shape[1], 3)
|
||||
self.assertEqual(encoded_batched.shape[1], 3)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
video_processing(
|
||||
video_inputs[0], return_tensors="pt", num_frames=self.video_processor_tester.num_frames + 2
|
||||
)
|
||||
|
||||
def test_video_processor_from_dict_with_kwargs(self):
|
||||
"""Gemma4Unified has no `size`/`crop_size`; override with patch budget kwargs instead."""
|
||||
video_processor = self.fast_video_processing_class.from_dict(self.video_processor_dict)
|
||||
self.assertEqual(video_processor.patch_size, self.video_processor_tester.patch_size)
|
||||
self.assertEqual(video_processor.max_soft_tokens, self.video_processor_tester.max_soft_tokens)
|
||||
|
||||
video_processor = self.fast_video_processing_class.from_dict(self.video_processor_dict, patch_size=18)
|
||||
self.assertEqual(video_processor.patch_size, 18)
|
||||
|
||||
def test_video_processor_defaults(self):
|
||||
processor = self.fast_video_processing_class()
|
||||
self.assertEqual(processor.patch_size, 16)
|
||||
self.assertEqual(processor.max_soft_tokens, 70)
|
||||
self.assertEqual(processor.pooling_kernel_size, 3)
|
||||
self.assertEqual(processor.num_frames, 32)
|
||||
|
||||
def test_unsupported_max_soft_tokens_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.fast_video_processing_class(max_soft_tokens=71)
|
||||
|
||||
def test_output_keys(self):
|
||||
processor = self.fast_video_processing_class(**self.video_processor_dict)
|
||||
videos = self.video_processor_tester.prepare_video_inputs(return_tensors="torch")
|
||||
result = processor(videos[0], return_tensors="pt")
|
||||
self.assertIn("pixel_values_videos", result)
|
||||
self.assertIn("video_position_ids", result)
|
||||
self.assertIn("num_soft_tokens_per_video", result)
|
||||
|
||||
def test_position_ids_structure(self):
|
||||
"""Per frame: real positions are non-negative and contiguous, padding positions are (-1, -1)."""
|
||||
processor = self.fast_video_processing_class(**self.video_processor_dict)
|
||||
videos = self.video_processor_tester.prepare_video_inputs(return_tensors="torch")
|
||||
result = processor(videos[0], return_tensors="pt")
|
||||
|
||||
position_ids = result.video_position_ids[0]
|
||||
self.assertEqual(position_ids.shape[-1], 2)
|
||||
for frame_positions in position_ids:
|
||||
real_mask = frame_positions[:, 0] >= 0
|
||||
self.assertGreater(real_mask.sum().item(), 0)
|
||||
pad_mask = ~real_mask
|
||||
if pad_mask.any():
|
||||
self.assertTrue((frame_positions[pad_mask] == -1).all())
|
||||
last_real_idx = torch.where(real_mask)[0][-1].item()
|
||||
first_pad_idx = torch.where(pad_mask)[0][0].item()
|
||||
self.assertEqual(last_real_idx + 1, first_pad_idx)
|
||||
|
||||
def test_padding_patches_are_zero(self):
|
||||
processor = self.fast_video_processing_class(**self.video_processor_dict)
|
||||
video = torch.randint(1, 255, (self.video_processor_tester.num_frames, 3, 50, 50), dtype=torch.uint8)
|
||||
result = processor(video, return_tensors="pt")
|
||||
|
||||
position_ids = result.video_position_ids[0]
|
||||
pixel_values = result.pixel_values_videos[0]
|
||||
for frame_index in range(position_ids.shape[0]):
|
||||
pad_mask = position_ids[frame_index, :, 0] < 0
|
||||
if pad_mask.any():
|
||||
self.assertTrue((pixel_values[frame_index, pad_mask] == 0).all())
|
||||
|
||||
def test_num_soft_tokens_per_video(self):
|
||||
processor = self.fast_video_processing_class(**self.video_processor_dict)
|
||||
videos = self.video_processor_tester.prepare_video_inputs(return_tensors="torch")
|
||||
result = processor(videos, return_tensors="pt")
|
||||
|
||||
num_soft_tokens = np.asarray(result.num_soft_tokens_per_video)
|
||||
self.assertEqual(num_soft_tokens.shape[0], self.video_processor_tester.batch_size)
|
||||
self.assertTrue((num_soft_tokens > 0).all())
|
||||
self.assertTrue((num_soft_tokens <= self.video_processor_tester.max_soft_tokens).all())
|
||||
Reference in New Issue
Block a user