File size: 9,242 Bytes
9fc2a4f
 
 
 
 
 
 
 
 
 
70b7602
 
9fc2a4f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179fc8f
9fc2a4f
 
 
 
 
179fc8f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9fc2a4f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e88cfe
 
9fc2a4f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179fc8f
 
 
 
 
9fc2a4f
 
 
179fc8f
9fc2a4f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e88cfe
9fc2a4f
9e88cfe
9fc2a4f
 
 
 
 
 
 
 
 
 
 
 
 
 
90154c0
 
 
 
 
9fc2a4f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
220
221
222
223
224
225
226
227
228
# /// script
# dependencies = [
#     "trl>=0.24.0",
#     "peft>=0.17.0",
#     "transformers>=5.14.0",
#     "datasets>=4.0",
#     "trackio",
#     "Pillow>=10.0",
#     "accelerate>=1.0",
#     "torch>=2.5",
#     "torchvision>=0.20",
#     "num2words",
# ]
# ///
"""LoRA SFT of Gemma 4 for GUI grounding + web-agent action prediction.

Trains on the flat dataset built by prep_data.py (image, system, user,
assistant, source). Each row becomes one multimodal chat example:

    system : the GUI-agent system prompt
    user   : [screenshot] + instruction + previous actions
    assistant (label): <think>…</think> <code>action(...)</code>

Only the assistant turn contributes to the loss (the prompt + image tokens are
masked to -100 in the collator). Adapters are pushed to the Hub.

Launch via HF Jobs (see gemma4/launch_train.sh) — never run untethered; the
Jobs box is ephemeral so push_to_hub must be on.
"""

import argparse

import torch
import torch.nn as nn
from datasets import load_dataset
from peft import LoraConfig
from transformers import AutoModelForImageTextToText, AutoProcessor
from trl import SFTConfig, SFTTrainer

PROJ_SUFFIXES = ("q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj")


def find_lora_targets(model):
    """FULL module names of the language-tower projection Linears.

    Gemma 4's vision tower wraps its projections in `Gemma4ClippableLinear`
    (not `nn.Linear`), which PEFT can't adapt, and matching by bare suffix (e.g.
    "q_proj") would also grab those and crash. So we walk the graph and return
    the *fully-qualified* names of the real `nn.Linear` projections under
    `language_model`. PEFT matches those exactly, leaving the vision encoder
    frozen and skipping MoE expert Parameters (which aren't Modules).
    """
    targets = []
    for name, module in model.named_modules():
        if not isinstance(module, nn.Linear):
            continue
        if "language_model" not in name:
            continue
        if name.split(".")[-1] in PROJ_SUFFIXES:
            targets.append(name)
    return targets


