Skip to content

Gemma4 kerashub to safetensors checkpoint conversion#2853

Open
kharshith-k wants to merge 18 commits into
keras-team:masterfrom
kharshith-k:gemma4-kerashub-hf-checkpoint-conversion
Open

Gemma4 kerashub to safetensors checkpoint conversion#2853
kharshith-k wants to merge 18 commits into
keras-team:masterfrom
kharshith-k:gemma4-kerashub-hf-checkpoint-conversion

Conversation

@kharshith-k

Copy link
Copy Markdown
Member

No description provided.

- keras_hub/src/utils/transformers/export/gemma4.py: New export helper
  implementing get_gemma4_config, get_gemma4_weights_map, and
  get_gemma4_tokenizer_config. Handles text-only and multimodal
  (vision + audio) variants, MoE (26B-A4B), KV-shared layers,
  per-layer embeddings (PLE), and layer_scalar buffers.

- keras_hub/src/utils/transformers/export/gemma4_test.py: Unit tests
  covering config generation, weight-map key names and shapes,
  end-to-end export, and tokenizer config.

- tools/checkpoint_export/verify_gemma4_export.py: Round-trip
  verification script (mirrors verify_mistral_export.py pattern).
  Supports --text_only structural smoke-test and real-weights
  comparison via --preset.

- keras_hub/src/utils/transformers/export/hf_exporter.py: Register- keras_hub/src/utils/transformers/export/hf_exporter.py: Rs.
@github-actions github-actions Bot added the Gemma Gemma model specific issues label Jul 22, 2026
@github-actions
github-actions Bot requested a review from laxmareddyp July 22, 2026 14:49

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces Hugging Face export utilities, unit tests, and verification scripts for the Gemma4 model in KerasHub, supporting both text-only and multimodal (vision and audio) configurations. The feedback highlights several key improvement opportunities: updating .gamma to .scale on normalization layers for Keras 3 compatibility, accessing configuration attributes directly from the vision and audio encoders rather than traversing internal layers, removing unused imports in the test file, and initializing st_keys in the verification script to prevent a potential NameError during exception handling.

weights_dict[f"{hf_conv_pfx}.conv.weight"] = ops.transpose(
conv_block.conv.kernel, axes=(3, 2, 0, 1)
)
weights_dict[f"{hf_conv_pfx}.norm.weight"] = conv_block.norm.gamma

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

On line 483, conv_block.norm.gamma is used to retrieve the scale parameter of the LayerNormalization layer. However, in Keras 3, LayerNormalization uses scale as the attribute name for its scale weights (and bias for its center weights). Furthermore, the rest of this file consistently uses .scale to access normalization weights (e.g., block.pre_attention_norm.scale, backbone.get_layer("final_normalization").scale). Using .gamma here is inconsistent and may fail in Keras 3. It should be updated to .scale.

Suggested change
weights_dict[f"{hf_conv_pfx}.norm.weight"] = conv_block.norm.gamma
weights_dict[f"{hf_conv_pfx}.norm.weight"] = conv_block.norm.scale

