Skip to content

Improve nested base_model_prefix handling in weight conversion and loading - #45421

Closed
yonigozlan wants to merge 5 commits into
huggingface:mainfrom
yonigozlan:support-nested-prefixes
Closed

yonigozlan wants to merge 5 commits into
huggingface:mainfrom
yonigozlan:support-nested-prefixes

Conversation

@yonigozlan

Copy link
Copy Markdown
Contributor

Split off from the RF-DETR PR (#36895). These are changes to core_model_loading.py and test_modeling_common.py that are needed to load and save RF-DETR checkpoints correctly and in the same format as the original roboflow checkpoints, but they fix pre-existing issues in transformers that affect any doubly-nested model.

Why RF-DETR needs this

RfDetrForInstanceSegmentation has a two-level nesting: it wraps RfDetrForObjectDetection (as self.model) which wraps RfDetrModel (as self.model). The HF state dict therefore has keys under model.model.*. The conversion rules in conversion_mapping.py are written relative to RfDetrModel (bare keys, no prefix), matching Roboflow's checkpoint format. So if we the saved checkpoint to be compatible with roboflow original ones, we need to be able to add nested prefixes when loading, and strip nested prefixes when saving.

What was already broken

  • Loading with more than one prefix level: rename_source_key step 3 only tried a single model.base_model_prefix to add or remove. Loading a doubly-nested model correctly was not possible without hacks. That means for example we couldn't load weights from DetrModel in DetrModelForSegmentation, as we need to support adding nested prefixes for this to work.

  • Saving without prefixes: To have a more comprehensive support of source weights with the weight converter, this PR adds support for saving models without nesting, if the original checkpoints were flat.

Changes

  • _compute_all_prefixes(model): walks the base_model_prefix / submodel chain and returns all cumulative prefix paths, e.g. ["model"] for LlamaForCausalLM, ["model", "model.model"] for RfDetrForInstanceSegmentation. rename_source_key now takes this list instead of a single prefix string and tries each level (longest first) to add or strip when resolving a key against the model state dict.

  • _strip_model_prefix_for_save(key, model): recursively strips all prefix levels from a key before applying reverse conversion rules. Opt-in via _checkpoint_conversion_prefix_free = True on the model class, so it only fires when the source checkpoint genuinely has bare keys.

One other somewhat related fix:

  • test_reverse_loading_mapping: Now precomputes state-dict keys for all classes in all_model_classes before the loop. Instead of failing everytime a rule isn't used in all classes, it now asserts the rule matches at least one other class.

@yonigozlan
yonigozlan requested review from ArthurZucker, Cyrilvallez and vasqu and removed request for Cyrilvallez April 13, 2026 23:57
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

@vasqu

vasqu commented Apr 15, 2026 •

Copy link
Copy Markdown
Collaborator

@Cyrilvallez does #45448 also handle this case? I think we now have 2 cases with the prefix

  • Nested removals
  • Nested additions

Unsure about the current state and who is doing what

@github-actions

Copy link
Copy Markdown
Contributor

[For maintainers] Suggested jobs to run (before merge)

run-slow: conditional_detr, detr

Comment on lines +1067 to +1087
def _strip_model_prefix_for_save(key: str, model) -> str:
"""
Recursively strip all `base_model_prefix` segments from a state-dict key so that
reverse conversion rules (written relative to the innermost base model) operate on
bare keys regardless of nesting depth.

We identify each prefix level by finding the direct child whose `base_model_prefix`
matches its attribute name (same logic as `_compute_all_prefixes`).

Examples for `DetrForSegmentation` (prefix chain `detr` -> `model`):

"detr.model.backbone.x" -> "backbone.x"
"detr.class_labels_classifier.x" -> "class_labels_classifier.x"
"mask_head.x" -> "mask_head.x"
"""
for name, child in model.named_children():
child_prefix = getattr(child, "base_model_prefix", "")
if child_prefix and child_prefix == name and key.startswith(name + "."):
stripped_key = key[len(name) + 1 :]
return _strip_model_prefix_for_save(stripped_key, child)
return key

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This might be made redundant by #45448
The rest I think is somewhat orthogonal, could you confirm @Cyrilvallez ?

Comment on lines +1483 to +1497
# Opt in via `_checkpoint_conversion_prefix_free = True` when the source checkpoint is fully flat,
# so that all prefixes should be stripped before saving.
strip_prefix = getattr(model, "_checkpoint_conversion_prefix_free", False)

state_dict = sorted(state_dict.items(), key=lambda kv: dot_natural_key(kv[0]))
for original_key, tensor in state_dict:
# Rename the key according to all renaming pattern and optional weight converter patterns
renamed_key, source_pattern = rename_source_key(original_key, renamings, converters)
bare_key = _strip_model_prefix_for_save(original_key, model) if strip_prefix else original_key
renamed_key, source_pattern = rename_source_key(bare_key, renamings, converters)
if source_pattern is not None:
new_converter = deepcopy(pattern_to_converter[source_pattern])
# each target key gets its own converter instance
mapping = conversion_mapping.setdefault(renamed_key, new_converter)
else:
mapping = conversion_mapping.setdefault(renamed_key, WeightRenaming(original_key, renamed_key))
source_pattern = original_key
mapping = conversion_mapping.setdefault(renamed_key, WeightRenaming(bare_key, renamed_key))
source_pattern = bare_key

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

(This as well)

@Cyrilvallez Cyrilvallez left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hey @yonigozlan! Concerning this

Loading with more than one prefix level: rename_source_key step 3 only tried a single model.base_model_prefix to add or remove. Loading a doubly-nested model correctly was not possible without hacks. That means for example we couldn't load weights from DetrModel in DetrModelForSegmentation, as we need to support adding nested prefixes for this to work.

The base_model_prefix is not intended to be used for conversions! It's only there to allow loading a BaseModel from a ForXXX checkpoint, and the opposite, and find the embeddings correctly etc.
We should not use them to replace the conversion mappings.

Indeed, you should be able to use PrefixChange from #45448 to help you here I believe! Let me know if you need help/you encounter issues!

Comment on lines +1480 to +1481
base_model_prefix = "conditional_detr"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We cannot have that, the base model is named model

@yonigozlan yonigozlan Apr 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ConditionalDetrForObjectDetection is used as a "base model" in ConditionalDetrForSegmentation as self.detr, hence why we need to modify it here. The modifications in core_model_loading.py now fully works with loading/saving these kind of nested models

Comment on lines +1301 to +1302
base_model_prefix = "detr"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same here, DetrForObjectDetection is used as a "base_model" in DetrForSegmentation

Comment on lines -1058 to +1168
# 3. check if we need to add or remove prefix if necessary (only during loading, not saving)
if prefix is not None and meta_state_dict is not None:
if (
renamed_key.startswith(prefix)
and meta_state_dict.get(re.sub(f"^{prefix}.", "", renamed_key, count=1)) is not None
):
renamed_key = re.sub(f"^{prefix}.", "", renamed_key, count=1)
elif meta_state_dict.get(f"{prefix}.{renamed_key}") is not None:
renamed_key = f"{prefix}.{renamed_key}"
# 3. If the key is still not in the model state dict, try adding or removing each
# prefix level (longest first) until a match is found. Only active during loading.
if valid_prefixes is not None and meta_state_dict is not None and renamed_key not in meta_state_dict:
renamed_key = _resolve_key_for_prefix_nesting(renamed_key, valid_prefixes, meta_state_dict)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is only used to be able to load BaseModel from ForXXXModel and opposite. It's really not intended for anything else, this should be only the conversion mappings! Very important to not confuse both, as it will otherwise become very quickly unmaintanable

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes agreed! But we still need this to be able to support nested ForXXXModel, and load each level of weights in each level of nesting

@ArthurZucker ArthurZucker 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.

I agree with Cyril here sorry for the back and forth, I think we should NOT allow this in general its too big, too hard to maintain and you have too many cases to handle.

It will gracefully error out, let's add a ref to doc on how to load a model CLIP from a DETRForObjectDetection for example (manually pass prefix change for example) + just allow it for detr! 🤗

pass


def _compute_all_prefixes(model) -> list[str]:

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.

We don't need this AFAIK:
Upon __init__ of the parent DetrForSegmentation, it should have access to self.prefixes which can be obtain using the recursive nature of __init__. This prevent having to go over the model again, and is only run once at init time.

valid_prefixes: list[str],
meta_state_dict: dict,
) -> str:
"""

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.

longest first can be done with regex piping (shortex | short | sh) will go with longest first we don't need all this engineering no?

@ArthurZucker

Copy link
Copy Markdown
Collaborator

Closing per the discussion with @Cyrilvallez and @ArthurZucker: we don't want to support multi-level base_model_prefix remapping in general, and RF-DETR landed in #36895 without it. Thanks @yonigozlan!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants