Skip to content

Fix LoRA save/load to use stable layer paths (#2701)#2774

Open
ManojPawar2 wants to merge 1 commit into
keras-team:masterfrom
ManojPawar2:fix-lora-save-load-path-keys
Open

Fix LoRA save/load to use stable layer paths (#2701)#2774
ManojPawar2 wants to merge 1 commit into
keras-team:masterfrom
ManojPawar2:fix-lora-save-load-path-keys

Conversation

@ManojPawar2

Copy link
Copy Markdown

Summary

Fixes #2701. Backbone.save_lora_weights / load_lora_weights could restore LoRA adapters into the wrong layers when reloading into a freshly constructed model, which changed the model's outputs. The problem is reproducible on bert_base_en and is not model-specific.

Root cause

The save/load logic identified each LoRA-enabled layer by its integer position in the weight-filtered flattened layer list ([l for l in self._flatten_layers() if l.weights]). Layer names are non-unique, so indices were used as a workaround, but that position is not stable across model reconstructions: the ordering of layers-with-weights can differ between the model that saved the adapters and a freshly built model that loads them. When the ordering shifts, lora_kernel_a / lora_kernel_b are assigned to the wrong layers, and the reloaded model no longer matches the original (the rtol=1e-5, atol=1e-5 comparison fails).

Fix

Identify each layer by its layer.path, which is unique and stable across reconstructions of the same architecture:

  • save_lora_weights stores each adapter under lora/{layer.path}.
  • load_lora_weights looks each adapter up by layer.path, and falls back to the legacy integer-index key so .lora.h5 files written by earlier versions continue to load.

Tests

Added to keras_hub/src/models/backbone_test.py:

  • test_lora_save_and_reload: enables LoRA on a BERT backbone, assigns non-zero adapters, saves the LoRA weights, reloads them into a separately constructed backbone (different auto-generated name), and asserts the outputs match at rtol=1e-5, atol=1e-5, the exact scenario from the issue.
  • test_lora_weights_use_stable_path_keys: asserts the saved file is keyed by layer path rather than integer index, and that a legacy index-keyed file still loads.

All existing Gemma/Gemma3/Gemma4 LoRA fine-tuning and save/reload tests continue to pass.

@github-actions github-actions Bot added the Gemma Gemma model specific issues label Jun 23, 2026

@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 updates LoRA weight saving and loading to identify layers by their stable path attribute instead of their unstable integer index in a topologically sorted list, preventing issues when models are reconstructed. Backward compatibility is maintained for loading legacy integer-indexed weights, and comprehensive tests have been added. The reviewer recommends using a try-except KeyError block instead of directly checking the internal store.h5_file attribute to make the loading logic more robust.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +281 to +284
if f"lora/{layer.path}" in store.h5_file:
inner_store = store.get(f"lora/{layer.path}")
else:
inner_store = store.get(f"lora/{layer_index}")

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

Relying on the internal h5_file attribute of H5IOStore is fragile because it is an implementation detail of Keras's saving library that could change in future Keras releases. Instead of checking membership in store.h5_file directly, it is more robust and idiomatic to use a try-except KeyError block around store.get(). This adheres to the EAFP (Easier to Ask for Forgiveness than Permission) principle and only relies on the public store interface.

Suggested change
if f"lora/{layer.path}" in store.h5_file:
inner_store = store.get(f"lora/{layer.path}")
else:
inner_store = store.get(f"lora/{layer_index}")
try:
inner_store = store.get(f"lora/{layer.path}")
except KeyError:
inner_store = store.get(f"lora/{layer_index}")
References
  1. Demand Robustness: Do not accept fragile code. If the proposed code is not robust enough or lacks proper error handling, explicitly tell the author why the current approach is brittle and what must be done to reinforce it. (link)

`save_lora_weights` / `load_lora_weights` identified each LoRA layer by
its integer position in the weight-filtered, flattened layer list. That
position is not stable across model reconstructions, so reloading LoRA
weights into a freshly built model could place them on the wrong layers
and silently change the outputs (e.g. with `bert_base_en`).

Identify layers by their unique and stable `layer.path` instead. Loading
falls back to the legacy integer keys, so `.lora.h5` files written by
earlier versions still load. Added a round-trip test and a path-key
regression test to `backbone_test.py`.
@ManojPawar2 ManojPawar2 force-pushed the fix-lora-save-load-path-keys branch from 8cf4e86 to 786bd37 Compare June 24, 2026 05:39

@laxmareddyp laxmareddyp left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Left initial comments and address the gemini comment as it is valid comment.

Comment on lines +241 to +247
# We identify each layer by its `path` rather than by its position in
# the flattened layer list. Layer names are non-unique, and integer
# positions are not stable: the topologically sorted list of layers
# that have weights can be ordered differently when a model is rebuilt,
# which previously caused LoRA weights to be restored into the wrong
# layers (see #2701). A layer's `path` is both unique and stable across
# reconstructions of the same architecture.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: comment is a bit long ,trimming to 2-3 lines and pointing at #2701 for full context.

# so the factored weights are always named `kernel`
layer = all_layers[layer_index]
inner_store = store.make(f"lora/{layer_index}")
inner_store = store.make(f"lora/{layer.path}")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can you confirm you verified layer.path stability across different model types (BERT, Gemma, etc.)? we want to make sure paths don't include the model name and stay consistent across from_preset vs fresh construction

Comment on lines +105 to +123
legacy_path = os.path.join(self.get_temp_dir(), "legacy.lora.h5")
store = keras.src.saving.saving_lib.H5IOStore(legacy_path, mode="w")
lora_store = store.make("lora")
lora_store["rank"] = backbone._lora_rank
all_layers = [
lyr
for lyr in backbone._flatten_layers(include_self=False)
if lyr.weights
]
for layer_index in backbone._lora_enabled_layers:
layer = all_layers[layer_index]
inner_store = store.make(f"lora/{layer_index}")
inner_store["lora_kernel_a"] = layer.lora_kernel_a
inner_store["lora_kernel_b"] = layer.lora_kernel_b
store.close()

reloaded = BertBackbone(**init_kwargs)
reloaded.enable_lora(4)
reloaded.load_lora_weights(legacy_path) # must not raise

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

legacy compat test only checks "doesn't crash" worth adding non-zero adapter values and assertAllClose like the other test? with zero-init weights, even assertAllClose wouldn't catch wrong-layer assignment here..

def test_lora_save_and_reload(self):
# Regression test for #2701: saving LoRA weights and reloading them
# into a freshly constructed model must reproduce the same outputs.
init_kwargs = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: init_kwargs is repeated in both tests — can we pull it into setUp?

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LoRA save/load Bug

2 participants