Skip to content

Commit 9eb5800

Browse files
fix(vl): strip model.visual.* in qwen2_vl/qwen3_vl/qwen3_vl_moe sanitize
HuggingFace ForConditionalGeneration VL checkpoints (Qwen3_5MoeForConditionalGeneration, Qwen3VLForConditionalGeneration, Qwen2VLForConditionalGeneration) nest the language model and vision tower under a top-level 'model' dict: - model.language_model.* (language model weights) - model.visual.* (vision tower weights) The existing sanitize() helpers only popped bare 'visual'/'vision_tower' top-level keys, which matches the mlx-vlm conversion layout but not the HF layout. On HF checkpoints: - qwen3_vl_moe.sanitize crashed with KeyError('model') because weights['language_model']['model'] doesn't exist (the language model is nested under model.language_model.model.*). - qwen3_vl and qwen2_vl leaked model.visual.* into the sanitized weights, re-prefixed as language_model.model.visual.* — causing 'parameters not in model' errors downstream. Fix: after tree_unflatten, pop the top-level 'model' dict, re-root its 'language_model' to the top level, and drop its 'visual'/'vision_tower' subtrees. Verified on deepreinforce-ai/Ornith-1.0-35B (333 model.visual.* tensors, full vision_config): the grafted checkpoint now loads without the '768 parameters not in model' error that blocked it before. Tests: TestVLSanitize covers all three model classes against the HF ForConditionalGeneration layout — each must (a) not crash, (b) leave zero visual/vision_tower keys, (c) preserve the language model tensors.
1 parent 2ed2231 commit 9eb5800

4 files changed

Lines changed: 159 additions & 2 deletions

File tree

mlx_lm/models/qwen2_vl.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ def sanitize(self, weights):
4444
weights = tree_unflatten(list(weights.items()))
4545
weights.pop("visual", None)
4646
weights.pop("vision_tower", None)
47+
# HF ForConditionalGeneration nests the language model under
48+
# ``model.language_model.*`` and vision under ``model.visual.*``.
49+
nested = weights.pop("model", None)
50+
if isinstance(nested, dict):
51+
if isinstance(nested.get("language_model"), dict):
52+
weights["language_model"] = nested["language_model"]
4753
weights = dict(tree_flatten(weights))
4854

4955
sanitized = {}