def build_collator(processor, model_config):
    """Render our (system/user/assistant + image) rows into a batch.

    Gemma 4 uses an image-text-to-text processor: we pass the chat template
    with an image placeholder in the user turn, tokenize, and mask padding plus
    every image-structural token (the soft image token + <start_of_image> /
    <end_of_image>) so loss is computed only over real text tokens. We read the
    exact ids from the model config so this can't drift with the tokenizer.
    """
    image_token_ids = {
        tid for tid in [
            getattr(model_config, "image_token_id", None),
            getattr(model_config, "boi_token_id", None),
            getattr(model_config, "eoi_token_id", None),
            getattr(processor, "image_token_id", None),
        ] if isinstance(tid, int)
    }
    print(f"[train] masking image/pad token ids: {sorted(image_token_ids)}")
    processor.tokenizer.padding_side = "right"  # so the prompt is a clean prefix

    def to_messages(example):
        # IMPORTANT: match inference (gemma4/server.py via mlx_vlm) EXACTLY —
        # one user turn holding [image] + "SYSTEM\n\nuser", then the assistant.
        # Gemma's chat template has no separate system role, and mlx_vlm wraps a
        # raw prompt as a single user turn, so we mirror that here to keep the
        # train and inference token streams identical.
        return [
            {"role": "user", "content": [
                {"type": "image"},
                {"type": "text", "text": f"{example['system']}\n\n{example['user']}"},
            ]},
            {"role": "assistant", "content": [{"type": "text", "text": example["assistant"]}]},
        ]

    def collate(examples):
        full_texts, prompt_texts, images = [], [], []
        for ex in examples:
            msgs = to_messages(ex)
            full_texts.append(
                processor.apply_chat_template(msgs, tokenize=False, add_generation_prompt=False)
            )
            # Prompt = everything up to (and including) the assistant header, so
            # its token length marks where the completion begins.
            prompt_texts.append(
                processor.apply_chat_template(msgs[:-1], tokenize=False, add_generation_prompt=True)
            )
            images.append([ex["image"].convert("RGB")])

        batch = processor(text=full_texts, images=images, return_tensors="pt", padding=True)
        # Second pass with the SAME images so the measured prompt length includes
        # the expanded soft-image tokens — the completion offset is then exact.
        prompt_batch = processor(text=prompt_texts, images=images, return_tensors="pt", padding=True)
        prompt_lens = prompt_batch["attention_mask"].sum(dim=1)

        labels = batch["input_ids"].clone()
        labels[batch["attention_mask"] == 0] = -100     # padding
        for tid in image_token_ids:
            labels[labels == tid] = -100                 # image structural tokens
        for i, plen in enumerate(prompt_lens):
            labels[i, : int(plen)] = -100                # prompt (assistant-only loss)
        batch["labels"] = labels
        return batch

    return collate


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--model", default="google/gemma-4-E4B-it")
    ap.add_argument("--dataset", required=True)
    ap.add_argument("--hub-model-id", required=True)
    ap.add_argument("--epochs", type=float, default=1.0)
    ap.add_argument("--batch-size", type=int, default=2)
    ap.add_argument("--grad-accum", type=int, default=8)
    ap.add_argument("--lr", type=float, default=2e-4)
    ap.add_argument("--max-steps", type=int, default=-1)
    ap.add_argument("--eval-frac", type=float, default=0.03)
    ap.add_argument("--eval-steps", type=int, default=50)
    ap.add_argument("--save-steps", type=int, default=100)
    ap.add_argument("--project", default="gemma4-gui-agent")
    ap.add_argument("--run-name", default="gemma4-e4b-lora")
    ap.add_argument("--private", action="store_true", default=True)
    args = ap.parse_args()

    print(f"[train] loading dataset {args.dataset}")
    ds = load_dataset(args.dataset, split="train")
    split = ds.train_test_split(test_size=args.eval_frac, seed=42)
    train_ds, eval_ds = split["train"], split["test"]
    print(f"[train] {len(train_ds)} train / {len(eval_ds)} eval")

    print(f"[train] loading {args.model}")
    processor = AutoProcessor.from_pretrained(args.model)
    model = AutoModelForImageTextToText.from_pretrained(
        args.model, dtype=torch.bfloat16, attn_implementation="eager",
    )

    # Adapt attention + MLP projections across the language tower; leave the
    # vision encoder frozen (grounding signal comes from the LM reading tokens).
    targets = find_lora_targets(model)
    print(f"[train] LoRA targets: {len(targets)} language-model Linear projections "
          f"(e.g. {targets[:2]})")
    if not targets:
        raise RuntimeError("no language_model projection Linears found for LoRA")
    peft_config = LoraConfig(
        r=16, lora_alpha=32, lora_dropout=0.05, bias="none",
        task_type="CAUSAL_LM",
        target_modules=targets,
        modules_to_save=None,
    )

    sft_config = SFTConfig(
        output_dir=args.hub_model_id.split("/")[-1],
        per_device_train_batch_size=args.batch_size,
        per_device_eval_batch_size=args.batch_size,
        gradient_accumulation_steps=args.grad_accum,
        num_train_epochs=args.epochs,
        max_steps=args.max_steps,
        learning_rate=args.lr,
        warmup_ratio=0.03,
        lr_scheduler_type="cosine",
        logging_steps=5,
        eval_strategy="steps",
        eval_steps=args.eval_steps,
        save_strategy="steps",
        save_steps=args.save_steps,
        save_total_limit=2,
        bf16=True,
        gradient_checkpointing=True,
        gradient_checkpointing_kwargs={"use_reentrant": False},
        dataset_kwargs={"skip_prepare_dataset": True},  # we collate raw rows
        remove_unused_columns=False,
        max_length=None,  # never truncate image tokens
        push_to_hub=True,
        hub_model_id=args.hub_model_id,
        hub_private_repo=args.private,
        hub_strategy="every_save",
        report_to="trackio",
        run_name=args.run_name,
        project=args.project,
        # A private model repo forces Trackio's per-push "static" space private
        # too, which it rejects (browser-only snapshots must be public) and that
        # crashes the Hub push. Disable the static snapshot; the live Trackio
        # dashboard during training still works.
        trackio_static_space_id=False,
    )

    trainer = SFTTrainer(
        model=model,
        args=sft_config,
        train_dataset=train_ds,
        eval_dataset=eval_ds,
        data_collator=build_collator(processor, model.config),
        peft_config=peft_config,
        processing_class=processor,
    )

    trainer.train()
    trainer.save_model(sft_config.output_dir)
    trainer.push_to_hub()
    print(f"[train] pushed adapters to {args.hub_model_id}")


if __name__ == "__main__":
    main()