File size: 10,101 Bytes
78cb49e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
"""Shared model/config loading for train_sft.py, train_dpo.py, train_kto.py."""
from __future__ import annotations

import os
from pathlib import Path
from typing import Any

import torch
import yaml
from peft import LoraConfig
from transformers import AutoModelForImageTextToText, AutoProcessor, BitsAndBytesConfig

# All linear layers of the language-model backbone (attention proj + MLP).
# QLoRA (Dettmers et al., 2305.14314) found this — not rank — is the critical
# LoRA hyperparameter: "LoRA on all linear transformer block layers are
# required to match full finetuning performance... the projection dimension r
# does not affect performance." LoRA (Hu et al., 2106.09685) independently
# found spreading a fixed budget across more matrix types beats concentrating
# it in fewer at higher rank. Vision-tower/projector linears deliberately left
# out — adapting only the language backbone is the cheaper, lower-forgetting-
# risk default; revisit if the vision side turns out to be the bottleneck.
DEFAULT_LORA_TARGET_MODULES = [
    "q_proj", "k_proj", "v_proj", "o_proj",
    "gate_proj", "up_proj", "down_proj",
]


def load_config(path: str) -> dict[str, Any]:
    with open(path) as f:
        return yaml.safe_load(f)


def build_quantization_config(cfg: dict) -> BitsAndBytesConfig | None:
    if not cfg.get("qlora", {}).get("enabled", False):
        return None
    q = cfg["qlora"]
    return BitsAndBytesConfig(
        load_in_4bit=True,
        bnb_4bit_quant_type=q.get("quant_type", "nf4"),
        bnb_4bit_compute_dtype=getattr(torch, q.get("compute_dtype", "bfloat16")),
        bnb_4bit_use_double_quant=q.get("double_quant", True),
    )


def build_lora_config(cfg: dict) -> LoraConfig:
    l = cfg["lora"]
    # NOTE: `l.get("target_modules", DEFAULT)` would NOT apply the default when
    # the YAML explicitly sets `target_modules: null` (every configs/*.yaml
    # does) — a present key with value None defeats dict.get's default arg,
    # it only falls back on a MISSING key. `or` catches both missing-and-None.
    # Getting this wrong would silently pass target_modules=None into
    # LoraConfig, which makes PEFT try to auto-detect target modules from a
    # model-type table that has no entry for this checkpoint's "qwen3_5"
    # architecture — almost certainly a hard failure, not a quiet fallback.
    target_modules = l.get("target_modules") or DEFAULT_LORA_TARGET_MODULES
    return LoraConfig(
        r=l.get("r", 16),
        lora_alpha=l.get("alpha", 32),
        lora_dropout=l.get("dropout", 0.05),
        target_modules=target_modules,
        bias="none",
        task_type="CAUSAL_LM",
    )


def load_model_and_processor(cfg: dict):
    model_id = cfg["model"]["base_model"]
    revision = cfg["model"].get("revision")
    quant_config = build_quantization_config(cfg)
    # "sdpa" ships in PyTorch itself — no extra install, no CUDA/torch-version-
    # matched wheel to get wrong. "flash_attention_2" is faster but requires
    # the flash-attn package (NOT in requirements.txt — it needs a build step
    # matched to your exact torch/CUDA version, easy to get wrong on a fresh
    # instance) — opt into it explicitly per-config once flash-attn is
    # confirmed installed, don't make it the default something can silently
    # depend on.
    attn_implementation = cfg["model"].get("attn_implementation", "sdpa")

    processor = AutoProcessor.from_pretrained(
        model_id, revision=revision, trust_remote_code=True,
    )
    model_kwargs = dict(
        revision=revision,
        trust_remote_code=True,
        torch_dtype=torch.bfloat16,
        quantization_config=quant_config,
        device_map="auto",
        attn_implementation=attn_implementation,
    )
    try:
        model = AutoModelForImageTextToText.from_pretrained(model_id, **model_kwargs)
    except (ValueError, KeyError) as e:
        # This checkpoint family's HF model card metadata reports
        # transformersInfo.auto_model = "AutoModelForMultimodalLM" — not a
        # standard transformers auto-class name as of when this was written,
        # possibly custom-code-registered via trust_remote_code. If the
        # standard VLM auto-class doesn't recognize the architecture, fall
        # back to the fully generic AutoModel, which resolves purely through
        # the checkpoint's own registered config/model classes and doesn't
        # care what auto-class name shows up in unrelated HF metadata.
        print(
            f"AutoModelForImageTextToText failed ({e!r}) — retrying with generic "
            f"AutoModel (trust_remote_code). See README.md's pre-flight checklist."
        )
        from transformers import AutoModel
        model = AutoModel.from_pretrained(model_id, **model_kwargs)
    return model, processor


