From 23e3d25d226324eb0abd7f99fa8c066f7fed43da Mon Sep 17 00:00:00 2001 From: Ziyang Guo <121015044+RerankerGuo@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:11:34 +0800 Subject: [PATCH] fix(model): rewrite seq_cls architectures to *ForSequenceClassification (#9704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When fine-tuning a VLM or LM with task_type=seq_cls (or reranker), the seq_cls patcher monkey-patches a 'score' head onto the generation model class without swapping the class itself. transformers' PreTrainedModel.save_pretrained writes 'model.__class__.__name__' into config.json['architectures'], so the on-disk checkpoint advertises the generation architecture (e.g. Qwen3VLForConditionalGeneration) while shipping a score head, num_labels, id2label, and problem_type. Downstream vLLM deployment reads architectures to pick the model class, finds the generation class, and rejects the checkpoint — even though inference through PtEngine works correctly. The fix rewrites model.config.architectures in-place to the matching *ForSequenceClassification class at the same place the score head is attached, so every save path (trainer, save_checkpoint, export, peft merge) writes the right value. A new helper _seq_cls_architectures() handles the suffix rewrite idempotently and leaves unknown/custom architectures untouched. Unit tests cover the rewrite, idempotence, multi-arch lists, and empty/None inputs. Refs: https://github.com/modelscope/ms-swift/issues/9704 --- swift/model/patcher.py | 29 +++++++++++++++++++++++ tests/general/test_model.py | 47 +++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/swift/model/patcher.py b/swift/model/patcher.py index 57bd6ac4d7..57e88e2ed2 100644 --- a/swift/model/patcher.py +++ b/swift/model/patcher.py @@ -249,6 +249,29 @@ def transformers_seq_cls_forward(self, *args, origin_forward, padding_side=None, ) +def _seq_cls_architectures(arch_list): + """Rewrite architectures to the matching ``*ForSequenceClassification`` class. + + Used by :func:`_patch_sequence_classification` so that the on-disk + ``config.json`` produced by ``PreTrainedModel.save_pretrained`` references + the seq_cls architecture (e.g. ``Qwen3VLForSequenceClassification``) instead + of the generation architecture that the model class is actually an instance + of. Without this, downstream vLLM deployment fails because the checkpoint + advertises ``Qwen3VLForConditionalGeneration`` while shipping a + ``score`` head and ``num_labels`` / ``problem_type`` fields — see #9704. + """ + if not arch_list: + return arch_list + res = [] + for arch in arch_list: + if arch.endswith('ForConditionalGeneration'): + arch = arch[:-len('ForConditionalGeneration')] + 'ForSequenceClassification' + elif arch.endswith('ForCausalLM'): + arch = arch[:-len('ForCausalLM')] + 'ForSequenceClassification' + res.append(arch) + return res + + def _patch_sequence_classification(model, model_meta): hidden_size = HfConfigFactory.get_config_attr(model.config, 'hidden_size') initializer_range = HfConfigFactory.get_config_attr(model.config, 'initializer_range') @@ -274,6 +297,12 @@ def new_forward(self, *args, **kwargs): lm_head_model.forward = MethodType(new_forward, lm_head_model) + # Align the on-disk `architectures` with the task. PreTrainedModel.save_pretrained + # writes `model.__class__.__name__` for `architectures`, but the seq_cls patcher + # monkey-patches a `score` head onto the generation class without swapping it, + # so the saved checkpoint would otherwise advertise the wrong class (see #9704). + model.config.architectures = _seq_cls_architectures(getattr(model.config, 'architectures', None)) + @contextmanager def patch_automodel_for_sequence_classification(model_info=None, diff --git a/tests/general/test_model.py b/tests/general/test_model.py index 89825b597f..08b1e1977d 100644 --- a/tests/general/test_model.py +++ b/tests/general/test_model.py @@ -48,6 +48,53 @@ def test_registration(self): self.assertIn(TemplateType.molmo2, TEMPLATE_MAPPING) +class TestSeqClsArchitecturesRewrite(unittest.TestCase): + """Regression coverage for #9704. + + The seq_cls / reranker patcher monkey-patches a ``score`` head onto the + generation model class without swapping the class itself, so + ``PreTrainedModel.save_pretrained`` would otherwise write the wrong + ``architectures`` to ``config.json`` and break downstream vLLM deployment. + """ + + def test_seq_cls_architectures_rewrite(self): + from swift.model.patcher import _seq_cls_architectures + + # Generation -> SequenceClassification + self.assertEqual(_seq_cls_architectures(['Qwen2ForCausalLM']), ['Qwen2ForSequenceClassification']) + self.assertEqual( + _seq_cls_architectures(['Qwen3VLForConditionalGeneration']), + ['Qwen3VLForSequenceClassification']) + self.assertEqual(_seq_cls_architectures(['LlamaForCausalLM']), ['LlamaForSequenceClassification']) + self.assertEqual( + _seq_cls_architectures(['Qwen2VLForConditionalGeneration']), + ['Qwen2VLForSequenceClassification']) + + # Already-seq_cls class is preserved (idempotent). + self.assertEqual( + _seq_cls_architectures(['BertForSequenceClassification']), + ['BertForSequenceClassification']) + + # Multi-arch list: only the matching suffix is rewritten. + self.assertEqual( + _seq_cls_architectures(['FooForCausalLM', 'BarForConditionalGeneration']), + ['FooForSequenceClassification', 'BarForSequenceClassification']) + + # Empty / None inputs are returned unchanged. + self.assertEqual(_seq_cls_architectures([]), []) + self.assertIsNone(_seq_cls_architectures(None)) + + def test_seq_cls_architectures_unknown_suffix(self): + """Unknown suffixes (custom architectures) are left alone — we don't + silently invent a class name that may not exist in transformers.""" + from swift.model.patcher import _seq_cls_architectures + + self.assertEqual(_seq_cls_architectures(['MyCustomLMHeadModel']), ['MyCustomLMHeadModel']) + self.assertEqual( + _seq_cls_architectures(['MyCustomLMHeadModel', 'LlamaForCausalLM']), + ['MyCustomLMHeadModel', 'LlamaForSequenceClassification']) + + if __name__ == '__main__': test_qwen2() # test_modelscope_hub()