Skip to content

Commit 5536fd6

Browse files
authored
Fix Gemma3/3n/4 preprocessor crashes with Python-only tokenizer (keras-team#2773)
* Fix Gemma3/3n/4 preprocessor crashes with Python-only tokenizer Use tf.shape(token_ids)[0] instead of tf.shape(prompts)[0] in Gemma3CausalLMPreprocessor, Gemma3nCausalLMPreprocessor, and Gemma4CausalLMPreprocessor after tokenization. This prevents crashes when the Python-only tokenizer workflow returns list[list[int]] with varying sequence lengths. The tensor_utils.py ragged-batch fallback is intentionally omitted here; PR keras-team#2769 adds _call_python paths that bypass convert_to_ragged_batch for the Python-only workflow, so that part is not needed in this PR. Adds regression tests for generate_preprocess() and __call__() with real Python tokenizers and prompts that tokenize to different lengths. * Strengthen call() regression tests for Gemma3/3n/4 python-only tokenizer fix test_call_with_python_only_tokenizer previously constructed each preprocessor with image_converter=None, which makes call() return from the text-only branch before reaching the tf.shape(token_ids)[0] line this fix touches, so the test passed regardless of the fix. Set an image_converter (mirroring test_generate_preprocess_with_python_only_tokenizer) so the test actually exercises that code path. --------- Co-authored-by: RAHUL KUMAR <pctablet505@users.noreply.github.com>
1 parent 41faf96 commit 5536fd6

6 files changed

Lines changed: 147 additions & 6 deletions

keras_hub/src/models/gemma3/gemma3_causal_lm_preprocessor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,7 @@ def call(
605605

606606
# === Vision processing ===
607607

608-
batch_size = tf.shape(prompts)[0]
608+
batch_size = tf.shape(token_ids)[0]
609609
desired_height = self.image_converter.image_size[0]
610610
desired_width = self.image_converter.image_size[1]
611611
if images is None:
@@ -759,7 +759,7 @@ def generate_preprocess(
759759

760760
# === Vision processing ===
761761

762-
batch_size = tf.shape(prompts)[0]
762+
batch_size = tf.shape(token_ids)[0]
763763
desired_height = self.image_converter.image_size[0]
764764
desired_width = self.image_converter.image_size[1]
765765
if images is None:

keras_hub/src/models/gemma3/gemma3_causal_lm_preprocessor_test.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
13
import numpy as np
24
import pytest
35

@@ -7,6 +9,7 @@
79
from keras_hub.src.models.gemma3.gemma3_image_converter import (
810
Gemma3ImageConverter,
911
)
12+
from keras_hub.src.models.gemma3.gemma3_tokenizer import Gemma3Tokenizer
1013
from keras_hub.src.tests.mocks.mock_gemma3_tokenizer import MockGemma3Tokenizer
1114
from keras_hub.src.tests.test_case import TestCase
1215

@@ -181,6 +184,48 @@ def test_invalid_shape(self):
181184
}
182185
self.text_preprocessor(input_data)
183186

187+
def test_generate_preprocess_with_python_only_tokenizer(self):
188+
proto_path = os.path.join(
189+
self.get_test_data_dir(), "gemma3_test_vocab.spm"
190+
)
191+
tokenizer = Gemma3Tokenizer(proto=proto_path)
192+
image_converter = Gemma3ImageConverter(image_size=(4, 4))
193+
preprocessor = Gemma3CausalLMPreprocessor(
194+
tokenizer=tokenizer,
195+
image_converter=image_converter,
196+
sequence_length=20,
197+
max_images_per_prompt=2,
198+
num_vision_tokens_per_image=5,
199+
)
200+
# Prompts tokenize to different lengths -> non-rectangular list.
201+
prompts = ["the quick brown fox", "the quick"]
202+
output = preprocessor.generate_preprocess(prompts, sequence_length=20)
203+
self.assertEqual(output["token_ids"].shape[0], 2)
204+
self.assertEqual(output["padding_mask"].shape[0], 2)
205+
206+
def test_call_with_python_only_tokenizer(self):
207+
proto_path = os.path.join(
208+
self.get_test_data_dir(), "gemma3_test_vocab.spm"
209+
)
210+
tokenizer = Gemma3Tokenizer(proto=proto_path)
211+
# image_converter must be set: the text-only branch of `call()`
212+
# returns before `token_ids` is used to compute `batch_size`.
213+
image_converter = Gemma3ImageConverter(image_size=(4, 4))
214+
preprocessor = Gemma3CausalLMPreprocessor(
215+
tokenizer=tokenizer,
216+
image_converter=image_converter,
217+
sequence_length=20,
218+
max_images_per_prompt=2,
219+
num_vision_tokens_per_image=5,
220+
)
221+
# Prompts tokenize to different lengths -> non-rectangular list.
222+
input_data = {
223+
"prompts": ["the quick brown fox", "the quick"],
224+
"responses": ["round", "round"],
225+
}
226+
output = preprocessor(input_data)
227+
self.assertEqual(output[0]["token_ids"].shape[0], 2)
228+
184229
@pytest.mark.kaggle_key_required
185230
@pytest.mark.extra_large
186231
def test_all_presets(self):

keras_hub/src/models/gemma3n/gemma3n_causal_lm_preprocessor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,7 @@ def call(
656656
return keras.utils.pack_x_y_sample_weight(x, y, sample_weight)
657657

658658
# === Multimodal processing ===
659-
batch_size = tf.shape(prompts)[0]
659+
batch_size = tf.shape(token_ids)[0]
660660
desired_height = (
661661
self.image_converter.image_size[0] if self.image_converter else 0
662662
)
@@ -846,7 +846,7 @@ def generate_preprocess(
846846
}
847847

848848
# === Multimodal processing ===
849-
batch_size = tf.shape(prompts)[0]
849+
batch_size = tf.shape(token_ids)[0]
850850
desired_height = (
851851
self.image_converter.image_size[0] if self.image_converter else 0
852852
)

keras_hub/src/models/gemma3n/gemma3n_causal_lm_preprocessor_test.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
13
import numpy as np
24

35
from keras_hub.src.models.gemma3n.gemma3n_audio_converter import (
@@ -9,6 +11,7 @@
911
from keras_hub.src.models.gemma3n.gemma3n_image_converter import (
1012
Gemma3nImageConverter,
1113
)
14+
from keras_hub.src.models.gemma3n.gemma3n_tokenizer import Gemma3nTokenizer
1215
from keras_hub.src.tests.mocks.mock_gemma3n_tokenizer import (
1316
MockGemma3nTokenizer,
1417
)
@@ -325,3 +328,51 @@ def test_text_only_with_audios_raises_error(self):
325328
"audios": [np.ones((16000,))],
326329
}
327330
self.text_preprocessor(input_data)
331+
332+
def test_generate_preprocess_with_python_only_tokenizer(self):
333+
proto_path = os.path.join(
334+
self.get_test_data_dir(), "gemma3n_test_vocab.spm"
335+
)
336+
tokenizer = Gemma3nTokenizer(proto=proto_path)
337+
image_converter = Gemma3nImageConverter(image_size=(4, 4))
338+
preprocessor = Gemma3nCausalLMPreprocessor(
339+
tokenizer=tokenizer,
340+
image_converter=image_converter,
341+
audio_converter=None,
342+
sequence_length=20,
343+
max_images_per_prompt=2,
344+
num_vision_tokens_per_image=5,
345+
max_audios_per_prompt=0,
346+
num_audio_tokens_per_audio=0,
347+
)
348+
# Prompts tokenize to different lengths -> non-rectangular list.
349+
prompts = ["the quick brown fox", "the quick"]
350+
output = preprocessor.generate_preprocess(prompts, sequence_length=20)
351+
self.assertEqual(output["token_ids"].shape[0], 2)
352+
self.assertEqual(output["padding_mask"].shape[0], 2)
353+
354+
def test_call_with_python_only_tokenizer(self):
355+
proto_path = os.path.join(
356+
self.get_test_data_dir(), "gemma3n_test_vocab.spm"
357+
)
358+
tokenizer = Gemma3nTokenizer(proto=proto_path)
359+
# image_converter must be set: the text-only branch of `call()`
360+
# returns before `token_ids` is used to compute `batch_size`.
361+
image_converter = Gemma3nImageConverter(image_size=(4, 4))
362+
preprocessor = Gemma3nCausalLMPreprocessor(
363+
tokenizer=tokenizer,
364+
image_converter=image_converter,
365+
audio_converter=None,
366+
sequence_length=20,
367+
max_images_per_prompt=2,
368+
num_vision_tokens_per_image=5,
369+
max_audios_per_prompt=0,
370+
num_audio_tokens_per_audio=0,
371+
)
372+
# Prompts tokenize to different lengths -> non-rectangular list.
373+
input_data = {
374+
"prompts": ["the quick brown fox", "the quick"],
375+
"responses": ["round", "round"],
376+
}
377+
output = preprocessor(input_data)
378+
self.assertEqual(output[0]["token_ids"].shape[0], 2)

keras_hub/src/models/gemma4/gemma4_causal_lm_preprocessor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -966,7 +966,7 @@ def call(
966966

967967
return keras.utils.pack_x_y_sample_weight(x, y, sample_weight)
968968

969-
batch_size = tf.shape(prompts)[0]
969+
batch_size = tf.shape(token_ids)[0]
970970

971971
# === Vision & Audio processing ===
972972
if images is not None and self.image_converter is not None:
@@ -1149,7 +1149,7 @@ def generate_preprocess(
11491149
),
11501150
}
11511151

1152-
batch_size = tf.shape(prompts)[0]
1152+
batch_size = tf.shape(token_ids)[0]
11531153

11541154
# === Vision & Audio processing ===
11551155
if images is not None and self.image_converter is not None:

keras_hub/src/models/gemma4/gemma4_causal_lm_preprocessor_test.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
13
import numpy as np
24
import pytest
35
import tensorflow as tf
@@ -8,6 +10,7 @@
810
from keras_hub.src.models.gemma4.gemma4_image_converter import (
911
Gemma4ImageConverter,
1012
)
13+
from keras_hub.src.models.gemma4.gemma4_tokenizer import Gemma4Tokenizer
1114
from keras_hub.src.models.gemma4.gemma4_video_converter import (
1215
Gemma4VideoConverter,
1316
)
@@ -313,6 +316,48 @@ def test_video_metadata_timestamps(self):
313316
expanded_cleared.numpy()[0], expanded_default.numpy()[0]
314317
)
315318

319+
def test_generate_preprocess_with_python_only_tokenizer(self):
320+
proto_path = os.path.join(
321+
self.get_test_data_dir(), "gemma4_test_vocab.spm"
322+
)
323+
tokenizer = Gemma4Tokenizer(proto=proto_path)
324+
image_converter = Gemma4ImageConverter(image_size=(4, 4), patch_size=4)
325+
preprocessor = Gemma4CausalLMPreprocessor(
326+
tokenizer=tokenizer,
327+
image_converter=image_converter,
328+
sequence_length=20,
329+
max_images_per_prompt=2,
330+
num_vision_tokens_per_image=5,
331+
)
332+
# Prompts tokenize to different lengths -> non-rectangular list.
333+
prompts = ["the quick brown fox", "the quick"]
334+
output = preprocessor.generate_preprocess(prompts, sequence_length=20)
335+
self.assertEqual(output["token_ids"].shape[0], 2)
336+
self.assertEqual(output["padding_mask"].shape[0], 2)
337+
338+
def test_call_with_python_only_tokenizer(self):
339+
proto_path = os.path.join(
340+
self.get_test_data_dir(), "gemma4_test_vocab.spm"
341+
)
342+
tokenizer = Gemma4Tokenizer(proto=proto_path)
343+
# image_converter must be set: the text-only branch of `call()`
344+
# returns before `token_ids` is used to compute `batch_size`.
345+
image_converter = Gemma4ImageConverter(image_size=(4, 4), patch_size=4)
346+
preprocessor = Gemma4CausalLMPreprocessor(
347+
tokenizer=tokenizer,
348+
image_converter=image_converter,
349+
sequence_length=20,
350+
max_images_per_prompt=2,
351+
num_vision_tokens_per_image=5,
352+
)
353+
# Prompts tokenize to different lengths -> non-rectangular list.
354+
input_data = {
355+
"prompts": ["the quick brown fox", "the quick"],
356+
"responses": ["round", "round"],
357+
}
358+
output = preprocessor(input_data)
359+
self.assertEqual(output[0]["token_ids"].shape[0], 2)
360+
316361
@pytest.mark.kaggle_key_required
317362
@pytest.mark.extra_large
318363
def test_all_presets(self):

0 commit comments

Comments
 (0)