def build_vlm_sft_collator(processor):
    """Custom data collator for multimodal SFT.

    Required, not optional: SFTTrainer's default dataset-preparation pipeline
    assumes text-only tokenization/packing and will mishandle (or error on)
    an "images" column — this is why train_sft.py pairs this collator with
    `dataset_kwargs={"skip_prepare_dataset": True}`. DPO/KTO/SimPO's trainers
    are NOT given an equivalent custom collator here — TRL's own VLM examples
    for those trainers consume prompt/chosen/rejected/completion + images
    columns directly via `processing_class` without one; SFT is the one that
    actually needs this. See README.md's pre-flight checklist — this collator
    was written against the general HF VLM processor contract
    (apply_chat_template + images kwarg), not run against this specific
    "qwen3_5" architecture's actual processor.
    """
    tokenizer = getattr(processor, "tokenizer", processor)

    def collate(examples: list[dict]) -> dict:
        texts = []
        images = []
        prompt_lengths = []
        for ex in examples:
            full_text = processor.apply_chat_template(ex["prompt"] + ex["completion"], tokenize=False)
            texts.append(full_text)
            images.append(ex["images"])
            prompt_text = processor.apply_chat_template(
                ex["prompt"], tokenize=False, add_generation_prompt=True
            )
            prompt_lengths.append(len(tokenizer(prompt_text, add_special_tokens=False)["input_ids"]))

        batch = processor(text=texts, images=images, return_tensors="pt", padding=True)
        labels = batch["input_ids"].clone()
        for i, plen in enumerate(prompt_lengths):
            labels[i, :plen] = -100
        if tokenizer.pad_token_id is not None:
            labels[batch["input_ids"] == tokenizer.pad_token_id] = -100
        batch["labels"] = labels
        return batch

    return collate


def init_wandb(cfg: dict, run_name: str) -> None:
    """TRL's *Trainer classes report to wandb automatically when
    report_to=["wandb"] is set in the *Config and WANDB_API_KEY/WANDB_PROJECT
    are in the environment — this just makes the project/run naming explicit
    and fails fast (before an expensive model load) if the key is missing."""
    if "wandb" not in (cfg.get("training", {}).get("report_to") or []):
        return
    if not os.environ.get("WANDB_API_KEY"):
        raise RuntimeError(
            "training.report_to includes 'wandb' but WANDB_API_KEY is not set. "
            "Run `wandb login` or `export WANDB_API_KEY=...` before launching, "
            "or remove 'wandb' from the config to train without it."
        )
    os.environ.setdefault("WANDB_PROJECT", cfg.get("wandb", {}).get("project", "404-coder-training"))
    os.environ.setdefault("WANDB_RUN_GROUP", cfg.get("wandb", {}).get("group", run_name))


def resolve_output_dir(cfg: dict, default: str) -> str:
    out = cfg.get("training", {}).get("output_dir", default)
    Path(out).mkdir(parents=True, exist_ok=True)
    return out


# --- Optional Muon–AdamW hybrid optimizer (opt-in via `optim: "muon_adamw"`) ---
# See muon.py for the what/why and the honest caveat (naive Muon-on-LoRA is a
# heuristic). Kept off the default path — configs ship paged_adamw_8bit — so
# nothing here runs unless a config explicitly asks for it.

def resolve_optim(cfg: dict) -> tuple[str, bool]:
    """Return (hf_optim_string, use_muon). If the config asks for our
    "muon_adamw" extension, we hand HF a harmless valid optim string (its
    optimizer is then replaced by our create_optimizer override — see
    make_muon_trainer) because "muon_adamw" is NOT a valid HF OptimizerNames
    enum and would fail TrainingArguments validation if passed through."""
    o = cfg.get("training", {}).get("optim", "adamw_torch")
    if o == "muon_adamw":
        return "adamw_torch", True
    return o, False


def build_muon_config(cfg: dict) -> dict:
    """The `training.muon` block, with Muon's lr defaulting to the run's base
    learning_rate (LoRA makes Muon robust to a suboptimal lr — arXiv 2605.10468
    — so reusing the Adam lr is the safe default; raise training.muon.lr toward
    Muon's native ~0.01–0.02 once a baseline works)."""
    t = cfg.get("training", {})
    muon = dict(t.get("muon") or {})
    muon.setdefault("lr", t.get("learning_rate", 1e-4))
    return muon


def make_muon_trainer(base_cls):
    """Subclass a TRL/HF trainer so create_optimizer builds the Muon–AdamW
    hybrid from the *already-PEFT-wrapped* self.model. Set `.muon_config` on
    the instance before .train(); if unset, behaves exactly like the base
    trainer. Works uniformly whether PEFT wrapping happened inside the trainer
    (SFT) or before it (KTO with an sft_adapter)."""
    class _MuonTrainer(base_cls):
        muon_config: dict | None = None

        def create_optimizer(self):
            if self.optimizer is None and self.muon_config:
                from muon import build_muon_adamw_optimizer
                self.optimizer = build_muon_adamw_optimizer(self.model, self.muon_config)
            return super().create_optimizer()  # returns self.optimizer once set
    _MuonTrainer.__name__ = f"Muon{base_cls.__name__}"
    return _MuonTrainer