khalidFlex commited on
Commit
9fc2a4f
·
verified ·
1 Parent(s): 572ea6f

Upload train_sft.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. train_sft.py +190 -0
train_sft.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # dependencies = [
3
+ # "trl>=0.24.0",
4
+ # "peft>=0.17.0",
5
+ # "transformers>=5.14.0",
6
+ # "datasets>=4.0",
7
+ # "trackio",
8
+ # "Pillow>=10.0",
9
+ # "accelerate>=1.0",
10
+ # "torch>=2.5",
11
+ # ]
12
+ # ///
13
+ """LoRA SFT of Gemma 4 for GUI grounding + web-agent action prediction.
14
+
15
+ Trains on the flat dataset built by prep_data.py (image, system, user,
16
+ assistant, source). Each row becomes one multimodal chat example:
17
+
18
+ system : the GUI-agent system prompt
19
+ user : [screenshot] + instruction + previous actions
20
+ assistant (label): <think>…</think> <code>action(...)</code>
21
+
22
+ Only the assistant turn contributes to the loss (the prompt + image tokens are
23
+ masked to -100 in the collator). Adapters are pushed to the Hub.
24
+
25
+ Launch via HF Jobs (see gemma4/launch_train.sh) — never run untethered; the
26
+ Jobs box is ephemeral so push_to_hub must be on.
27
+ """
28
+
29
+ import argparse
30
+
31
+ import torch
32
+ from datasets import load_dataset
33
+ from peft import LoraConfig
34
+ from transformers import AutoModelForImageTextToText, AutoProcessor
35
+ from trl import SFTConfig, SFTTrainer
36
+
37
+
38
+ def build_collator(processor, model_config):
39
+ """Render our (system/user/assistant + image) rows into a batch.
40
+
41
+ Gemma 4 uses an image-text-to-text processor: we pass the chat template
42
+ with an image placeholder in the user turn, tokenize, and mask padding plus
43
+ every image-structural token (the soft image token + <start_of_image> /
44
+ <end_of_image>) so loss is computed only over real text tokens. We read the
45
+ exact ids from the model config so this can't drift with the tokenizer.
46
+ """
47
+ image_token_ids = {
48
+ tid for tid in [
49
+ getattr(model_config, "image_token_id", None),
50
+ getattr(model_config, "boi_token_id", None),
51
+ getattr(model_config, "eoi_token_id", None),
52
+ getattr(processor, "image_token_id", None),
53
+ ] if isinstance(tid, int)
54
+ }
55
+ print(f"[train] masking image/pad token ids: {sorted(image_token_ids)}")
56
+ processor.tokenizer.padding_side = "right" # so the prompt is a clean prefix
57
+
58
+ def to_messages(example):
59
+ # IMPORTANT: match inference (gemma4/server.py via mlx_vlm) EXACTLY —
60
+ # one user turn holding [image] + "SYSTEM\n\nuser", then the assistant.
61
+ # Gemma's chat template has no separate system role, and mlx_vlm wraps a
62
+ # raw prompt as a single user turn, so we mirror that here to keep the
63
+ # train and inference token streams identical.
64
+ return [
65
+ {"role": "user", "content": [
66
+ {"type": "image"},
67
+ {"type": "text", "text": f"{example['system']}\n\n{example['user']}"},
68
+ ]},
69
+ {"role": "assistant", "content": [{"type": "text", "text": example["assistant"]}]},
70
+ ]
71
+
72
+ def collate(examples):
73
+ full_texts, prompt_texts, images = [], [], []
74
+ for ex in examples:
75
+ msgs = to_messages(ex)
76
+ full_texts.append(
77
+ processor.apply_chat_template(msgs, tokenize=False, add_generation_prompt=False)
78
+ )
79
+ # Prompt = everything up to (and including) the assistant header, so
80
+ # its token length marks where the completion begins.
81
+ prompt_texts.append(
82
+ processor.apply_chat_template(msgs[:-1], tokenize=False, add_generation_prompt=True)
83
+ )
84
+ images.append([ex["image"].convert("RGB")])
85
+
86
+ batch = processor(text=full_texts, images=images, return_tensors="pt", padding=True)
87
+ # Second pass with the SAME images so the measured prompt length includes
88
+ # the expanded soft-image tokens — the completion offset is then exact.
89
+ prompt_batch = processor(text=prompt_texts, images=images, return_tensors="pt", padding=True)
90
+ prompt_lens = prompt_batch["attention_mask"].sum(dim=1)
91
+
92
+ labels = batch["input_ids"].clone()
93
+ labels[batch["attention_mask"] == 0] = -100 # padding
94
+ for tid in image_token_ids:
95
+ labels[labels == tid] = -100 # image structural tokens
96
+ for i, plen in enumerate(prompt_lens):
97
+ labels[i, : int(plen)] = -100 # prompt (assistant-only loss)
98
+ batch["labels"] = labels
99
+ return batch
100
+
101
+ return collate
102
+
103
+
104
+ def main():
105
+ ap = argparse.ArgumentParser()
106
+ ap.add_argument("--model", default="google/gemma-4-E4B-it")
107
+ ap.add_argument("--dataset", required=True)
108
+ ap.add_argument("--hub-model-id", required=True)
109
+ ap.add_argument("--epochs", type=float, default=1.0)
110
+ ap.add_argument("--batch-size", type=int, default=2)
111
+ ap.add_argument("--grad-accum", type=int, default=8)
112
+ ap.add_argument("--lr", type=float, default=2e-4)
113
+ ap.add_argument("--max-steps", type=int, default=-1)
114
+ ap.add_argument("--eval-frac", type=float, default=0.03)
115
+ ap.add_argument("--project", default="gemma4-gui-agent")
116
+ ap.add_argument("--run-name", default="gemma4-e4b-lora")
117
+ ap.add_argument("--private", action="store_true", default=True)
118
+ args = ap.parse_args()
119
+
120
+ print(f"[train] loading dataset {args.dataset}")
121
+ ds = load_dataset(args.dataset, split="train")
122
+ split = ds.train_test_split(test_size=args.eval_frac, seed=42)
123
+ train_ds, eval_ds = split["train"], split["test"]
124
+ print(f"[train] {len(train_ds)} train / {len(eval_ds)} eval")
125
+
126
+ print(f"[train] loading {args.model}")
127
+ processor = AutoProcessor.from_pretrained(args.model)
128
+ model = AutoModelForImageTextToText.from_pretrained(
129
+ args.model, dtype=torch.bfloat16, attn_implementation="eager",
130
+ )
131
+
132
+ # Adapt attention + MLP projections across the language tower; leave the
133
+ # vision encoder frozen (grounding signal comes from the LM reading tokens).
134
+ peft_config = LoraConfig(
135
+ r=16, lora_alpha=32, lora_dropout=0.05, bias="none",
136
+ task_type="CAUSAL_LM",
137
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
138
+ "gate_proj", "up_proj", "down_proj"],
139
+ modules_to_save=None,
140
+ )
141
+
142
+ sft_config = SFTConfig(
143
+ output_dir=args.hub_model_id.split("/")[-1],
144
+ per_device_train_batch_size=args.batch_size,
145
+ per_device_eval_batch_size=args.batch_size,
146
+ gradient_accumulation_steps=args.grad_accum,
147
+ num_train_epochs=args.epochs,
148
+ max_steps=args.max_steps,
149
+ learning_rate=args.lr,
150
+ warmup_ratio=0.03,
151
+ lr_scheduler_type="cosine",
152
+ logging_steps=5,
153
+ eval_strategy="steps",
154
+ eval_steps=50,
155
+ save_strategy="steps",
156
+ save_steps=100,
157
+ save_total_limit=2,
158
+ bf16=True,
159
+ gradient_checkpointing=True,
160
+ gradient_checkpointing_kwargs={"use_reentrant": False},
161
+ dataset_kwargs={"skip_prepare_dataset": True}, # we collate raw rows
162
+ remove_unused_columns=False,
163
+ max_length=None, # never truncate image tokens
164
+ push_to_hub=True,
165
+ hub_model_id=args.hub_model_id,
166
+ hub_private_repo=args.private,
167
+ hub_strategy="every_save",
168
+ report_to="trackio",
169
+ run_name=args.run_name,
170
+ project=args.project,
171
+ )
172
+
173
+ trainer = SFTTrainer(
174
+ model=model,
175
+ args=sft_config,
176
+ train_dataset=train_ds,
177
+ eval_dataset=eval_ds,
178
+ data_collator=build_collator(processor, model.config),
179
+ peft_config=peft_config,
180
+ processing_class=processor,
181
+ )
182
+
183
+ trainer.train()
184
+ trainer.save_model(sft_config.output_dir)
185
+ trainer.push_to_hub()
186
+ print(f"[train] pushed adapters to {args.hub_model_id}")
187
+
188
+
189
+ if __name__ == "__main__":
190
+ main()