mlx_lm/models/qwen3_vl.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ def __call__(
4343
def sanitize(self, weights):
4444
weights = tree_unflatten(list(weights.items()))
4545
weights.pop("vision_tower", None)
46+
# HF ForConditionalGeneration nests the language model under
47+
# ``model.language_model.*`` and vision under ``model.visual.*``.
48+
nested = weights.pop("model", None)
49+
if isinstance(nested, dict):
50+
if isinstance(nested.get("language_model"), dict):
51+
weights["language_model"] = nested["language_model"]
4652
weights = dict(tree_flatten(weights))
4753

4854
sanitized = {}

mlx_lm/models/qwen3_vl_moe.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,20 @@ def __call__(
3939
def sanitize(self, weights):
4040
weights = tree_unflatten(list(weights.items()))
4141
weights.pop("visual", None)
42+
# HF ForConditionalGeneration nests the language model under
43+
# ``model.language_model.*`` and vision under ``model.visual.*``.
44+
nested = weights.pop("model", None)
45+
if isinstance(nested, dict):
46+
if isinstance(nested.get("language_model"), dict):
47+
weights["language_model"] = nested["language_model"]
48+
49+
language_model = weights["language_model"]
4250
weights = dict(
4351
tree_flatten(
4452
{
4553
"language_model": {
46-
"model": weights["language_model"]["model"],
47-
"lm_head": weights["language_model"]["lm_head"],
54+
"model": language_model["model"],
55+
"lm_head": language_model["lm_head"],
4856
}
4957
}
5058
)

tests/test_models.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3221,5 +3221,142 @@ def test_gated_delta_masked(self):
32213221
self.assertTrue(mx.allclose(st, st_gt, rtol=1e-4, atol=1e-3))
32223222

32233223

3224+
class TestVLSanitize(unittest.TestCase):
3225+
"""Vision-language model sanitize() must strip the vision tower for the
3226+
HuggingFace ForConditionalGeneration checkpoint layout, where vision
3227+
weights ship under ``model.visual.*`` (or ``model.vision_tower.*``) and the
3228+
language model under ``model.language_model.*`` — not the bare
3229+
``visual`` / ``vision_tower`` / ``language_model.*`` prefixes the sanitize
3230+
helpers were written against.
3231+
"""
3232+
3233+
# HF Qwen3_5MoeForConditionalGeneration layout (Ornith-1.0, etc.).
3234+
HF_VL_WEIGHTS = {
3235+
"model.visual.pos_embed.weight": None,
3236+
"model.visual.blocks.0.attn.qkv.weight": None,
3237+
"model.visual.merger.norm.weight": None,
3238+
"model.language_model.model.embed_tokens.weight": None,
3239+
"model.language_model.model.layers.0.self_attn.q_proj.weight": None,
3240+
"model.language_model.model.layers.0.mlp.experts.gate_up_proj.weight": None,
3241+
"model.language_model.model.layers.0.mlp.experts.down_proj.weight": None,
3242+
"model.language_model.lm_head.weight": None,
3243+
"language_model.lm_head.weight": None,
3244+
}
3245+
3246+
def _qwen3_moe_text_config(self):
3247+
return {
3248+
"model_type": "qwen3_moe",
3249+
"num_hidden_layers": 1,
3250+
"num_experts": 4,
3251+
"num_experts_per_tok": 2,
3252+
"hidden_size": 32,
3253+
"intermediate_size": 64,
3254+
"moe_intermediate_size": 16,
3255+
"num_attention_heads": 4,
3256+
"num_key_value_heads": 2,
3257+
"head_dim": 8,
3258+
"vocab_size": 100,
3259+
"rms_norm_eps": 1e-6,
3260+
"full_attention_interval": 4,
3261+
"linear_num_value_heads": 4,
3262+
"linear_num_key_heads": 2,
3263+
"linear_key_head_dim": 8,
3264+
"linear_value_head_dim": 8,
3265+
"linear_conv_kernel_dim": 4,
3266+
"tie_word_embeddings": False,
3267+
"decoder_sparse_step": 1,
3268+
"mlp_only_layers": [],
3269+
"rope_theta": 100000.0,
3270+
"max_position_embeddings": 4096,
3271+
"norm_topk_prob": True,
3272+
}
3273+
3274+
def _qwen2_text_config(self):
3275+
return {
3276+
"model_type": "qwen2",
3277+
"num_hidden_layers": 1,
3278+
"hidden_size": 32,
3279+
"intermediate_size": 64,
3280+
"num_attention_heads": 4,
3281+
"num_key_value_heads": 2,
3282+
"head_dim": 8,
3283+
"vocab_size": 100,
3284+
"rms_norm_eps": 1e-6,
3285+
"tie_word_embeddings": False,
3286+
"rope_theta": 100000.0,
3287+
"max_position_embeddings": 4096,
3288+
}
3289+
3290+
def _qwen3_text_config(self):
3291+
return {
3292+
"model_type": "qwen3",
3293+
"num_hidden_layers": 1,
3294+
"hidden_size": 32,
3295+
"intermediate_size": 64,
3296+
"num_attention_heads": 4,
3297+
"num_key_value_heads": 2,
3298+
"head_dim": 8,
3299+
"vocab_size": 100,
3300+
"rms_norm_eps": 1e-6,
3301+
"tie_word_embeddings": False,
3302+
"rope_theta": 100000.0,
3303+
"max_position_embeddings": 4096,
3304+
}
3305+
3306+
def test_qwen3_vl_moe_strips_model_visual_prefix(self):
3307+
from mlx_lm.models.qwen3_vl_moe import Model, ModelArgs
3308+
3309+
args = ModelArgs(
3310+
model_type="qwen3_vl_moe",
3311+
text_config=self._qwen3_moe_text_config(),
3312+
)
3313+
model = Model(args)
3314+
result = model.sanitize(dict(self.HF_VL_WEIGHTS))
3315+
3316+
visual = [k for k in result if "visual" in k or "vision_tower" in k]
3317+
self.assertEqual(
3318+
visual, [], f"vision keys leaked into sanitized weights: {visual}"
3319+
)
3320+
# The language model tensors must survive, re-rooted under language_model.*.
3321+
self.assertIn(
3322+
"language_model.model.layers.0.self_attn.q_proj.weight",
3323+
result,
3324+
)
3325+
3326+
def test_qwen3_vl_strips_model_visual_prefix(self):
3327+
from mlx_lm.models.qwen3_vl import Model, ModelArgs
3328+
3329+
args = ModelArgs(
3330+
model_type="qwen3_vl",
3331+
text_config=self._qwen3_text_config(),
3332+
)
3333+
model = Model(args)
3334+
result = model.sanitize(dict(self.HF_VL_WEIGHTS))
3335+
3336+
visual = [k for k in result if "visual" in k or "vision_tower" in k]
3337+
self.assertEqual(
3338+
visual, [], f"vision keys leaked into sanitized weights: {visual}"
3339+
)
3340+
self.assertIn(
3341+
"language_model.model.layers.0.self_attn.q_proj.weight",
3342+
result,
3343+
)
3344+
3345+
def test_qwen2_vl_strips_model_visual_prefix(self):
3346+
from mlx_lm.models.qwen2_vl import Model, ModelArgs
3347+
3348+
args = ModelArgs(
3349+
model_type="qwen2_vl",
3350+
text_config=self._qwen2_text_config(),
3351+
)
3352+
model = Model(args)
3353+
result = model.sanitize(dict(self.HF_VL_WEIGHTS))
3354+
3355+
visual = [k for k in result if "visual" in k or "vision_tower" in k]
3356+
self.assertEqual(
3357+
visual, [], f"vision keys leaked into sanitized weights: {visual}"
3358+
)
3359+
3360+
32243361
if __name__ == "__main__":
32253362
unittest.main()

0 commit comments

Comments
 (0)