Comment on lines +89 to +109
def _build_vision_config(vision_encoder):
"""Build the ``vision_config`` dict from a Gemma4VisionEncoder."""
image_encoder = vision_encoder.get_layer("image_encoder")
return {
"model_type": "gemma4_vision_model",
"num_hidden_layers": vision_encoder.num_layers,
"num_attention_heads": vision_encoder.num_heads,
"num_key_value_heads": vision_encoder.num_key_value_heads,
"hidden_size": vision_encoder.hidden_dim,
"intermediate_size": vision_encoder.intermediate_dim,
"head_dim": vision_encoder.head_dim,
"patch_size": vision_encoder.patch_size,
"pooling_kernel_size": vision_encoder.pool_size,
"position_embedding_size": image_encoder.patch_embedder.position_embedding_table.shape[1],
"rms_norm_eps": vision_encoder.layer_norm_epsilon,
"use_clipped_linears": vision_encoder.use_clipped_linears,
"standardize": vision_encoder.standardize,
"rope_parameters": {
"rope_theta": vision_encoder.rope_max_wavelength,
},
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In _build_vision_config, the code retrieves the internal image_encoder layer and accesses its private attributes (image_encoder.patch_embedder.position_embedding_table.shape[1]) to get the position embedding size. However, Gemma4VisionEncoder already exposes position_embedding_size as a direct attribute (self.position_embedding_size). Accessing it directly is cleaner, safer, and avoids deep attribute traversal.

def _build_vision_config(vision_encoder):
    """Build the ``vision_config`` dict from a Gemma4VisionEncoder."""
    return {
        "model_type": "gemma4_vision_model",
        "num_hidden_layers": vision_encoder.num_layers,
        "num_attention_heads": vision_encoder.num_heads,
        "num_key_value_heads": vision_encoder.num_key_value_heads,
        "hidden_size": vision_encoder.hidden_dim,
        "intermediate_size": vision_encoder.intermediate_dim,
        "head_dim": vision_encoder.head_dim,
        "patch_size": vision_encoder.patch_size,
        "pooling_kernel_size": vision_encoder.pool_size,
        "position_embedding_size": vision_encoder.position_embedding_size,
        "rms_norm_eps": vision_encoder.layer_norm_epsilon,
        "use_clipped_linears": vision_encoder.use_clipped_linears,
        "standardize": vision_encoder.standardize,
        "rope_parameters": {
            "rope_theta": vision_encoder.rope_max_wavelength,
        },
    }

Comment on lines +112 to +134
def _build_audio_config(audio_encoder):
"""Build the ``audio_config`` dict from a Gemma4AudioEncoder."""
# Retrieve parameters from the first conformer block.
block0 = audio_encoder.conformer_blocks[0]
attn = block0.attention.attn
sscp = audio_encoder.subsample_conv_projection
return {
"model_type": "gemma4_audio_model",
"hidden_size": audio_encoder.hidden_size,
"num_attention_heads": audio_encoder.num_heads,
"num_hidden_layers": audio_encoder.num_layers,
"attention_chunk_size": attn.chunk_size,
"attention_context_left": attn.max_past_horizon + 1,
"attention_context_right": attn.max_future_horizon,
"attention_logit_cap": attn.logit_cap,
"attention_invalid_logits_value": attn.invalid_logit_value,
"conv_kernel_size": block0.lconv.kernel_size,
"residual_weight": block0.ffw_start.residual_weight,
"gradient_clipping": block0.attention.gradient_clipping,
"subsampling_conv_channels": list(sscp.conv_channels),
"output_proj_dims": audio_encoder.output_proj_dims,
"rms_norm_eps": block0.norm.epsilon,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In _build_audio_config, the code accesses private/internal layers of audio_encoder (such as audio_encoder.conformer_blocks[0].attention.attn and audio_encoder.subsample_conv_projection) to retrieve configuration parameters. However, Gemma4AudioEncoder already exposes almost all of these properties directly as attributes on the class instance (e.g., chunk_size, context_left, context_right, logit_cap, invalid_logit_value, conv_kernel_size, residual_weight, gradient_clipping, sscp_conv_channels, norm_eps). Accessing these attributes directly is much cleaner, more robust, and respects the encapsulation of the class.

def _build_audio_config(audio_encoder):
    """Build the ``audio_config`` dict from a Gemma4AudioEncoder."""
    return {
        "model_type": "gemma4_audio_model",
        "hidden_size": audio_encoder.hidden_size,
        "num_attention_heads": audio_encoder.num_heads,
        "num_hidden_layers": audio_encoder.num_layers,
        "attention_chunk_size": audio_encoder.chunk_size,
        "attention_context_left": audio_encoder.context_left,
        "attention_context_right": audio_encoder.context_right,
        "attention_logit_cap": audio_encoder.logit_cap,
        "attention_invalid_logits_value": audio_encoder.invalid_logit_value,
        "conv_kernel_size": audio_encoder.conv_kernel_size,
        "residual_weight": audio_encoder.residual_weight,
        "gradient_clipping": audio_encoder.gradient_clipping,
        "subsampling_conv_channels": list(audio_encoder.sscp_conv_channels),
        "output_proj_dims": audio_encoder.output_proj_dims,
        "rms_norm_eps": audio_encoder.norm_eps,
    }

Comment on lines +18 to +21
from transformers import AutoConfig
from transformers import AutoModel
from transformers import AutoModelForCausalLM
from transformers import AutoTokenizer

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The imports AutoConfig, AutoModel, and AutoTokenizer from transformers are imported but never used anywhere in gemma4_test.py. Only AutoModelForCausalLM is used. Removing these unused imports will clean up the code and improve maintainability.

Suggested change
from transformers import AutoConfig
from transformers import AutoModel
from transformers import AutoModelForCausalLM
from transformers import AutoTokenizer
from transformers import AutoModelForCausalLM

Comment on lines +467 to +483
try:
from safetensors import safe_open
st_total = 0
with safe_open(st_path, framework="pt") as f:
st_keys = sorted(f.keys())
for k in st_keys:
st_total += f.get_tensor(k).numel()
keras_params = model.count_params()
count_match = st_total == keras_params
print(
f" {'✓' if count_match else '✗'} Parameter count: "
f"Keras={keras_params:,}, safetensors={st_total:,}"
)
all_ok = all_ok and count_match
except Exception as st_exc:
print(f" ✗ Safetensors inspection failed: {st_exc}")
all_ok = False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In run_structural_smoke_test, the variable st_keys is defined inside the try block on line 471. If an exception occurs during the safetensors inspection (e.g., if safetensors is not installed or safe_open fails), the except block on line 481 will execute, but the script will continue to the next try block on line 485. In that block, missing_from_hf = [k for k in st_keys if k not in hf_sd_keys] is executed, which will raise a NameError: name 'st_keys' is not defined because st_keys was never initialized. To prevent this secondary crash, initialize st_keys = [] before the first try block.

Suggested change
try:
from safetensors import safe_open
st_total = 0
with safe_open(st_path, framework="pt") as f:
st_keys = sorted(f.keys())
for k in st_keys:
st_total += f.get_tensor(k).numel()
keras_params = model.count_params()
count_match = st_total == keras_params
print(
f" {'✓' if count_match else '✗'} Parameter count: "
f"Keras={keras_params:,}, safetensors={st_total:,}"
)
all_ok = all_ok and count_match
except Exception as st_exc:
print(f" ✗ Safetensors inspection failed: {st_exc}")
all_ok = False
st_keys = []
try:
from safetensors import safe_open
st_total = 0
with safe_open(st_path, framework="pt") as f:
st_keys = sorted(f.keys())
for k in st_keys:
st_total += f.get_tensor(k).numel()
keras_params = model.count_params()
count_match = st_total == keras_params
print(
f" ✓ Parameter count: "
f"Keras={keras_params:,}, safetensors={st_total:,}"
)
all_ok = all_ok and count_match
except Exception as st_exc:
print(f" ✗ Safetensors inspection failed: {st_exc}")
all_ok = False

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Gemma Gemma model specific issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant