MHHHH02 commited on
Commit
1737ebf
Β·
verified Β·
1 Parent(s): 634aae4

Upload folder using huggingface_hub

Browse files
evaluation/inference_aesthetic.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Inference script for the aesthetic scoring model (score-token approach).
3
+
4
+ For each test image, runs 13 criterion queries + 1 total-score query,
5
+ maps score tokens to numeric values, then classifies into levels:
6
+ A β€” Poor (0 <= score < 5)
7
+ B β€” Medium (5 <= score < 7)
8
+ C β€” Good (7 <= score <= 10)
9
+
10
+ Also answers the per-image MCQ by feeding the question+options directly.
11
+
12
+ Usage:
13
+ CUDA_VISIBLE_DEVICES=0,1,2,3 python evaluation/inference_aesthetic.py
14
+ """
15
+
16
+ import os
17
+ import sys
18
+ import json
19
+ import math
20
+ import re
21
+ import glob
22
+ import time
23
+ import torch
24
+ import multiprocessing as mp
25
+ from pathlib import Path
26
+ from tqdm import tqdm
27
+ from multiprocessing import Process
28
+ from PIL import Image
29
+
30
+ Image.MAX_IMAGE_PIXELS = None
31
+ mp.set_start_method("spawn", force=True)
32
+
33
+ project_root = Path(__file__).parent.parent
34
+ sys.path.insert(0, str(project_root))
35
+
36
+ from qwenvl.train.score_tokens import ScoreTokenRegistry
37
+ from qwenvl.data.aesthetic_dataset import CRITERIA_QUESTIONS, CRITERIA_ORDER, TOTAL_SCORE_QUESTION
38
+
39
+ # ── Config ────────────────────────────────────────────────────────────────────
40
+ CHECKPOINT = ""
41
+
42
+ IMAGE_DIR = "zijielou/PortraitCraft/main"
43
+
44
+ TEST_JSON = "PortraitCraft/track_1_test.json"
45
+
46
+ OUTPUT_JSON = "PortraitCraft/track_1_test_results_2.json"
47
+
48
+ WORKERS_PER_GPU = 1
49
+ MAX_NEW_TOKENS = 1024
50
+
51
+ # ── Score token registries (must match training) ─────────────────────────────
52
+ CRIT_BINS = 20
53
+ TOTAL_BINS = 101
54
+
55
+ crit_registry = ScoreTokenRegistry(n_bins=CRIT_BINS, score_min=1.0, score_max=10.0, prefix="score")
56
+ total_registry = ScoreTokenRegistry(n_bins=TOTAL_BINS, score_min=0.0, score_max=100.0, prefix="total")
57
+
58
+ # ── Level classification ─────────────────────────────────────────────────────
59
+ def score_to_level(score: float) -> str:
60
+ if score < 5.0:
61
+ return "A"
62
+ elif score < 7.0:
63
+ return "B"
64
+ else:
65
+ return "C"
66
+
67
+
68
+ # ── Image path resolver (test json has flat names, images live in subdirs) ───
69
+ _image_index = None
70
+
71
+ def build_image_index(image_dir: str) -> dict:
72
+ index = {}
73
+ for subdir in sorted(os.listdir(image_dir)):
74
+ subdir_path = os.path.join(image_dir, subdir)
75
+ if not os.path.isdir(subdir_path):
76
+ continue
77
+ for fname in os.listdir(subdir_path):
78
+ index[fname] = os.path.join(subdir_path, fname)
79
+ return index
80
+
81
+
82
+ def resolve_image_path(image_name: str, image_dir: str) -> str:
83
+ global _image_index
84
+ full = os.path.join(image_dir, image_name)
85
+ if os.path.exists(full):
86
+ return full
87
+ if _image_index is None:
88
+ _image_index = build_image_index(image_dir)
89
+ return _image_index.get(image_name, full)
90
+
91
+
92
+ # ── Model wrapper ─────────────────────────────────────────────────────────────
93
+ class AestheticInferenceServer:
94
+ def __init__(self, gpu_id: int):
95
+ from transformers import Qwen3VLForConditionalGeneration, AutoProcessor
96
+
97
+ self.device = torch.device(f"cuda:{gpu_id}")
98
+ print(f"Loading model on GPU {gpu_id} from {CHECKPOINT}...")
99
+
100
+ self.model = Qwen3VLForConditionalGeneration.from_pretrained(
101
+ CHECKPOINT,
102
+ torch_dtype=torch.bfloat16,
103
+ attn_implementation="flash_attention_2",
104
+ ).to(self.device).eval()
105
+
106
+ self.processor = AutoProcessor.from_pretrained(CHECKPOINT)
107
+
108
+ self.crit_token_ids = crit_registry.get_token_ids(self.processor.tokenizer)
109
+ self.crit_values = crit_registry.get_value_tensor(device=self.device)
110
+ self.total_token_ids = total_registry.get_token_ids(self.processor.tokenizer)
111
+ self.total_values = total_registry.get_value_tensor(device=self.device)
112
+
113
+ print(f"Model loaded on GPU {gpu_id}")
114
+
115
+ def _generate(self, image_path: str, text: str, max_new_tokens: int = None) -> tuple:
116
+ """Returns (generated_text, generated_ids, scores_tuple)."""
117
+ if max_new_tokens is None:
118
+ max_new_tokens = MAX_NEW_TOKENS
119
+
120
+ img = Image.open(image_path).convert("RGB")
121
+ messages = [{
122
+ "role": "user",
123
+ "content": [
124
+ {"type": "image", "image": img},
125
+ {"type": "text", "text": text},
126
+ ],
127
+ }]
128
+
129
+ inputs = self.processor.apply_chat_template(
130
+ messages, tokenize=True, add_generation_prompt=True,
131
+ return_dict=True, return_tensors="pt",
132
+ ).to(self.device)
133
+
134
+ with torch.no_grad():
135
+ outputs = self.model.generate(
136
+ **inputs,
137
+ max_new_tokens=max_new_tokens,
138
+ do_sample=False,
139
+ return_dict_in_generate=True,
140
+ output_scores=True,
141
+ )
142
+
143
+ generated_ids = outputs.sequences[0][inputs["input_ids"].shape[1]:]
144
+ generated_text = self.processor.tokenizer.decode(generated_ids, skip_special_tokens=False)
145
+ scores_tuple = outputs.scores
146
+
147
+ return generated_text, generated_ids, scores_tuple
148
+
149
+ def predict_criterion(self, image_path: str, criterion: str) -> dict:
150
+ question = CRITERIA_QUESTIONS[criterion]
151
+ gen_text, gen_ids, scores_tuple = self._generate(image_path, question)
152
+
153
+ score_token_id_set = set(self.crit_token_ids)
154
+ expected_score = None
155
+ argmax_score = None
156
+ argmax_token = None
157
+
158
+ gen_ids_list = gen_ids.tolist()
159
+ for step_idx, tid in enumerate(gen_ids_list):
160
+ if tid in score_token_id_set:
161
+ token_str = self.processor.tokenizer.decode([tid])
162
+ argmax_token = token_str.strip()
163
+ argmax_score = crit_registry.token_to_score(argmax_token)
164
+ logits_at_step = scores_tuple[step_idx]
165
+ expected_score = crit_registry.expected_score(
166
+ logits_at_step[0], self.crit_token_ids, self.crit_values
167
+ ).item()
168
+ break
169
+
170
+ final_score = expected_score if expected_score is not None else argmax_score
171
+ if final_score is None:
172
+ print(f" WARNING: no score token found for [{criterion}], raw output: {gen_text[:200]}", flush=True)
173
+ final_score = 5.0
174
+
175
+ return {
176
+ "score": round(final_score, 2),
177
+ "level": score_to_level(final_score),
178
+ "argmax_token": argmax_token,
179
+ "argmax_score": round(argmax_score, 2) if argmax_score is not None else None,
180
+ "expected_score": round(expected_score, 2) if expected_score is not None else None,
181
+ "raw_text": gen_text,
182
+ }
183
+
184
+ def predict_total(self, image_path: str) -> dict:
185
+ gen_text, gen_ids, scores_tuple = self._generate(image_path, TOTAL_SCORE_QUESTION)
186
+
187
+ total_token_id_set = set(self.total_token_ids)
188
+ expected_score = None
189
+ argmax_score = None
190
+ argmax_token = None
191
+
192
+ gen_ids_list = gen_ids.tolist()
193
+ for step_idx, tid in enumerate(gen_ids_list):
194
+ if tid in total_token_id_set:
195
+ token_str = self.processor.tokenizer.decode([tid])
196
+ argmax_token = token_str.strip()
197
+ argmax_score = total_registry.token_to_score(argmax_token)
198
+ logits_at_step = scores_tuple[step_idx]
199
+ expected_score = total_registry.expected_score(
200
+ logits_at_step[0], self.total_token_ids, self.total_values
201
+ ).item()
202
+ break
203
+
204
+ final_score = expected_score if expected_score is not None else argmax_score
205
+ if final_score is None:
206
+ print(f" WARNING: no total token found, raw output: {gen_text[:200]}", flush=True)
207
+ final_score = 50.0
208
+
209
+ return {
210
+ "total_score": round(final_score, 1),
211
+ "argmax_token": argmax_token,
212
+ "argmax_score": round(argmax_score, 1) if argmax_score is not None else None,
213
+ "expected_score": round(expected_score, 1) if expected_score is not None else None,
214
+ "raw_text": gen_text,
215
+ }
216
+
217
+ def answer_mcq(self, image_path: str, question: str, options: dict) -> dict:
218
+ options_text = "\n".join([f"{k}. {v}" for k, v in options.items()])
219
+ prompt = (
220
+ f"{question}\n\n"
221
+ f"Options:\n{options_text}\n\n"
222
+ f"Answer with a single letter (A, B, C, or D)."
223
+ )
224
+
225
+ img = Image.open(image_path).convert("RGB")
226
+ messages = [{
227
+ "role": "user",
228
+ "content": [
229
+ {"type": "image", "image": img},
230
+ {"type": "text", "text": prompt},
231
+ ],
232
+ }]
233
+ inputs = self.processor.apply_chat_template(
234
+ messages, tokenize=True, add_generation_prompt=True,
235
+ return_dict=True, return_tensors="pt",
236
+ ).to(self.device)
237
+
238
+ think_ids = self.processor.tokenizer.encode("<think>\n", add_special_tokens=False)
239
+ think_tensor = torch.tensor([think_ids], device=self.device)
240
+ inputs["input_ids"] = torch.cat([inputs["input_ids"], think_tensor], dim=1)
241
+ if "attention_mask" in inputs:
242
+ inputs["attention_mask"] = torch.cat(
243
+ [inputs["attention_mask"], torch.ones_like(think_tensor)], dim=1
244
+ )
245
+
246
+ with torch.no_grad():
247
+ outputs = self.model.generate(
248
+ **inputs,
249
+ max_new_tokens=2048,
250
+ do_sample=False,
251
+ return_dict_in_generate=True,
252
+ output_scores=True,
253
+ )
254
+
255
+ generated_ids = outputs.sequences[0][inputs["input_ids"].shape[1]:]
256
+ gen_text = self.processor.tokenizer.decode(generated_ids, skip_special_tokens=False)
257
+
258
+ text_to_search = gen_text.split("</think>")[-1] if "</think>" in gen_text else gen_text
259
+ match = re.search(r'\b([A-D])\b', text_to_search)
260
+ answer = match.group(1) if match else "A"
261
+ return {"answer": answer, "raw_text": gen_text}
262
+
263
+
264
+ # ── Worker ────────────────────────────────────────────────────────────────────
265
+ def worker_run(worker_id: int, gpu_id: int, data_chunk: list):
266
+ server = AestheticInferenceServer(gpu_id)
267
+
268
+ out_path = OUTPUT_JSON + f".part{worker_id}.json"
269
+
270
+ results = []
271
+ if os.path.exists(out_path):
272
+ try:
273
+ with open(out_path, "r", encoding="utf-8") as f:
274
+ results = json.load(f)
275
+ print(f"Worker {worker_id} resuming from {len(results)} existing results")
276
+ except Exception:
277
+ results = []
278
+
279
+ done_images = {r["image_path"] for r in results}
280
+
281
+ total_to_process = len([it for it in data_chunk if it["image_path"] not in done_images])
282
+ processed_count = 0
283
+ t_start_all = time.time()
284
+
285
+ for item_idx, item in enumerate(data_chunk):
286
+ if item["image_path"] in done_images:
287
+ continue
288
+
289
+ image_path = resolve_image_path(item["image_path"], IMAGE_DIR)
290
+ if not os.path.exists(image_path):
291
+ print(f"[W{worker_id}] IMAGE NOT FOUND: {image_path}", flush=True)
292
+ continue
293
+
294
+ t_img_start = time.time()
295
+
296
+ try:
297
+ criteria_results = {}
298
+ print(f"\n[W{worker_id}] ({'='*60})", flush=True)
299
+ print(f"[W{worker_id}] Image {processed_count+1}/{total_to_process}: {item['image_path']}", flush=True)
300
+ print(f"[W{worker_id}] {'-'*60}", flush=True)
301
+
302
+ for crit_name in CRITERIA_ORDER:
303
+ t_crit = time.time()
304
+ pred = server.predict_criterion(image_path, crit_name)
305
+ criteria_results[crit_name] = {
306
+ "level": pred["level"],
307
+ "score": pred["score"],
308
+ "argmax_token": pred.get("argmax_token"),
309
+ "argmax_score": pred.get("argmax_score"),
310
+ "expected_score": pred.get("expected_score"),
311
+ "raw_text": pred.get("raw_text", ""),
312
+ }
313
+ elapsed = time.time() - t_crit
314
+ argmax_info = f"argmax={pred.get('argmax_token','?')}({pred.get('argmax_score','?')})" if pred.get('argmax_token') else "NO_TOKEN"
315
+ print(f"[W{worker_id}] {crit_name:45s} => E[score]={pred['score']:5.2f} level={pred['level']:6s} {argmax_info} ({elapsed:.1f}s)", flush=True)
316
+ print(f"[W{worker_id}] raw_text: {pred.get('raw_text', '')[:300]}", flush=True)
317
+
318
+ total_pred = server.predict_total(image_path)
319
+ t_argmax = f"argmax={total_pred.get('argmax_token','?')}({total_pred.get('argmax_score','?')})" if total_pred.get('argmax_token') else "NO_TOKEN"
320
+ print(f"[W{worker_id}] {'Total Score':45s} => E[score]={total_pred['total_score']:5.1f}/100 {t_argmax}", flush=True)
321
+ print(f"[W{worker_id}] raw_text: {total_pred.get('raw_text', '')[:300]}", flush=True)
322
+
323
+ mcq_result = server.answer_mcq(
324
+ image_path,
325
+ item.get("question", ""),
326
+ item.get("options", {}),
327
+ )
328
+ mcq_answer = mcq_result["answer"]
329
+ mcq_raw = mcq_result["raw_text"]
330
+ print(f"[W{worker_id}] {'MCQ Answer':45s} => {mcq_answer}", flush=True)
331
+ print(f"[W{worker_id}] raw_text: {mcq_raw[:500]}", flush=True)
332
+
333
+ t_img_elapsed = time.time() - t_img_start
334
+ processed_count += 1
335
+ avg_time = (time.time() - t_start_all) / processed_count
336
+ eta = avg_time * (total_to_process - processed_count)
337
+ print(f"[W{worker_id}] {'-'*60}", flush=True)
338
+ print(f"[W{worker_id}] Done in {t_img_elapsed:.1f}s | Progress: {processed_count}/{total_to_process} | ETA: {eta/60:.1f}min", flush=True)
339
+
340
+ level_summary = {v["level"]: 0 for v in criteria_results.values()}
341
+ for v in criteria_results.values():
342
+ level_summary[v["level"]] += 1
343
+ print(f"[W{worker_id}] Level distribution: {dict(level_summary)} | total={total_pred['total_score']} | mcq={mcq_answer}", flush=True)
344
+
345
+ record = {
346
+ "image_path": item["image_path"],
347
+ "criteria": criteria_results,
348
+ "total_score": total_pred["total_score"],
349
+ "total_score_detail": {
350
+ "argmax_token": total_pred.get("argmax_token"),
351
+ "argmax_score": total_pred.get("argmax_score"),
352
+ "expected_score": total_pred.get("expected_score"),
353
+ "raw_text": total_pred.get("raw_text", ""),
354
+ },
355
+ "question": item.get("question", ""),
356
+ "options": item.get("options", {}),
357
+ "answer": mcq_answer,
358
+ "mcq_raw_text": mcq_raw,
359
+ }
360
+
361
+ results.append(record)
362
+
363
+ tmp_path = out_path + ".tmp"
364
+ with open(tmp_path, "w", encoding="utf-8") as f:
365
+ json.dump(results, f, ensure_ascii=False, indent=2)
366
+ os.replace(tmp_path, out_path)
367
+ print(f"[W{worker_id}] Saved to {out_path} ({len(results)} total records)", flush=True)
368
+
369
+ except Exception as e:
370
+ print(f"[W{worker_id}] ERROR on {item['image_path']}: {e}", flush=True)
371
+ import traceback
372
+ traceback.print_exc()
373
+ continue
374
+
375
+ total_elapsed = time.time() - t_start_all
376
+ print(f"\n[W{worker_id}] ALL DONE | {len(results)} records | {total_elapsed/60:.1f}min total", flush=True)
377
+
378
+
379
+ # ── Merge ─────────────────────────────────────────────────────────────────────
380
+ def merge_results():
381
+ files = glob.glob(OUTPUT_JSON + ".part*.json")
382
+ unique = {}
383
+ for f in files:
384
+ with open(f, "r", encoding="utf-8") as fp:
385
+ for x in json.load(fp):
386
+ unique[x["image_path"]] = x
387
+ all_results = list(unique.values())
388
+ with open(OUTPUT_JSON, "w", encoding="utf-8") as f:
389
+ json.dump(all_results, f, ensure_ascii=False, indent=2)
390
+ print(f"Merged {len(all_results)} results to {OUTPUT_JSON}")
391
+
392
+
393
+ # ── Main ──────────────────────────────────────────────────────────────────────
394
+ def main():
395
+ with open(TEST_JSON, "r", encoding="utf-8") as f:
396
+ data = json.load(f)
397
+
398
+ done_set = set()
399
+ for part_file in glob.glob(OUTPUT_JSON + ".part*.json"):
400
+ try:
401
+ with open(part_file, "r") as fp:
402
+ for x in json.load(fp):
403
+ done_set.add(x["image_path"])
404
+ except Exception:
405
+ continue
406
+
407
+ remaining = [item for item in data if item["image_path"] not in done_set]
408
+ print(f"Total: {len(data)}, already done: {len(done_set)}, remaining: {len(remaining)}")
409
+
410
+ if len(remaining) == 0:
411
+ print("All done, merging...")
412
+ merge_results()
413
+ return
414
+
415
+ NUM_GPUS = torch.cuda.device_count()
416
+ TOTAL_WORKERS = NUM_GPUS * WORKERS_PER_GPU
417
+ print(f"GPUs: {NUM_GPUS}, workers: {TOTAL_WORKERS}")
418
+
419
+ chunk_size = math.ceil(len(remaining) / TOTAL_WORKERS)
420
+ chunks = [remaining[i:i + chunk_size] for i in range(0, len(remaining), chunk_size)]
421
+
422
+ processes = []
423
+ for i, chunk in enumerate(chunks):
424
+ gpu_id = i // WORKERS_PER_GPU
425
+ p = Process(target=worker_run, args=(i, gpu_id, chunk))
426
+ p.start()
427
+ processes.append(p)
428
+
429
+ for p in processes:
430
+ p.join()
431
+
432
+ merge_results()
433
+
434
+
435
+ if __name__ == "__main__":
436
+ main()
qwenvl/__init__.py ADDED
File without changes
qwenvl/data/__init__.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ # Define placeholders for dataset paths
4
+ CAMBRIAN_737K = {
5
+ "annotation_path": "PATH_TO_CAMBRIAN_737K_ANNOTATION",
6
+ "data_path": "",
7
+ }
8
+
9
+ CAMBRIAN_737K_PACK = {
10
+ "annotation_path": f"PATH_TO_CAMBRIAN_737K_ANNOTATION_PACKED",
11
+ "data_path": f"",
12
+ }
13
+
14
+ MP_DOC = {
15
+ "annotation_path": "PATH_TO_MP_DOC_ANNOTATION",
16
+ "data_path": "PATH_TO_MP_DOC_DATA",
17
+ }
18
+
19
+ CLEVR_MC = {
20
+ "annotation_path": "PATH_TO_CLEVR_MC_ANNOTATION",
21
+ "data_path": "PATH_TO_CLEVR_MC_DATA",
22
+ }
23
+
24
+ VIDEOCHATGPT = {
25
+ "annotation_path": "PATH_TO_VIDEOCHATGPT_ANNOTATION",
26
+ "data_path": "PATH_TO_VIDEOCHATGPT_DATA",
27
+ }
28
+
29
+ SINGLEIMAGES = {
30
+ "annotation_path": "./demo/single_images_train_convert.json",
31
+ "data_path": "./demo/images",
32
+ }
33
+
34
+ AESTHETIC_TRACK1 = {
35
+ # image_path in json uses subdir format: images_00/xxx.jpg
36
+ "annotation_path": "PortraitCraft/track_1_train_subdir.json",
37
+ "data_path": "zijielou/PortraitCraft/main",
38
+ "dataset_type": "aesthetic",
39
+ }
40
+
41
+ data_dict = {
42
+ "cambrian_737k": CAMBRIAN_737K,
43
+ "cambrian_737k_pack": CAMBRIAN_737K_PACK,
44
+ "mp_doc": MP_DOC,
45
+ "clevr_mc": CLEVR_MC,
46
+ "videochatgpt": VIDEOCHATGPT,
47
+ "singleimages": SINGLEIMAGES,
48
+ "aesthetic_track1": AESTHETIC_TRACK1,
49
+ }
50
+
51
+
52
+ def parse_sampling_rate(dataset_name):
53
+ match = re.search(r"%(\d+)$", dataset_name)
54
+ if match:
55
+ return int(match.group(1)) / 100.0
56
+ return 1.0
57
+
58
+
59
+ def data_list(dataset_names):
60
+ config_list = []
61
+ for dataset_name in dataset_names:
62
+ sampling_rate = parse_sampling_rate(dataset_name)
63
+ dataset_name = re.sub(r"%(\d+)$", "", dataset_name)
64
+ if dataset_name in data_dict.keys():
65
+ config = data_dict[dataset_name].copy()
66
+ config["sampling_rate"] = sampling_rate
67
+ config_list.append(config)
68
+ else:
69
+ raise ValueError(f"do not find {dataset_name}")
70
+ return config_list
71
+
72
+
73
+ if __name__ == "__main__":
74
+ dataset_names = ["cambrian_737k"]
75
+ configs = data_list(dataset_names)
76
+ for config in configs:
77
+ print(config)
qwenvl/data/aesthetic_dataset.py ADDED
@@ -0,0 +1,419 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dataset for image aesthetic scoring using generic integer-index score tokens.
3
+
4
+ Each image produces N+1 independent training samples:
5
+ - one sample per criterion (N=13)
6
+ - one sample for total score
7
+
8
+ Every sample is a single-turn VQA pair:
9
+ user: <image> + criterion-specific question
10
+ assistant: <think>reason</think>\nThe score is <scoreK>
11
+
12
+ The <scoreK> token is what we use for the logits-weighted prediction.
13
+ The <think>...</think> block is labelled for CE loss so the model learns to reason.
14
+ """
15
+
16
+ import json
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Dict, Any, List, Tuple
20
+
21
+ import torch
22
+ from torch.utils.data import Dataset
23
+
24
+ from .rope2d import get_rope_index_25, get_rope_index_2, get_rope_index_3
25
+ from ..train.score_tokens import ScoreTokenRegistry
26
+
27
+ IGNORE_INDEX = -100
28
+
29
+ # ── Per-criterion questions ───────────────────────────────────────────────────
30
+ # Each question is carefully designed to probe the specific visual dimension.
31
+ # Default instruction uses the original <score1>..<score20> tokens.
32
+ # When existing vocab tokens are used, build_criteria_questions() overrides this.
33
+ CRIT_SCORE_INSTRUCTION = (
34
+ "Rate this aspect using a score token from <score1> (lowest quality) to <score20> (highest quality)."
35
+ )
36
+
37
+ _CRITERIA_QUESTION_BODIES: Dict[str, str] = {
38
+ "Color Harmony": (
39
+ "Evaluate the color harmony of this image. "
40
+ "Consider how well the colors work together in terms of palette coherence, "
41
+ "contrast balance, and emotional consistency. "
42
+ ),
43
+ "Visual Style Consistency": (
44
+ "Assess the visual style consistency of this image. "
45
+ "Does the overall aesthetic β€” including lighting, color grading, subject treatment, "
46
+ "and compositional choices β€” feel unified and purposeful throughout? "
47
+ ),
48
+ "Sharpness": (
49
+ "Evaluate the sharpness of this image. "
50
+ "Focus on whether the intended subject is in crisp focus, "
51
+ "the level of fine detail preserved, and whether any blur is intentional or distracting. "
52
+ ),
53
+ "Light and Shadow Modeling": (
54
+ "Assess the light and shadow modeling in this image. "
55
+ "Consider the quality of tonal transitions, whether shadows flatter the subject, "
56
+ "and how well the lighting reveals three-dimensional form. "
57
+ ),
58
+ "Creativity and Originality": (
59
+ "Evaluate the creativity and originality of this image. "
60
+ "Does it demonstrate a distinctive perspective, novel visual language, "
61
+ "or an unexpected approach that sets it apart from typical photography? "
62
+ ),
63
+ "Exposure Control": (
64
+ "Assess the exposure control of this image. "
65
+ "Consider whether highlights are blown out, shadows are crushed, "
66
+ "and whether the overall tonal range is balanced and intentional. "
67
+ ),
68
+ "Application of Classical Composition Principles": (
69
+ "Evaluate how well classical composition principles are applied in this image. "
70
+ "Consider elements such as rule of thirds, leading lines, framing, "
71
+ "symmetry, and the intentionality of the subject placement within the frame. "
72
+ ),
73
+ "Depth of Field and Layering": (
74
+ "Assess the depth of field and visual layering in this image. "
75
+ "Consider whether foreground, midground, and background are purposefully separated, "
76
+ "and whether the depth of field effectively isolates the subject. "
77
+ ),
78
+ "Visual Center Stability": (
79
+ "Evaluate the visual center stability of this image. "
80
+ "Consider whether there is a clear focal point that anchors the viewer's gaze, "
81
+ "and whether competing elements undermine or reinforce that center. "
82
+ ),
83
+ "Visual Flow Guidance": (
84
+ "Assess the visual flow guidance in this image. "
85
+ "Does the composition lead the viewer's eye naturally through the frame "
86
+ "using lines, shapes, contrast, or subject placement? "
87
+ ),
88
+ "Structural Support Stability": (
89
+ "Evaluate the structural support and stability of this image's composition. "
90
+ "Consider whether the arrangement of elements creates a balanced, grounded feel "
91
+ "or whether the structure feels unresolved or tension-inducing. "
92
+ ),
93
+ "Appropriateness of Negative Space": (
94
+ "Assess the use of negative space in this image. "
95
+ "Consider whether empty areas enhance the subject's presence, "
96
+ "create breathing room, and serve a compositional or emotional purpose. "
97
+ ),
98
+ "Subject Integrity": (
99
+ "Evaluate the subject integrity of this image. "
100
+ "Are the main subjects fully visible, clearly defined, and free from "
101
+ "awkward cropping, occlusion, or distracting intrusions that compromise their presence? "
102
+ ),
103
+ }
104
+
105
+
106
+ def build_criteria_questions(crit_registry) -> Dict[str, str]:
107
+ """Build criteria questions with the actual token names from the registry."""
108
+ first_token = crit_registry.tokens[0]
109
+ last_token = crit_registry.tokens[-1]
110
+ instruction = (
111
+ f"Rate this aspect using a score token from {first_token} (lowest quality) "
112
+ f"to {last_token} (highest quality)."
113
+ )
114
+ return {k: v + instruction for k, v in _CRITERIA_QUESTION_BODIES.items()}
115
+
116
+
117
+ def build_total_score_question(total_registry) -> str:
118
+ """Build total score question with the actual token names from the registry."""
119
+ first_token = total_registry.tokens[0]
120
+ last_token = total_registry.tokens[-1]
121
+ return (
122
+ "Give an overall aesthetic quality score for this image. "
123
+ "Consider all aspects: composition, exposure, sharpness, color, creativity, "
124
+ "and subject presentation. "
125
+ f"Rate using a score token from {first_token} (lowest, 0) to {last_token} (highest, 100)."
126
+ )
127
+
128
+
129
+ # Default questions (backward compatible with original <score1>..<score20> tokens)
130
+ CRITERIA_QUESTIONS: Dict[str, str] = {
131
+ k: v + CRIT_SCORE_INSTRUCTION for k, v in _CRITERIA_QUESTION_BODIES.items()
132
+ }
133
+
134
+ TOTAL_SCORE_QUESTION = (
135
+ "Give an overall aesthetic quality score for this image. "
136
+ "Consider all aspects: composition, exposure, sharpness, color, creativity, "
137
+ "and subject presentation. "
138
+ "Rate using a score token from <total1> (lowest, 0) to <total101> (highest, 100)."
139
+ )
140
+
141
+ # Ordered list used to keep sample ordering deterministic
142
+ CRITERIA_ORDER: List[str] = list(CRITERIA_QUESTIONS.keys())
143
+
144
+
145
+ # ── Text builders ─────────────────────────────────────────────────────────────
146
+
147
+ def build_crit_assistant_text(
148
+ reason: str,
149
+ score: float,
150
+ crit_registry: ScoreTokenRegistry,
151
+ ) -> str:
152
+ token = crit_registry.score_to_token(score)
153
+ return f"<think>\n{reason.strip()}\n</think>\nThe score is {token}"
154
+ # return f"The score is {token}"
155
+
156
+ def build_total_assistant_text(
157
+ total_score: float,
158
+ total_registry: ScoreTokenRegistry,
159
+ ) -> str:
160
+ token = total_registry.score_to_token(total_score)
161
+ return f"The score is {token}"
162
+
163
+
164
+
165
+ def build_mcq_messages(
166
+ image_path: str,
167
+ question: str,
168
+ options: dict,
169
+ answer: str,
170
+ ) -> List[Dict]:
171
+ options_text = "\n".join(f"{k}. {v}" for k, v in sorted(options.items()))
172
+ user_text = f"{question}\n{options_text}"
173
+ return [
174
+ {
175
+ "role": "user",
176
+ "content": [
177
+ {"type": "image", "image": image_path},
178
+ {"type": "text", "text": user_text},
179
+ ],
180
+ },
181
+ {
182
+ "role": "assistant",
183
+ "content": [
184
+ {"type": "text", "text": answer},
185
+ ],
186
+ },
187
+ ]
188
+
189
+
190
+ def build_crit_messages(
191
+ image_path: str,
192
+ criterion: str,
193
+ reason: str,
194
+ score: float,
195
+ crit_registry,
196
+ criteria_questions: Dict[str, str] = None,
197
+ ) -> List[Dict]:
198
+ questions = criteria_questions if criteria_questions is not None else CRITERIA_QUESTIONS
199
+ return [
200
+ {
201
+ "role": "user",
202
+ "content": [
203
+ {"type": "image", "image": image_path},
204
+ {"type": "text", "text": questions[criterion]},
205
+ ],
206
+ },
207
+ {
208
+ "role": "assistant",
209
+ "content": [
210
+ {"type": "text", "text": build_crit_assistant_text(reason, score, crit_registry)},
211
+ ],
212
+ },
213
+ ]
214
+
215
+
216
+ def build_total_messages(
217
+ image_path: str,
218
+ total_score: float,
219
+ total_registry,
220
+ total_score_question: str = None,
221
+ ) -> List[Dict]:
222
+ question = total_score_question if total_score_question is not None else TOTAL_SCORE_QUESTION
223
+ return [
224
+ {
225
+ "role": "user",
226
+ "content": [
227
+ {"type": "image", "image": image_path},
228
+ {"type": "text", "text": question},
229
+ ],
230
+ },
231
+ {
232
+ "role": "assistant",
233
+ "content": [
234
+ {"type": "text", "text": build_total_assistant_text(total_score, total_registry)},
235
+ ],
236
+ },
237
+ ]
238
+
239
+
240
+ # ── Tokenisation ──────────────────────────────────────────────────────────────
241
+
242
+ def _tokenise_messages(messages: List[Dict], processor) -> Dict:
243
+ full_result = processor.apply_chat_template(
244
+ messages, tokenize=True, return_dict=True, return_tensors="pt"
245
+ )
246
+ input_ids = full_result["input_ids"]
247
+ if isinstance(input_ids, list):
248
+ input_ids = torch.tensor(input_ids).unsqueeze(0)
249
+
250
+ labels = torch.full_like(input_ids, IGNORE_INDEX)
251
+
252
+ # Label the assistant turn only (after token 77091 = "assistant")
253
+ ids_flat = input_ids[0].tolist()
254
+ L = len(ids_flat)
255
+ pos = 0
256
+ while pos < L:
257
+ if ids_flat[pos] == 77091:
258
+ ans_start = pos + 2
259
+ ans_end = ans_start
260
+ while ans_end < L and ids_flat[ans_end] != 151645:
261
+ ans_end += 1
262
+ if ans_end < L:
263
+ labels[0, ans_start : ans_end + 2] = input_ids[0, ans_start : ans_end + 2]
264
+ pos = ans_end
265
+ pos += 1
266
+
267
+ full_result["labels"] = labels
268
+ full_result["input_ids"] = input_ids
269
+ return full_result
270
+
271
+
272
+ # ── Dataset ───────────────────────────────────────────────────────────────────
273
+
274
+ class AestheticScoringDataset(Dataset):
275
+ """
276
+ Each image in track_1_train.json expands into len(criteria) + 1 samples.
277
+
278
+ Args:
279
+ processor: HF processor with score tokens already added.
280
+ annotation_path: Path to track_1_train.json.
281
+ image_dir: Root directory for image files.
282
+ data_args: DataArguments (needs model_type).
283
+ crit_registry: ScoreTokenRegistry for criteria scores (1–10).
284
+ total_registry: ScoreTokenRegistry for total score (0–100).
285
+ include_total: Whether to include total-score samples (default True).
286
+ """
287
+
288
+ def __init__(
289
+ self,
290
+ processor,
291
+ annotation_path: str,
292
+ image_dir: str,
293
+ data_args,
294
+ crit_registry,
295
+ total_registry,
296
+ include_total: bool = True,
297
+ ):
298
+ super().__init__()
299
+ with open(annotation_path) as f:
300
+ raw = json.load(f)
301
+
302
+ self.image_dir = Path(image_dir)
303
+ self.processor = processor
304
+ self.crit_registry = crit_registry
305
+ self.total_registry = total_registry
306
+
307
+ # Build questions dynamically based on registry token names
308
+ self.criteria_questions = build_criteria_questions(crit_registry)
309
+ self.total_score_question = build_total_score_question(total_registry)
310
+
311
+ model_type = getattr(data_args, "model_type", "qwen3vl")
312
+ if model_type == "qwen3vl":
313
+ self.get_rope_index = get_rope_index_3
314
+ elif model_type == "qwen2.5vl":
315
+ self.get_rope_index = get_rope_index_25
316
+ elif model_type == "qwen2vl":
317
+ self.get_rope_index = get_rope_index_2
318
+ else:
319
+ raise ValueError(f"Unsupported model_type: {model_type}")
320
+
321
+ self.merge_size = getattr(processor.image_processor, "merge_size", 2)
322
+
323
+ # Expand: each item β†’ (image_path, type_key, score, text_field, question, options)
324
+ # type_key: criterion name | "__total__" | "__mcq__"
325
+ self.samples: List[Tuple] = []
326
+ for item in raw:
327
+ img = item["image_path"]
328
+ if "question" in item:
329
+ # MCQ entry: single sample
330
+ self.samples.append((
331
+ img, "__mcq__", 0.0,
332
+ item["answer"], item["question"], item["options"],
333
+ ))
334
+ else:
335
+ # Scoring entry: 13 criteria + 1 total
336
+ criteria = item.get("criteria", {})
337
+ for key in CRITERIA_ORDER:
338
+ if key in criteria:
339
+ entry = criteria[key]
340
+ self.samples.append((
341
+ img,
342
+ key,
343
+ float(entry["score"]),
344
+ entry.get("reason", ""),
345
+ None, None,
346
+ ))
347
+ if include_total:
348
+ self.samples.append((
349
+ img,
350
+ "__total__",
351
+ float(item["total_score"]),
352
+ "",
353
+ None, None,
354
+ ))
355
+
356
+ def __len__(self) -> int:
357
+ return len(self.samples)
358
+
359
+ def __getitem__(self, i: int) -> Dict[str, torch.Tensor]:
360
+ for attempt in range(3):
361
+ try:
362
+ return self._get_item(i)
363
+ except Exception as e:
364
+ img_name = self.samples[i][0]
365
+ print(f"[Attempt {attempt}] Error loading sample {i} (image={img_name}): {e}")
366
+ # skip to a random different sample instead of retrying the same broken image
367
+ import random
368
+ i = random.randint(0, len(self.samples) - 1)
369
+ time.sleep(0.1)
370
+ return self._get_item(random.randint(0, len(self.samples) - 1))
371
+
372
+ def _get_item(self, i: int) -> Dict[str, torch.Tensor]:
373
+ img_name, criterion, score, text_field, question, options = self.samples[i]
374
+
375
+ if criterion == "__mcq__":
376
+ image_path = str((self.image_dir / img_name).resolve())
377
+ messages = build_mcq_messages(image_path, question, options, text_field)
378
+ elif criterion == "__total__":
379
+ image_path = str((self.image_dir / img_name).resolve())
380
+ messages = build_total_messages(
381
+ image_path, score, self.total_registry,
382
+ total_score_question=self.total_score_question,
383
+ )
384
+ else:
385
+ image_path = str((self.image_dir / img_name).resolve())
386
+ messages = build_crit_messages(
387
+ image_path, criterion, text_field, score, self.crit_registry,
388
+ criteria_questions=self.criteria_questions,
389
+ )
390
+
391
+ data_dict = _tokenise_messages(messages, self.processor)
392
+
393
+ seq_len = data_dict["input_ids"][0].size(0)
394
+ # print(f"seq_length:{seq_len}")
395
+ grid_thw = data_dict.get("image_grid_thw")
396
+ if grid_thw is not None and not isinstance(grid_thw, (list, tuple)):
397
+ grid_thw = [grid_thw]
398
+
399
+ position_ids, _ = self.get_rope_index(
400
+ self.merge_size,
401
+ data_dict["input_ids"],
402
+ image_grid_thw=torch.cat(grid_thw, dim=0) if grid_thw else None,
403
+ video_grid_thw=None,
404
+ second_per_grid_ts=None,
405
+ )
406
+
407
+ data_dict["position_ids"] = position_ids
408
+ data_dict["attention_mask"] = [seq_len]
409
+ # sample_type: 0=total, 1=crit, 2=mcq (mcq skips regression loss)
410
+ if criterion == "__mcq__":
411
+ data_dict["sample_type"] = 2
412
+ elif criterion == "__total__":
413
+ data_dict["sample_type"] = 0
414
+ else:
415
+ data_dict["sample_type"] = 1
416
+ data_dict["gt_score"] = score
417
+ return data_dict
418
+
419
+
qwenvl/data/data_processor.py ADDED
@@ -0,0 +1,693 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import random
3
+ import logging
4
+ import re
5
+ import time
6
+ import itertools
7
+ from dataclasses import dataclass, field
8
+ from typing import Dict, Optional, Sequence, List, Tuple, Any
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import torch
14
+ from torch.utils.data import Dataset
15
+
16
+ import transformers
17
+
18
+ from . import data_list
19
+ from .rope2d import get_rope_index_25, get_rope_index_2, get_rope_index_3
20
+
21
+ IGNORE_INDEX = -100
22
+ IMAGE_TOKEN_INDEX = 151655
23
+ VIDEO_TOKEN_INDEX = 151656
24
+ DEFAULT_IMAGE_TOKEN = "<image>"
25
+ DEFAULT_VIDEO_TOKEN = "<video>"
26
+
27
+ local_rank = None
28
+
29
+
30
+ def rank0_print(*args):
31
+ if local_rank == 0:
32
+ print(*args)
33
+
34
+
35
+ def read_jsonl(path):
36
+ with open(path, "r") as f:
37
+ return [json.loads(line) for line in f]
38
+
39
+
40
+ def _make_abs_paths(base: Path, files: str) -> str:
41
+ return f"{(base / files).resolve()}"
42
+
43
+
44
+ def update_processor_pixels(processor, data_args):
45
+ logger = logging.getLogger(__name__)
46
+
47
+ # --- Image Processor ---
48
+ ip = processor.image_processor
49
+ rank0_print("=== BEFORE IMAGE PROCESSOR PARAMETERS ===")
50
+ rank0_print(f"Image min_pixels: {getattr(ip, 'min_pixels', 'N/A')}")
51
+ rank0_print(f"Image max_pixels: {getattr(ip, 'max_pixels', 'N/A')}")
52
+ rank0_print(f"ip.size: {ip.size}")
53
+ rank0_print(f"Image size (shortest_edge): {ip.size.get('shortest_edge', 'N/A')}")
54
+ rank0_print(f"Image size (longest_edge): {ip.size.get('longest_edge', 'N/A')}")
55
+
56
+ if hasattr(ip, "min_pixels") and hasattr(ip, "max_pixels"):
57
+ ip.min_pixels = data_args.min_pixels
58
+ ip.max_pixels = data_args.max_pixels
59
+ rank0_print(f"βœ… Updated image_processor min_pixels to {data_args.min_pixels}")
60
+ rank0_print(f"βœ… Updated image_processor max_pixels to {data_args.max_pixels}")
61
+
62
+ if hasattr(ip, "size") and isinstance(ip.size, dict):
63
+ ip.size["shortest_edge"] = data_args.min_pixels
64
+ ip.size["longest_edge"] = data_args.max_pixels
65
+ rank0_print(
66
+ f"βœ… Updated image_processor size['shortest_edge'] to {data_args.min_pixels}"
67
+ )
68
+ rank0_print(
69
+ f"βœ… Updated image_processor size['longest_edge'] to {data_args.max_pixels}"
70
+ )
71
+
72
+ rank0_print("=== AFTER IMAGE PROCESSOR PARAMETERS ===")
73
+ rank0_print(f"Image min_pixels: {getattr(ip, 'min_pixels', 'N/A')}")
74
+ rank0_print(f"Image max_pixels: {getattr(ip, 'max_pixels', 'N/A')}")
75
+ rank0_print(f"Image size (shortest_edge): {ip.size.get('shortest_edge', 'N/A')}")
76
+ rank0_print(f"Image size (longest_edge): {ip.size.get('longest_edge', 'N/A')}")
77
+
78
+ # --- Video Processor ---
79
+ if hasattr(processor, "video_processor") and processor.video_processor is not None:
80
+ vp = processor.video_processor
81
+ rank0_print("\n=== BEFORE VIDEO PROCESSOR PARAMETERS ===")
82
+ rank0_print(f"Video min_pixels: {getattr(vp, 'min_pixels', 'N/A')}")
83
+ rank0_print(f"Video max_pixels: {getattr(vp, 'max_pixels', 'N/A')}")
84
+ rank0_print(f"Video min_frames: {getattr(vp, 'min_frames', 'N/A')}")
85
+ rank0_print(f"Video max_frames: {getattr(vp, 'max_frames', 'N/A')}")
86
+ rank0_print(f"Video fps: {getattr(vp, 'fps', 'N/A')}")
87
+ rank0_print(
88
+ f"Video size (shortest_edge): {vp.size.get('shortest_edge', 'N/A')}"
89
+ )
90
+ rank0_print(f"Video size (longest_edge): {vp.size.get('longest_edge', 'N/A')}")
91
+
92
+ if hasattr(vp, "min_pixels") and hasattr(vp, "max_pixels"):
93
+ vp.min_pixels = data_args.video_min_pixels
94
+ vp.max_pixels = data_args.video_max_pixels
95
+ rank0_print(
96
+ f"βœ… Updated Qwen2-VL video_processor min_pixels to {data_args.video_min_pixels}"
97
+ )
98
+ rank0_print(
99
+ f"βœ… Updated Qwen2-VL video_processor max_pixels to {data_args.video_max_pixels}"
100
+ )
101
+
102
+ if hasattr(vp, "min_frames") and hasattr(vp, "max_frames"):
103
+ vp.min_frames = data_args.video_min_frames
104
+ vp.max_frames = data_args.video_max_frames
105
+ rank0_print(
106
+ f"βœ… Updated video_processor min_frames to {data_args.video_min_frames}"
107
+ )
108
+ rank0_print(
109
+ f"βœ… Updated video_processor max_frames to {data_args.video_max_frames}"
110
+ )
111
+
112
+ if hasattr(vp, "fps"):
113
+ vp.fps = data_args.video_fps
114
+ rank0_print(f"βœ… Updated video_processor fps to {data_args.video_fps}")
115
+
116
+ if hasattr(vp, "size") and isinstance(vp.size, dict):
117
+ vp.size["shortest_edge"] = data_args.video_min_pixels
118
+ vp.size["longest_edge"] = data_args.video_max_pixels
119
+ rank0_print(
120
+ f"βœ… Updated Video size (shortest_edge): {vp.size.get('shortest_edge', 'N/A')}"
121
+ )
122
+ rank0_print(
123
+ f"βœ… Updated Video size (longest_edge): {vp.size.get('longest_edge', 'N/A')}"
124
+ )
125
+
126
+ rank0_print("=== AFTER VIDEO PROCESSOR PARAMETERS ===")
127
+ rank0_print(f"Video min_pixels: {getattr(vp, 'min_pixels', 'N/A')}")
128
+ rank0_print(f"Video max_pixels: {getattr(vp, 'max_pixels', 'N/A')}")
129
+ rank0_print(f"Video min_frames: {getattr(vp, 'min_frames', 'N/A')}")
130
+ rank0_print(f"Video max_frames: {getattr(vp, 'max_frames', 'N/A')}")
131
+ rank0_print(f"Video fps: {getattr(vp, 'fps', 'N/A')}")
132
+ rank0_print(
133
+ f"Video size (shortest_edge): {vp.size.get('shortest_edge', 'N/A')}"
134
+ )
135
+ rank0_print(f"Video size (longest_edge): {vp.size.get('longest_edge', 'N/A')}")
136
+
137
+ return processor
138
+
139
+
140
+ def _build_messages(item: Dict[str, Any], base_path: Path) -> List[Dict[str, Any]]:
141
+ # Extract and normalize images and videos
142
+ images = item.get("image") or []
143
+ if isinstance(images, str):
144
+ images = [images]
145
+
146
+ videos = item.get("video") or []
147
+ if isinstance(videos, str):
148
+ videos = [videos]
149
+
150
+ # Build media pools with absolute paths
151
+ image_pool = [
152
+ {"type": "image", "image": _make_abs_paths(base_path, img)} for img in images
153
+ ]
154
+ video_pool = [
155
+ {"type": "video", "video": _make_abs_paths(base_path, vid)} for vid in videos
156
+ ]
157
+
158
+ messages = []
159
+ for turn in item["conversations"]:
160
+ role = "user" if turn["from"] == "human" else "assistant"
161
+ text: str = turn["value"]
162
+
163
+ if role == "user":
164
+ content = []
165
+ # Split text by <image> or <video> placeholders while keeping delimiters
166
+ text_parts = re.split(r"(<image>|<video>)", text)
167
+
168
+ for seg in text_parts:
169
+ if seg == "<image>":
170
+ if not image_pool:
171
+ raise ValueError(
172
+ "Number of <image> placeholders exceeds the number of provided images"
173
+ )
174
+ content.append(image_pool.pop(0))
175
+ elif seg == "<video>":
176
+ if not video_pool:
177
+ raise ValueError(
178
+ "Number of <video> placeholders exceeds the number of provided videos"
179
+ )
180
+ content.append(video_pool.pop(0))
181
+ elif seg.strip():
182
+ content.append({"type": "text", "text": seg.strip()})
183
+
184
+ messages.append({"role": role, "content": content})
185
+ else:
186
+ # Assistant messages contain only text
187
+ messages.append({"role": role, "content": [{"type": "text", "text": text}]})
188
+
189
+ # Check for unused media files
190
+ if image_pool:
191
+ raise ValueError(
192
+ f"{len(image_pool)} image(s) remain unused (not consumed by placeholders)"
193
+ )
194
+ if video_pool:
195
+ raise ValueError(
196
+ f"{len(video_pool)} video(s) remain unused (not consumed by placeholders)"
197
+ )
198
+
199
+ return messages
200
+
201
+
202
+ def preprocess_qwen_visual(
203
+ sources,
204
+ processor,
205
+ ) -> Dict:
206
+ if len(sources) != 1:
207
+ raise ValueError(f"Expected 1 source, got {len(sources)}")
208
+
209
+ source = sources[0]
210
+ base_path = Path(source.get("data_path", ""))
211
+ messages = _build_messages(source, base_path)
212
+
213
+ full_result = processor.apply_chat_template(
214
+ messages, tokenize=True, return_dict=True, return_tensors="pt"
215
+ )
216
+
217
+ input_ids = full_result["input_ids"]
218
+ if isinstance(input_ids, list):
219
+ input_ids = torch.tensor(input_ids).unsqueeze(0)
220
+
221
+ labels = torch.full_like(input_ids, IGNORE_INDEX)
222
+
223
+ input_ids_flat = input_ids[0].tolist()
224
+ L = len(input_ids_flat)
225
+ pos = 0
226
+ while pos < L:
227
+ if input_ids_flat[pos] == 77091:
228
+ ans_start = pos + 2
229
+ ans_end = ans_start
230
+ while ans_end < L and input_ids_flat[ans_end] != 151645:
231
+ ans_end += 1
232
+ if ans_end < L:
233
+ labels[0, ans_start : ans_end + 2] = input_ids[
234
+ 0, ans_start : ans_end + 2
235
+ ]
236
+ pos = ans_end
237
+ pos += 1
238
+
239
+ full_result["labels"] = labels
240
+ full_result["input_ids"] = input_ids
241
+ return full_result
242
+
243
+
244
+ class LazySupervisedDataset(Dataset):
245
+ """Dataset for supervised fine-tuning."""
246
+
247
+ def __init__(self, processor, data_args):
248
+ super(LazySupervisedDataset, self).__init__()
249
+
250
+ dataset = data_args.dataset_use.split(",")
251
+ dataset_list = data_list(dataset)
252
+ rank0_print(f"Loading datasets: {dataset_list}")
253
+ self.video_max_total_pixels = getattr(
254
+ data_args, "video_max_total_pixels", 1664 * 28 * 28
255
+ )
256
+ self.video_min_total_pixels = getattr(
257
+ data_args, "video_min_total_pixels", 256 * 28 * 28
258
+ )
259
+ self.model_type = data_args.model_type
260
+ if data_args.model_type == "qwen3vl":
261
+ self.get_rope_index = get_rope_index_3
262
+ elif data_args.model_type == "qwen2.5vl":
263
+ self.get_rope_index = get_rope_index_25
264
+ elif data_args.model_type == "qwen2vl":
265
+ self.get_rope_index = get_rope_index_2
266
+ else:
267
+ raise ValueError(f"model_type: {data_args.model_type} not supported")
268
+
269
+ list_data_dict = []
270
+
271
+ for data in dataset_list:
272
+ file_format = data["annotation_path"].split(".")[-1]
273
+ if file_format == "jsonl":
274
+ annotations = read_jsonl(data["annotation_path"])
275
+ else:
276
+ annotations = json.load(open(data["annotation_path"], "r"))
277
+ sampling_rate = data.get("sampling_rate", 1.0)
278
+ if sampling_rate < 1.0:
279
+ annotations = random.sample(
280
+ annotations, int(len(annotations) * sampling_rate)
281
+ )
282
+ rank0_print(f"sampling {len(annotations)} examples from dataset {data}")
283
+ else:
284
+ rank0_print(f"dataset name: {data}")
285
+ for ann in annotations:
286
+ if isinstance(ann, list):
287
+ for sub_ann in ann:
288
+ sub_ann["data_path"] = data["data_path"]
289
+ else:
290
+ ann["data_path"] = data["data_path"]
291
+ list_data_dict += annotations
292
+
293
+ rank0_print(f"Total training samples: {len(list_data_dict)}")
294
+
295
+
296
+ rank0_print("Formatting inputs...Skip in lazy mode")
297
+ processor = update_processor_pixels(processor, data_args)
298
+ self.processor = processor
299
+ self.tokenizer = processor.tokenizer
300
+ self.data_args = data_args
301
+ self.merge_size = getattr(processor.image_processor, "merge_size", 2)
302
+ self.list_data_dict = list_data_dict
303
+
304
+ if data_args.data_packing:
305
+ self.item_fn = self._get_packed_item
306
+ else:
307
+ self.item_fn = self._get_item
308
+
309
+ def __len__(self):
310
+ return len(self.list_data_dict)
311
+
312
+ @property
313
+ def lengths(self):
314
+ length_list = []
315
+ for sample in self.list_data_dict:
316
+ img_tokens = 128 if "image" in sample else 0
317
+ length_list.append(
318
+ sum(len(conv["value"].split()) for conv in sample["conversations"])
319
+ + img_tokens
320
+ )
321
+ return length_list
322
+
323
+ @property
324
+ def modality_lengths(self):
325
+ length_list = []
326
+ for sample in self.list_data_dict:
327
+ cur_len = sum(
328
+ len(conv["value"].split()) for conv in sample["conversations"]
329
+ )
330
+ cur_len = (
331
+ cur_len if ("image" in sample) or ("video" in sample) else -cur_len
332
+ )
333
+ length_list.append(cur_len)
334
+ return length_list
335
+
336
+ @property
337
+ def pre_calculated_length(self):
338
+ if "num_tokens" in self.list_data_dict[0]:
339
+ length_list = [sample["num_tokens"] for sample in self.list_data_dict]
340
+ return np.array(length_list)
341
+ else:
342
+ print("No pre-calculated length available.")
343
+ return np.array([1] * len(self.list_data_dict))
344
+
345
+ def __getitem__(self, i) -> Dict[str, torch.Tensor]:
346
+ num_base_retries = 3
347
+ num_final_retries = 30
348
+
349
+ # try the current sample first
350
+ for attempt_idx in range(num_base_retries):
351
+ try:
352
+ sources = self.list_data_dict[i]
353
+ if isinstance(sources, dict):
354
+ sources = [sources]
355
+ sample = self.item_fn(sources)
356
+ return sample
357
+ except Exception as e:
358
+ # sleep 1s in case it is a cloud disk issue
359
+ print(f"[Try #{attempt_idx}] Failed to fetch sample {i}. Exception:", e)
360
+ time.sleep(1)
361
+
362
+ # try other samples, in case it is file corruption issue
363
+ for attempt_idx in range(num_base_retries):
364
+ try:
365
+ next_index = min(i + 1, len(self.list_data_dict) - 1)
366
+ sources = self.list_data_dict[next_index]
367
+ if isinstance(sources, dict):
368
+ sources = [sources]
369
+
370
+ sample = self.item_fn(sources)
371
+ return sample
372
+ except Exception as e:
373
+ # no need to sleep
374
+ print(
375
+ f"[Try other #{attempt_idx}] Failed to fetch sample {next_index}. Exception:",
376
+ e,
377
+ )
378
+ pass
379
+
380
+ try:
381
+ sources = self.list_data_dict[i]
382
+ if isinstance(sources, dict):
383
+ sources = [sources]
384
+ sample = self.item_fn(sources)
385
+ return sample
386
+ except Exception as e:
387
+ raise e
388
+
389
+ def _get_item(self, sources) -> Dict[str, torch.Tensor]:
390
+ data_dict = preprocess_qwen_visual(
391
+ sources,
392
+ self.processor,
393
+ )
394
+
395
+ seq_len = data_dict["input_ids"][0].size(0)
396
+
397
+ if "image_grid_thw" in data_dict:
398
+ grid_thw = data_dict.get("image_grid_thw")
399
+ if not isinstance(grid_thw, Sequence):
400
+ grid_thw = [grid_thw]
401
+ else:
402
+ grid_thw = None
403
+
404
+ if "video_grid_thw" in data_dict:
405
+ video_grid_thw = data_dict.get("video_grid_thw")
406
+ if not isinstance(video_grid_thw, Sequence):
407
+ video_grid_thw = [video_grid_thw]
408
+ second_per_grid_ts = [
409
+ self.processor.video_processor.temporal_patch_size
410
+ / self.processor.video_processor.fps
411
+ ] * len(video_grid_thw)
412
+ else:
413
+ video_grid_thw = None
414
+ second_per_grid_ts = None
415
+
416
+ position_ids, _ = self.get_rope_index(
417
+ self.merge_size,
418
+ data_dict["input_ids"],
419
+ image_grid_thw=torch.cat(grid_thw, dim=0) if grid_thw else None,
420
+ video_grid_thw=(
421
+ torch.cat(video_grid_thw, dim=0) if video_grid_thw else None
422
+ ),
423
+ second_per_grid_ts=second_per_grid_ts if second_per_grid_ts else None,
424
+ )
425
+
426
+ data_dict["position_ids"] = position_ids
427
+ data_dict["attention_mask"] = [seq_len]
428
+
429
+ text = self.processor.tokenizer.decode(
430
+ data_dict["input_ids"][0], skip_special_tokens=False
431
+ )
432
+
433
+ labels = data_dict["labels"][0]
434
+ labels = [
435
+ tid if tid != -100 else self.processor.tokenizer.pad_token_id
436
+ for tid in labels
437
+ ]
438
+ label = self.processor.tokenizer.decode(labels, skip_special_tokens=False)
439
+
440
+ return data_dict
441
+
442
+ def _get_packed_item(self, sources) -> Dict[str, torch.Tensor]:
443
+
444
+ if isinstance(sources, dict):
445
+ if isinstance(source, dict):
446
+ sources = [sources]
447
+ assert len(sources) == 1, "Don't know why it is wrapped to a list" # FIXME
448
+ return self._get_item(sources)
449
+
450
+ if isinstance(sources, list):
451
+ data_list = []
452
+ new_data_dict = {}
453
+ for source in sources:
454
+ if isinstance(source, dict):
455
+ source = [source]
456
+ assert (
457
+ len(source) == 1
458
+ ), f"Don't know why it is wrapped to a list.\n {source}" # FIXME
459
+ data_list.append(self._get_item(source))
460
+
461
+ input_ids = torch.cat([d["input_ids"] for d in data_list], dim=1)
462
+ labels = torch.cat([d["labels"] for d in data_list], dim=1)
463
+ position_ids = torch.cat([d["position_ids"] for d in data_list], dim=2)
464
+ attention_mask = [
465
+ d["attention_mask"][0] for d in data_list if "attention_mask" in d
466
+ ]
467
+ new_data_dict = {
468
+ "input_ids": input_ids,
469
+ "labels": labels,
470
+ "position_ids": position_ids,
471
+ "attention_mask": attention_mask if attention_mask else None,
472
+ }
473
+
474
+ if any("pixel_values" in d for d in data_list):
475
+ new_data_dict.update(
476
+ {
477
+ "pixel_values": torch.cat(
478
+ [
479
+ d["pixel_values"]
480
+ for d in data_list
481
+ if "pixel_values" in d
482
+ ],
483
+ dim=0,
484
+ ),
485
+ "image_grid_thw": torch.cat(
486
+ [
487
+ d["image_grid_thw"]
488
+ for d in data_list
489
+ if "image_grid_thw" in d
490
+ ],
491
+ dim=0,
492
+ ),
493
+ }
494
+ )
495
+
496
+ if any("pixel_values_videos" in d for d in data_list):
497
+ new_data_dict.update(
498
+ {
499
+ "pixel_values_videos": torch.cat(
500
+ [
501
+ d["pixel_values_videos"]
502
+ for d in data_list
503
+ if "pixel_values_videos" in d
504
+ ],
505
+ dim=0,
506
+ ),
507
+ "video_grid_thw": torch.cat(
508
+ [
509
+ d["video_grid_thw"]
510
+ for d in data_list
511
+ if "video_grid_thw" in d
512
+ ],
513
+ dim=0,
514
+ ),
515
+ }
516
+ )
517
+ return new_data_dict
518
+
519
+
520
+ def pad_and_cat(tensor_list):
521
+ max_length = max(tensor.shape[2] for tensor in tensor_list)
522
+
523
+ padded_tensors = []
524
+ for tensor in tensor_list:
525
+ pad_length = max_length - tensor.shape[2]
526
+ padded_tensor = torch.nn.functional.pad(tensor, (0, pad_length), "constant", 1)
527
+ padded_tensors.append(padded_tensor)
528
+
529
+ stacked_tensor = torch.cat(padded_tensors, dim=1)
530
+
531
+ return stacked_tensor
532
+
533
+
534
+ @dataclass
535
+ class DataCollatorForSupervisedDataset(object):
536
+ """Collate examples for supervised fine-tuning."""
537
+
538
+ tokenizer: transformers.PreTrainedTokenizer
539
+
540
+ def __call__(self, instances: Sequence[Dict]) -> Dict[str, torch.Tensor]:
541
+ input_ids, labels, position_ids = tuple(
542
+ [instance[key] for instance in instances]
543
+ for key in ("input_ids", "labels", "position_ids")
544
+ )
545
+ input_ids = [ids.squeeze(0) for ids in input_ids]
546
+ labels = [ids.squeeze(0) for ids in labels]
547
+ input_ids = torch.nn.utils.rnn.pad_sequence(
548
+ input_ids, batch_first=True, padding_value=self.tokenizer.pad_token_id
549
+ )
550
+ labels = torch.nn.utils.rnn.pad_sequence(
551
+ labels, batch_first=True, padding_value=IGNORE_INDEX
552
+ )
553
+ position_ids = pad_and_cat(position_ids)
554
+ input_ids = input_ids[:, : self.tokenizer.model_max_length]
555
+ labels = labels[:, : self.tokenizer.model_max_length]
556
+ position_ids = position_ids[:, :, : self.tokenizer.model_max_length]
557
+ batch = dict(
558
+ input_ids=input_ids,
559
+ labels=labels,
560
+ attention_mask=input_ids.ne(self.tokenizer.pad_token_id),
561
+ )
562
+ images = list(
563
+ instance["pixel_values"]
564
+ for instance in instances
565
+ if "pixel_values" in instance
566
+ )
567
+ videos = list(
568
+ instance["pixel_values_videos"]
569
+ for instance in instances
570
+ if "pixel_values_videos" in instance
571
+ )
572
+ if len(images) != 0:
573
+ concat_images = torch.cat([image for image in images], dim=0)
574
+ grid_thw = [
575
+ instance["image_grid_thw"]
576
+ for instance in instances
577
+ if "image_grid_thw" in instance
578
+ ]
579
+ grid_thw = torch.cat(grid_thw, dim=0)
580
+ else:
581
+ concat_images = None
582
+ grid_thw = None
583
+
584
+ if len(videos) != 0:
585
+ concat_videos = torch.cat([video for video in videos], dim=0)
586
+ video_grid_thw = [
587
+ instance["video_grid_thw"]
588
+ for instance in instances
589
+ if "video_grid_thw" in instance
590
+ ]
591
+ video_grid_thw = torch.cat(video_grid_thw, dim=0)
592
+ else:
593
+ concat_videos = None
594
+ video_grid_thw = None
595
+
596
+ batch["pixel_values"] = concat_images
597
+ batch["image_grid_thw"] = grid_thw
598
+ batch["pixel_values_videos"] = concat_videos
599
+ batch["video_grid_thw"] = video_grid_thw
600
+ batch["position_ids"] = position_ids
601
+ return batch
602
+
603
+
604
+ @dataclass
605
+ class FlattenedDataCollatorForSupervisedDataset(DataCollatorForSupervisedDataset):
606
+ """Collate examples into packed sequence with multi-modal support."""
607
+
608
+ tokenizer: transformers.PreTrainedTokenizer
609
+
610
+ def __call__(self, instances: Sequence[Dict]) -> Dict[str, torch.Tensor]:
611
+ input_ids, labels, position_ids, attention_mask = tuple(
612
+ [instance[key] for instance in instances]
613
+ for key in ("input_ids", "labels", "position_ids", "attention_mask")
614
+ )
615
+ attention_mask = list(
616
+ itertools.chain(
617
+ *(
618
+ instance["attention_mask"]
619
+ for instance in instances
620
+ if "attention_mask" in instance
621
+ )
622
+ )
623
+ )
624
+ seq_lens = torch.tensor([0] + attention_mask, dtype=torch.int32)
625
+ cumsum_seq_lens = torch.cumsum(seq_lens, dim=0, dtype=torch.int32)
626
+ input_ids = torch.cat(input_ids, dim=1)
627
+ labels = torch.cat(labels, dim=1)
628
+ position_ids = torch.cat(position_ids, dim=2)
629
+
630
+ batch = dict(
631
+ input_ids=input_ids,
632
+ labels=labels,
633
+ attention_mask=cumsum_seq_lens,
634
+ position_ids=position_ids,
635
+ )
636
+ images = list(
637
+ instance["pixel_values"]
638
+ for instance in instances
639
+ if "pixel_values" in instance
640
+ )
641
+ videos = list(
642
+ instance["pixel_values_videos"]
643
+ for instance in instances
644
+ if "pixel_values_videos" in instance
645
+ )
646
+ if len(images) != 0:
647
+ concat_images = torch.cat([image for image in images], dim=0)
648
+ grid_thw = [
649
+ instance["image_grid_thw"]
650
+ for instance in instances
651
+ if "image_grid_thw" in instance
652
+ ]
653
+ grid_thw = torch.cat(grid_thw, dim=0)
654
+ else:
655
+ concat_images = None
656
+ grid_thw = None
657
+
658
+ if len(videos) != 0:
659
+ concat_videos = torch.cat([video for video in videos], dim=0)
660
+ video_grid_thw = [
661
+ instance["video_grid_thw"]
662
+ for instance in instances
663
+ if "video_grid_thw" in instance
664
+ ]
665
+ video_grid_thw = torch.cat(video_grid_thw, dim=0)
666
+ else:
667
+ concat_videos = None
668
+ video_grid_thw = None
669
+
670
+ batch["pixel_values"] = concat_images
671
+ batch["image_grid_thw"] = grid_thw
672
+ batch["pixel_values_videos"] = concat_videos
673
+ batch["video_grid_thw"] = video_grid_thw
674
+
675
+ return batch
676
+
677
+
678
+ def make_supervised_data_module(processor, data_args) -> Dict:
679
+ """Make dataset and collator for supervised fine-tuning."""
680
+ train_dataset = LazySupervisedDataset(processor, data_args=data_args)
681
+ if data_args.data_flatten or data_args.data_packing:
682
+ data_collator = FlattenedDataCollatorForSupervisedDataset(processor.tokenizer)
683
+ return dict(
684
+ train_dataset=train_dataset, eval_dataset=None, data_collator=data_collator
685
+ )
686
+ data_collator = DataCollatorForSupervisedDataset(processor.tokenizer)
687
+ return dict(
688
+ train_dataset=train_dataset, eval_dataset=None, data_collator=data_collator
689
+ )
690
+
691
+
692
+ if __name__ == "__main__":
693
+ pass
qwenvl/train/__init__.py ADDED
File without changes
qwenvl/train/argument.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import transformers
2
+ from dataclasses import dataclass, field
3
+ from typing import Dict, Optional, Sequence, List
4
+
5
+
6
+ @dataclass
7
+ class ModelArguments:
8
+ model_name_or_path: Optional[str] = field(default="Qwen/Qwen2.5-VL-3B-Instruct")
9
+ tune_mm_llm: bool = field(default=False)
10
+ tune_mm_mlp: bool = field(default=False)
11
+ tune_mm_vision: bool = field(default=False)
12
+
13
+ @dataclass
14
+ class DataArguments:
15
+ dataset_use: str = field(default="")
16
+ data_flatten: bool = field(default=False)
17
+ data_packing: bool = field(default=False)
18
+ base_interval: int = field(default=2)
19
+ max_pixels: int = field(default=28 * 28 * 576)
20
+ min_pixels: int = field(default=28 * 28 * 16)
21
+ video_max_frames: Optional[int] = field(default=8)
22
+ video_min_frames: Optional[int] = field(default=4)
23
+ video_max_pixels: int = field(default=1024 * 28 * 28)
24
+ video_min_pixels: int = field(default=256 * 28 * 28)
25
+ video_fps: float = 2
26
+ # Aesthetic scoring
27
+ use_score_tokens: bool = field(default=False,
28
+ metadata={"help": "Enable custom score tokens for aesthetic scoring tasks."})
29
+ use_existing_vocab_tokens: bool = field(default=False,
30
+ metadata={"help": "Use existing vocab tokens (aa, ab, ...) as score bins instead of adding new tokens."})
31
+ use_placeholder: bool = field(default=True,
32
+ metadata={"help": "Use placeholder tokens for score regression. If False, use direct score bin tokens."})
33
+ crit_score_bins: int = field(default=20,
34
+ metadata={"help": "Number of bins for criteria scores (1-10). Tokens: <score1>..<scoreN>."})
35
+ total_score_bins: int = field(default=101,
36
+ metadata={"help": "Number of bins for total score (0-100). Tokens: <total1>..<totalN>."})
37
+
38
+
39
+ @dataclass
40
+ class TrainingArguments(transformers.TrainingArguments):
41
+ cache_dir: Optional[str] = field(default=None)
42
+ optim: str = field(default="adamw_torch")
43
+ model_max_length: int = field(
44
+ default=512,
45
+ metadata={
46
+ "help": "Maximum sequence length. Sequences will be right padded (and possibly truncated)."
47
+ },
48
+ )
49
+ mm_projector_lr: Optional[float] = None
50
+ vision_tower_lr: Optional[float] = None
51
+
52
+ ## Lora config
53
+ lora_enable: bool = field(default=False)
54
+ lora_r: int = field(default=64)
55
+ lora_alpha: int = field(default=128)
56
+ lora_dropout: float = field(default=0.0)
57
+
58
+ ## Local logging
59
+ sample_every_steps: int = field(default=500,
60
+ metadata={"help": "Run model generation samples every N steps and save to output_dir/model_output_samples.txt."})
61
+
62
+ ## Score loss config
63
+ score_ce_weight: float = field(default=1.0,
64
+ metadata={"help": "Weight for CE loss in aesthetic scoring."})
65
+ crit_reg_weight: float = field(default=0.5,
66
+ metadata={"help": "Weight for criteria (1-10) regression loss."})
67
+ total_reg_weight: float = field(default=0.5,
68
+ metadata={"help": "Weight for total-score (0-100) regression loss."})
69
+ score_regression_loss: str = field(default="huber",
70
+ metadata={"help": "Regression loss type: 'mse' or 'huber'."})
qwenvl/train/logging_callback.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Local training logger callback.
3
+
4
+ Writes two files to output_dir:
5
+ - train_metrics.jsonl : one JSON line per logging step with loss / grad_norm / lr / step
6
+ - model_output_samples.txt : periodic model generation samples showing <think> + score token
7
+ """
8
+
9
+ import csv
10
+ import json
11
+ import os
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ import torch
17
+ from transformers import TrainerCallback, TrainerControl, TrainerState, TrainingArguments
18
+
19
+
20
+ class LocalLoggingCallback(TrainerCallback):
21
+ """
22
+ Args:
23
+ processor: AutoProcessor (for generation + decoding).
24
+ sample_dataset: A small list of dicts with keys image_path / question / gt_score,
25
+ used for periodic generation samples. Pass None to skip.
26
+ sample_every_steps: How often (in steps) to run generation samples.
27
+ output_dir: Where to write log files (defaults to training_args.output_dir).
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ processor,
33
+ sample_dataset=None,
34
+ sample_every_steps: int = 500,
35
+ output_dir: Optional[str] = None,
36
+ ):
37
+ self.processor = processor
38
+ self.sample_dataset = sample_dataset or []
39
+ self.sample_every_steps = sample_every_steps
40
+ self._output_dir = output_dir
41
+ self._metrics_path = None
42
+ self._samples_path = None
43
+ self._csv_writer = None
44
+ self._csv_file = None
45
+
46
+ # ── setup ─────────────────────────────────────────────────────────────────
47
+
48
+ def on_train_begin(self, args: TrainingArguments, state: TrainerState, control: TrainerControl, **kwargs):
49
+ out = Path(self._output_dir or args.output_dir)
50
+ out.mkdir(parents=True, exist_ok=True)
51
+ self._metrics_path = out / "train_metrics.jsonl"
52
+ self._samples_path = out / "model_output_samples.txt"
53
+ if args.local_rank in (-1, 0):
54
+ with open(self._samples_path, "a") as f:
55
+ f.write(f"\n{'='*60}\nTraining started: {time.strftime('%Y-%m-%d %H:%M:%S')}\n{'='*60}\n")
56
+
57
+ # ── metrics logging ───────────────────────────────────────────────────────
58
+
59
+ def on_log(self, args: TrainingArguments, state: TrainerState, control: TrainerControl, logs=None, **kwargs):
60
+ if args.local_rank not in (-1, 0):
61
+ return
62
+ if logs is None:
63
+ return
64
+
65
+ record = {
66
+ "step": state.global_step,
67
+ "epoch": round(state.epoch or 0, 4),
68
+ "loss": logs.get("loss"),
69
+ "grad_norm": logs.get("grad_norm"),
70
+ "lr": logs.get("learning_rate"),
71
+ "wall_time": time.time(),
72
+ }
73
+ record = {k: v for k, v in record.items() if v is not None}
74
+
75
+ with open(self._metrics_path, "a") as f:
76
+ f.write(json.dumps(record) + "\n")
77
+
78
+ # ── generation samples ────────────────────────────────────────────────────
79
+
80
+ def on_step_end(self, args: TrainingArguments, state: TrainerState, control: TrainerControl, model=None, **kwargs):
81
+ if args.local_rank not in (-1, 0):
82
+ return
83
+ if not self.sample_dataset:
84
+ return
85
+ if state.global_step % self.sample_every_steps != 0:
86
+ return
87
+ self._run_samples(model, state.global_step, args)
88
+
89
+ def on_train_end(self, args: TrainingArguments, state: TrainerState, control: TrainerControl, model=None, **kwargs):
90
+ if args.local_rank not in (-1, 0):
91
+ return
92
+ if self.sample_dataset and model is not None:
93
+ self._run_samples(model, state.global_step, args, label="[FINAL]")
94
+
95
+ def _run_samples(self, model, step: int, args: TrainingArguments, label: str = ""):
96
+ model.eval()
97
+ lines = [f"\n--- Step {step} {label} {time.strftime('%H:%M:%S')} ---\n"]
98
+
99
+ with torch.no_grad():
100
+ for item in self.sample_dataset:
101
+ image_path = item["image_path"]
102
+ question = item["question"]
103
+ gt_score = item.get("gt_score", "?")
104
+
105
+ messages = [
106
+ {
107
+ "role": "user",
108
+ "content": [
109
+ {"type": "image", "image": image_path},
110
+ {"type": "text", "text": question},
111
+ ],
112
+ }
113
+ ]
114
+ try:
115
+ inputs = self.processor.apply_chat_template(
116
+ messages, tokenize=True, return_dict=True,
117
+ return_tensors="pt", add_generation_prompt=True,
118
+ )
119
+ inputs = {k: v.to(model.device) if hasattr(v, "to") else v
120
+ for k, v in inputs.items()}
121
+
122
+ out_ids = model.generate(
123
+ **inputs,
124
+ max_new_tokens=300,
125
+ do_sample=False,
126
+ )
127
+ # decode only the generated tokens
128
+ generated = out_ids[0][inputs["input_ids"].shape[1]:]
129
+ text = self.processor.tokenizer.decode(generated, skip_special_tokens=False)
130
+
131
+ lines.append(f"[Q] {question[:80]}")
132
+ lines.append(f"[GT score] {gt_score}")
133
+ lines.append(f"[Model output]\n{text}\n")
134
+
135
+ except Exception as e:
136
+ lines.append(f"[ERROR] {e}\n")
137
+
138
+ with open(self._samples_path, "a") as f:
139
+ f.write("\n".join(lines) + "\n")
140
+
141
+ model.train()
qwenvl/train/score_loss.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Score loss for aesthetic scoring tasks.
3
+
4
+ Total loss = ce_weight * CE_loss + reg_weight * regression_loss
5
+
6
+ The regression branch is selected by `sample_type`:
7
+ - sample_type == 1 ("crit"): use crit_reg_weight + criteria token ids/values
8
+ - sample_type == 0 ("total"): use total_reg_weight + total token ids/values
9
+
10
+ Because the dataset produces one criterion or one total-score question per sample,
11
+ a batch is homogeneous in type and we route once per forward pass β€” no scanning
12
+ of both heads.
13
+ """
14
+
15
+ import torch
16
+ import torch.nn as nn
17
+ import torch.nn.functional as F
18
+ from typing import List, Optional
19
+
20
+ from .score_tokens import ScoreTokenRegistry
21
+
22
+ IGNORE_INDEX = -100
23
+
24
+ SAMPLE_TYPE_TOTAL = 0
25
+ SAMPLE_TYPE_CRIT = 1
26
+
27
+
28
+ class ScoreLoss(nn.Module):
29
+ """
30
+ Args:
31
+ crit_registry: ScoreTokenRegistry for criteria scores (1–10).
32
+ total_registry: ScoreTokenRegistry for total score (0–100).
33
+ tokenizer: HF tokenizer with the custom score tokens already added.
34
+ ce_weight: Weight for the CE (language-modelling) loss.
35
+ crit_reg_weight: Weight for the criteria regression loss.
36
+ total_reg_weight: Weight for the total-score regression loss.
37
+ regression_loss: "mse" or "huber".
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ crit_registry: ScoreTokenRegistry,
43
+ total_registry: ScoreTokenRegistry,
44
+ tokenizer,
45
+ ce_weight: float = 1.0,
46
+ crit_reg_weight: float = 0.5,
47
+ total_reg_weight: float = 0.5,
48
+ regression_loss: str = "mse",
49
+ ):
50
+ super().__init__()
51
+ self.ce_weight = ce_weight
52
+ self.crit_reg_weight = crit_reg_weight
53
+ self.total_reg_weight = total_reg_weight
54
+ self.regression_loss_type = regression_loss
55
+
56
+ # ── criteria head ─────────────────────────────────────────────────────
57
+ self.crit_ids = crit_registry.get_token_ids(tokenizer)
58
+ self.register_buffer("crit_vals", crit_registry.get_value_tensor())
59
+ self.crit_range = crit_registry.score_max - crit_registry.score_min
60
+ self.crit_use_placeholder = crit_registry.use_placeholder
61
+ self.crit_ph_id = crit_registry.get_placeholder_id(tokenizer) if crit_registry.use_placeholder else None
62
+
63
+ # ── total head ────────────────────────────────────────────────────────
64
+ self.total_ids = total_registry.get_token_ids(tokenizer)
65
+ self.register_buffer("total_vals", total_registry.get_value_tensor())
66
+ self.total_range = total_registry.score_max - total_registry.score_min
67
+ self.total_use_placeholder = total_registry.use_placeholder
68
+ self.total_ph_id = total_registry.get_placeholder_id(tokenizer) if total_registry.use_placeholder else None
69
+
70
+ # ── helpers ───────────────────────────────────────────────────────────────
71
+
72
+ def _reg_loss(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
73
+ # if self.regression_loss_type == "huber":
74
+ # return F.huber_loss(pred, target, delta=1.0)
75
+ return F.mse_loss(pred, target)
76
+
77
+ def _regression_for_head(
78
+ self,
79
+ flat_logits: torch.Tensor, # (B*(T-1), V)
80
+ flat_labels: torch.Tensor, # (B*(T-1),)
81
+ token_ids: List[int],
82
+ vals: torch.Tensor, # (n_bins,)
83
+ score_range: float,
84
+ gt_scores: Optional[torch.Tensor] = None, # (B,) continuous gt scores
85
+ placeholder_id: Optional[int] = None, # placeholder token id
86
+ batch_size: int = None, # needed for non-placeholder mode
87
+ ) -> Optional[torch.Tensor]:
88
+ """Compute regression loss at score positions.
89
+
90
+ If placeholder_id is set, find positions where label == placeholder_id,
91
+ then use logits over token_ids at those positions to compute expected score.
92
+ Otherwise, find the LAST score token position per sample to avoid matching
93
+ incidental occurrences of existing-vocab tokens in reasoning text.
94
+ """
95
+ if placeholder_id is not None:
96
+ mask = (flat_labels == placeholder_id)
97
+ if not mask.any():
98
+ return None
99
+ if gt_scores is None:
100
+ return None
101
+ selected_logits = flat_logits[mask]
102
+ else:
103
+ # Existing vocab tokens (aa, ab..) may appear in reasoning text.
104
+ # Find the LAST matching position per sample to get the intended score token.
105
+ ids_t = torch.tensor(token_ids, device=flat_labels.device)
106
+ token_match = (flat_labels.unsqueeze(1) == ids_t.unsqueeze(0)).any(dim=1)
107
+
108
+ if not token_match.any():
109
+ return None
110
+ if gt_scores is None:
111
+ return None
112
+
113
+ if batch_size is not None and batch_size > 0:
114
+ seq_len = flat_labels.shape[0] // batch_size
115
+ labels_2d = flat_labels.view(batch_size, seq_len)
116
+ match_2d = token_match.view(batch_size, seq_len)
117
+
118
+ # For each sample, find the last matching position
119
+ selected_indices = []
120
+ for b in range(batch_size):
121
+ positions = match_2d[b].nonzero(as_tuple=False).squeeze(-1)
122
+ if positions.numel() > 0:
123
+ last_pos = positions[-1].item()
124
+ selected_indices.append(b * seq_len + last_pos)
125
+
126
+ if not selected_indices:
127
+ return None
128
+
129
+ idx_t = torch.tensor(selected_indices, device=flat_logits.device)
130
+ selected_logits = flat_logits[idx_t]
131
+ # Align gt_scores: only keep samples that had a match
132
+ gt_scores = gt_scores[:len(selected_indices)]
133
+ else:
134
+ # Fallback: use all matches (may cause size mismatch warning)
135
+ selected_logits = flat_logits[token_match]
136
+
137
+ ids_t = torch.tensor(token_ids, device=flat_labels.device)
138
+ with torch.amp.autocast('cuda', enabled=False):
139
+ logits_fp32 = selected_logits.float()
140
+ head_logits = logits_fp32[:, ids_t] # (M, n_bins) fp32
141
+ head_logits = torch.clamp(head_logits, min=-50.0, max=50.0)
142
+ probs = torch.softmax(head_logits, dim=-1)
143
+ pred_scores = (probs * vals.float()).sum(dim=-1) # (M,) fp32
144
+ target_scores = gt_scores.float().to(pred_scores.device)
145
+ reg = self._reg_loss(pred_scores / score_range, target_scores / score_range)
146
+ return reg
147
+
148
+ # ── forward ───────────────────────────────────────────────────────────────
149
+
150
+ def forward(
151
+ self,
152
+ logits: torch.Tensor, # (B, T, V)
153
+ labels: torch.Tensor, # (B, T)
154
+ sample_type: Optional[torch.Tensor] = None, # (B,) int, 1=crit 0=total 2=mcq
155
+ gt_score: Optional[torch.Tensor] = None, # (B,) float, original continuous score
156
+ vocab_size: int = None,
157
+ ) -> torch.Tensor:
158
+ B, T, V = logits.shape
159
+
160
+ shift_logits = logits[:, :-1].contiguous() # (B, T-1, V)
161
+ shift_labels = labels[:, 1:].contiguous() # (B, T-1)
162
+
163
+ flat_logits = shift_logits.view(-1, V)
164
+ flat_labels = shift_labels.view(-1)
165
+
166
+ # ── CE loss ───────────────────────────────────────────────────────────
167
+ if torch.isnan(flat_logits).any() or torch.isinf(flat_logits).any():
168
+ print(f"[ScoreLoss] WARNING: logits contain nan/inf BEFORE ce_loss, "
169
+ f"nan_count={torch.isnan(flat_logits).sum()}, inf_count={torch.isinf(flat_logits).sum()}")
170
+ return torch.tensor(0.0, device=logits.device, requires_grad=True)
171
+
172
+ ce_loss = F.cross_entropy(flat_logits, flat_labels, ignore_index=IGNORE_INDEX)
173
+ if torch.isnan(ce_loss):
174
+ ce_loss = torch.tensor(0.0, device=logits.device)
175
+ total_loss = self.ce_weight * ce_loss
176
+
177
+ # ── Regression loss: route by sample_type ────────────────────────────
178
+ if sample_type is not None:
179
+ has_crit = (sample_type == SAMPLE_TYPE_CRIT).any()
180
+ has_total = (sample_type == SAMPLE_TYPE_TOTAL).any()
181
+ else:
182
+ has_crit = True
183
+ has_total = True
184
+
185
+ seq_len = T - 1 # after shift
186
+
187
+ if has_crit and self.crit_reg_weight > 0.0:
188
+ crit_gt = None
189
+ if gt_score is not None and sample_type is not None:
190
+ crit_mask = (sample_type == SAMPLE_TYPE_CRIT)
191
+ if crit_mask.any():
192
+ crit_gt = gt_score[crit_mask]
193
+ crit_logits = shift_logits[crit_mask].view(-1, V)
194
+ crit_labels = shift_labels[crit_mask].view(-1)
195
+ crit_B = crit_mask.sum().item()
196
+ else:
197
+ crit_gt = None
198
+ elif gt_score is not None:
199
+ crit_gt = gt_score
200
+ crit_logits = flat_logits
201
+ crit_labels = flat_labels
202
+ crit_B = B
203
+ else:
204
+ crit_gt = None
205
+
206
+ if crit_gt is not None:
207
+ reg = self._regression_for_head(
208
+ crit_logits, crit_labels,
209
+ self.crit_ids, self.crit_vals.to(logits.device),
210
+ self.crit_range, gt_scores=crit_gt,
211
+ placeholder_id=self.crit_ph_id,
212
+ batch_size=crit_B,
213
+ )
214
+ if reg is not None:
215
+ total_loss = total_loss + self.crit_reg_weight * reg
216
+
217
+ if has_total and self.total_reg_weight > 0.0:
218
+ total_gt = None
219
+ if gt_score is not None and sample_type is not None:
220
+ total_mask = (sample_type == SAMPLE_TYPE_TOTAL)
221
+ if total_mask.any():
222
+ total_gt = gt_score[total_mask]
223
+ total_logits = shift_logits[total_mask].view(-1, V)
224
+ total_labels = shift_labels[total_mask].view(-1)
225
+ total_B = total_mask.sum().item()
226
+ else:
227
+ total_gt = None
228
+ elif gt_score is not None:
229
+ total_gt = gt_score
230
+ total_logits = flat_logits
231
+ total_labels = flat_labels
232
+ total_B = B
233
+ else:
234
+ total_gt = None
235
+
236
+ if total_gt is not None:
237
+ reg = self._regression_for_head(
238
+ total_logits, total_labels,
239
+ self.total_ids, self.total_vals.to(logits.device),
240
+ self.total_range, gt_scores=total_gt,
241
+ placeholder_id=self.total_ph_id,
242
+ batch_size=total_B,
243
+ )
244
+ if reg is not None:
245
+ total_loss = total_loss + self.total_reg_weight * reg
246
+
247
+ return total_loss
qwenvl/train/score_tokens.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Generic integer-index score tokens.
3
+
4
+ A ScoreTokenRegistry holds N tokens <score1> ... <scoreN> that represent
5
+ N evenly-spaced bins covering [score_min, score_max].
6
+
7
+ The actual score value for bin k (1-indexed) is:
8
+ score_min + (k - 1) / (N - 1) * (score_max - score_min)
9
+
10
+ Usage
11
+ -----
12
+ # criteria: 1–10 range, 20 bins β†’ <score1> … <score20>
13
+ crit_reg = ScoreTokenRegistry(n_bins=20, score_min=1.0, score_max=10.0)
14
+
15
+ # total: 0–100 range, 101 bins β†’ <total1> … <total101>
16
+ total_reg = ScoreTokenRegistry(n_bins=101, score_min=0.0, score_max=100.0,
17
+ prefix="total")
18
+
19
+ all_tokens = crit_reg.tokens + total_reg.tokens
20
+ """
21
+
22
+ import string
23
+ import torch
24
+ from typing import List, Optional
25
+
26
+
27
+ def get_single_token_two_letter_combos(vocab: dict) -> List[str]:
28
+ """Return ordered list of two-letter combos that are single tokens in vocab."""
29
+ combos = []
30
+ for c1 in string.ascii_lowercase:
31
+ for c2 in string.ascii_lowercase:
32
+ t = c1 + c2
33
+ if t in vocab:
34
+ combos.append(t)
35
+ return combos
36
+
37
+
38
+ class ScoreTokenRegistry:
39
+ """
40
+ Registry for a set of evenly-spaced score tokens.
41
+
42
+ Args:
43
+ n_bins: Number of discrete bins / tokens.
44
+ score_min: Real value corresponding to bin 1.
45
+ score_max: Real value corresponding to bin n_bins.
46
+ prefix: Token prefix string (default "score").
47
+ Tokens are named <{prefix}1> … <{prefix}{n_bins}>.
48
+ use_placeholder: If True, add a placeholder token <{prefix}_ph> that
49
+ appears in text instead of specific score tokens.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ n_bins: int,
55
+ score_min: float,
56
+ score_max: float,
57
+ prefix: str = "score",
58
+ use_placeholder: bool = False,
59
+ ):
60
+ assert n_bins >= 2
61
+ self.n_bins = n_bins
62
+ self.score_min = score_min
63
+ self.score_max = score_max
64
+ self.prefix = prefix
65
+ self.use_placeholder = use_placeholder
66
+
67
+ self.tokens: list[str] = [f"<{prefix}{k}>" for k in range(1, n_bins + 1)]
68
+ self.placeholder_token: str = f"<{prefix}_ph>"
69
+
70
+ # Real value for each bin (linearly spaced)
71
+ self.values: list[float] = [
72
+ score_min + (k / (n_bins - 1)) * (score_max - score_min)
73
+ for k in range(n_bins)
74
+ ]
75
+
76
+ # Pre-computed constants for score_to_token
77
+ self._scale = (n_bins - 1) / (score_max - score_min)
78
+
79
+ # O(1) lookup tables
80
+ self._token_to_idx: dict[str, int] = {t: i for i, t in enumerate(self.tokens)}
81
+
82
+ @property
83
+ def all_tokens(self) -> list[str]:
84
+ """All tokens to register (score bins + placeholder if enabled)."""
85
+ if self.use_placeholder:
86
+ return self.tokens + [self.placeholder_token]
87
+ return self.tokens
88
+
89
+ def get_placeholder_id(self, tokenizer) -> int:
90
+ """Return the token id of the placeholder token."""
91
+ return tokenizer.convert_tokens_to_ids(self.placeholder_token)
92
+
93
+ # ── conversion helpers ────────────────────────────────────────────────────
94
+
95
+ def score_to_token(self, score: float) -> str:
96
+ """Map a real score to the nearest bin token (or placeholder if enabled)."""
97
+ if self.use_placeholder:
98
+ return self.placeholder_token
99
+ k = int(round((score - self.score_min) * self._scale))
100
+ k = max(0, min(self.n_bins - 1, k))
101
+ return self.tokens[k]
102
+
103
+ def token_to_score(self, token: str) -> float:
104
+ return self.values[self._token_to_idx[token]]
105
+
106
+ # ── tensor helpers (used at inference / loss time) ────────────────────────
107
+
108
+ def get_token_ids(self, tokenizer) -> list[int]:
109
+ """Return token ids in token order (token1, token2, …, tokenN)."""
110
+ return [tokenizer.convert_tokens_to_ids(t) for t in self.tokens]
111
+
112
+ def get_value_tensor(
113
+ self, tokenizer=None, device: Optional[torch.device] = None
114
+ ) -> torch.Tensor:
115
+ """Return float tensor of real values aligned with token order."""
116
+ t = torch.tensor(self.values, dtype=torch.float32)
117
+ return t.to(device) if device else t
118
+
119
+ def expected_score(
120
+ self,
121
+ logits: torch.Tensor,
122
+ token_ids: list[int],
123
+ value_tensor: torch.Tensor,
124
+ ) -> torch.Tensor:
125
+ """
126
+ Compute expected score from a logits slice.
127
+
128
+ Args:
129
+ logits: (vocab_size,) raw logits for one position
130
+ token_ids: ordered list of this registry's token ids
131
+ value_tensor: (n_bins,) real values matching token_ids order
132
+
133
+ Returns:
134
+ scalar expected score
135
+ """
136
+ ids_t = torch.tensor(token_ids, device=logits.device)
137
+ probs = torch.softmax(logits[ids_t], dim=-1)
138
+ return (probs * value_tensor.to(logits.device)).sum()
139
+
140
+
141
+ class ExistingVocabScoreTokenRegistry:
142
+ """
143
+ Registry that reuses existing vocabulary tokens (two-letter combos)
144
+ as score bins. Only the placeholder token (if enabled) is newly added.
145
+
146
+ Args:
147
+ token_list: Pre-selected tokens from the existing vocabulary.
148
+ score_min: Real value corresponding to the first token.
149
+ score_max: Real value corresponding to the last token.
150
+ prefix: Used only for the placeholder token name <{prefix}_ph>.
151
+ use_placeholder: If True, add a placeholder token <{prefix}_ph>.
152
+ """
153
+
154
+ def __init__(
155
+ self,
156
+ token_list: List[str],
157
+ score_min: float,
158
+ score_max: float,
159
+ prefix: str = "score",
160
+ use_placeholder: bool = False,
161
+ ):
162
+ assert len(token_list) >= 2
163
+ self.n_bins = len(token_list)
164
+ self.score_min = score_min
165
+ self.score_max = score_max
166
+ self.prefix = prefix
167
+ self.use_placeholder = use_placeholder
168
+
169
+ self.tokens: List[str] = list(token_list)
170
+ self.placeholder_token: str = f"<{prefix}_ph>"
171
+
172
+ self.values: List[float] = [
173
+ score_min + (k / (self.n_bins - 1)) * (score_max - score_min)
174
+ for k in range(self.n_bins)
175
+ ]
176
+
177
+ self._scale = (self.n_bins - 1) / (score_max - score_min)
178
+ self._token_to_idx: dict[str, int] = {t: i for i, t in enumerate(self.tokens)}
179
+
180
+ @property
181
+ def all_tokens(self) -> List[str]:
182
+ """Only the placeholder is a new token; bin tokens already exist."""
183
+ if self.use_placeholder:
184
+ return [self.placeholder_token]
185
+ return []
186
+
187
+ @property
188
+ def new_tokens(self) -> List[str]:
189
+ """Tokens that need to be added to the tokenizer."""
190
+ return self.all_tokens
191
+
192
+ def get_placeholder_id(self, tokenizer) -> int:
193
+ return tokenizer.convert_tokens_to_ids(self.placeholder_token)
194
+
195
+ def score_to_token(self, score: float) -> str:
196
+ if self.use_placeholder:
197
+ return self.placeholder_token
198
+ k = int(round((score - self.score_min) * self._scale))
199
+ k = max(0, min(self.n_bins - 1, k))
200
+ return self.tokens[k]
201
+
202
+ def token_to_score(self, token: str) -> float:
203
+ return self.values[self._token_to_idx[token]]
204
+
205
+ def get_token_ids(self, tokenizer) -> List[int]:
206
+ return [tokenizer.convert_tokens_to_ids(t) for t in self.tokens]
207
+
208
+ def get_value_tensor(
209
+ self, tokenizer=None, device: Optional[torch.device] = None
210
+ ) -> torch.Tensor:
211
+ t = torch.tensor(self.values, dtype=torch.float32)
212
+ return t.to(device) if device else t
213
+
214
+ def expected_score(
215
+ self,
216
+ logits: torch.Tensor,
217
+ token_ids: List[int],
218
+ value_tensor: torch.Tensor,
219
+ ) -> torch.Tensor:
220
+ ids_t = torch.tensor(token_ids, device=logits.device)
221
+ probs = torch.softmax(logits[ids_t], dim=-1)
222
+ return (probs * value_tensor.to(logits.device)).sum()
qwenvl/train/train_qwen.py ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Adopted from https://github.com/lm-sys/FastChat. Below is the original copyright:
2
+ # Adopted from tatsu-lab@stanford_alpaca. Below is the original copyright:
3
+ # Copyright 2023 Rohan Taori, Ishaan Gulrajani, Tianyi Zhang, Yann Dubois, Xuechen Li
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ import os
18
+ import logging
19
+ import pathlib
20
+ import torch
21
+ import transformers
22
+ import sys
23
+ from pathlib import Path
24
+ from PIL import Image
25
+ Image.MAX_IMAGE_PIXELS = None # disable decompression bomb check for large training images
26
+
27
+ project_root = Path(__file__).parent.parent.parent
28
+ sys.path.append(str(project_root))
29
+
30
+ from trainer import replace_qwen2_vl_attention_class
31
+
32
+ from transformers import (
33
+ Qwen2VLForConditionalGeneration,
34
+ Qwen2_5_VLForConditionalGeneration,
35
+ Qwen3VLForConditionalGeneration,
36
+ Qwen3VLMoeForConditionalGeneration
37
+ )
38
+ from qwenvl.data.data_processor import make_supervised_data_module
39
+ from qwenvl.data.aesthetic_dataset import AestheticScoringDataset
40
+ from qwenvl.train.argument import (
41
+ ModelArguments,
42
+ DataArguments,
43
+ TrainingArguments,
44
+ )
45
+ from qwenvl.train.score_tokens import ScoreTokenRegistry, ExistingVocabScoreTokenRegistry, get_single_token_two_letter_combos
46
+ from qwenvl.train.score_loss import ScoreLoss
47
+ from qwenvl.train.logging_callback import LocalLoggingCallback
48
+ from transformers import AutoProcessor, Trainer
49
+ from deepspeed import zero
50
+
51
+ local_rank = None
52
+
53
+
54
+ def rank0_print(*args):
55
+ if local_rank == 0:
56
+ print(*args)
57
+
58
+
59
+ def expand_vocab_for_scoring(model, tokenizer, registries):
60
+ """Add custom score tokens to tokenizer and resize model embeddings.
61
+
62
+ Score tokens are initialized along a bad→good semantic axis;
63
+ placeholder tokens get the mean embedding.
64
+ """
65
+ all_tokens = [t for reg in registries for t in reg.all_tokens]
66
+ num_added = tokenizer.add_tokens(all_tokens)
67
+ rank0_print(f"Added {num_added} score tokens to tokenizer (total vocab: {len(tokenizer)})")
68
+ model.resize_token_embeddings(len(tokenizer))
69
+
70
+ bad_words = ["bad", "terrible", "poor", "worst", "awful", "low", "weak"]
71
+ good_words = ["good", "excellent", "great", "best", "amazing", "high", "strong"]
72
+
73
+ with torch.no_grad():
74
+ w_in = model.get_input_embeddings().weight.data
75
+ w_out = model.get_output_embeddings().weight.data
76
+ base_vector = w_in[:-num_added].mean(dim=0, keepdim=True)
77
+
78
+ # Compute quality direction from semantic anchors
79
+ bad_ids = [tokenizer.convert_tokens_to_ids(w) for w in bad_words]
80
+ good_ids = [tokenizer.convert_tokens_to_ids(w) for w in good_words]
81
+ bad_vecs = torch.stack([w_in[i] for i in bad_ids if i != tokenizer.unk_token_id])
82
+ good_vecs = torch.stack([w_in[i] for i in good_ids if i != tokenizer.unk_token_id])
83
+
84
+ if len(bad_vecs) > 0 and len(good_vecs) > 0:
85
+ quality_dir = good_vecs.mean(0) - bad_vecs.mean(0)
86
+ q_norm = torch.norm(quality_dir)
87
+ if q_norm > 0:
88
+ quality_dir = quality_dir / q_norm
89
+ quality_scale = w_in[:-num_added].std() * 0.5
90
+
91
+ offset = 0
92
+ for reg in registries:
93
+ n_bins = reg.n_bins
94
+ for i in range(n_bins):
95
+ weight = 2.0 * i / (n_bins - 1) - 1.0 if n_bins > 1 else 0.0
96
+ vec = base_vector + quality_dir * weight * quality_scale
97
+ w_in[-num_added + offset + i] = vec
98
+ w_out[-num_added + offset + i] = vec
99
+ offset += n_bins
100
+ if reg.use_placeholder:
101
+ w_in[-num_added + offset] = base_vector
102
+ w_out[-num_added + offset] = base_vector
103
+ offset += 1
104
+
105
+ rank0_print(f"[init] Score tokens initialized with quality axis (bad→good), scale={quality_scale:.4f}")
106
+ else:
107
+ w_in[-num_added:] = base_vector
108
+ w_out[-num_added:] = base_vector
109
+ rank0_print("[init] Score tokens initialized with mean embedding (fallback)")
110
+
111
+ return model, tokenizer
112
+
113
+
114
+ def expand_vocab_for_existing_tokens(model, tokenizer, registries):
115
+ """Add only placeholder tokens when using existing vocab tokens as score bins.
116
+
117
+ Bin tokens (aa, ab, ...) already exist in the vocabulary β€” no embedding init needed.
118
+ Only placeholder tokens (if enabled) are newly added and initialized with mean embedding.
119
+ """
120
+ new_tokens = [t for reg in registries for t in reg.new_tokens]
121
+ if new_tokens:
122
+ num_added = tokenizer.add_tokens(new_tokens)
123
+ rank0_print(f"[existing-vocab] Added {num_added} placeholder tokens (total vocab: {len(tokenizer)})")
124
+ model.resize_token_embeddings(len(tokenizer))
125
+
126
+ with torch.no_grad():
127
+ w_in = model.get_input_embeddings().weight.data
128
+ w_out = model.get_output_embeddings().weight.data
129
+ base_vector = w_in[:-num_added].mean(dim=0, keepdim=True)
130
+ w_in[-num_added:] = base_vector
131
+ w_out[-num_added:] = base_vector
132
+ rank0_print(f"[existing-vocab] Placeholder tokens initialized with mean embedding")
133
+ else:
134
+ rank0_print("[existing-vocab] No placeholder β€” using direct score bin tokens only")
135
+
136
+ for reg in registries:
137
+ bin_ids = reg.get_token_ids(tokenizer)
138
+ rank0_print(f"[existing-vocab] {reg.prefix}: {reg.n_bins} bins using tokens "
139
+ f"{reg.tokens[0]}..{reg.tokens[-1]} (ids {bin_ids[0]}..{bin_ids[-1]})")
140
+
141
+ return model, tokenizer
142
+
143
+
144
+ class AestheticDataCollator:
145
+ """Wraps DataCollatorForSupervisedDataset and additionally collects sample_type."""
146
+
147
+ def __init__(self, base_collator):
148
+ self.base = base_collator
149
+
150
+ def __call__(self, instances):
151
+ # collect sample_type and gt_score before passing to base collator (which ignores unknown keys)
152
+ sample_types = [inst.pop("sample_type", None) for inst in instances]
153
+ gt_scores = [inst.pop("gt_score", None) for inst in instances]
154
+ batch = self.base(instances)
155
+ if any(t is not None for t in sample_types):
156
+ batch["sample_type"] = torch.tensor(
157
+ [t if t is not None else -1 for t in sample_types],
158
+ dtype=torch.long,
159
+ )
160
+ if any(s is not None for s in gt_scores):
161
+ batch["gt_score"] = torch.tensor(
162
+ [s if s is not None else 0.0 for s in gt_scores],
163
+ dtype=torch.float32,
164
+ )
165
+ return batch
166
+
167
+
168
+ class AestheticTrainer(Trainer):
169
+ """Trainer subclass that uses ScoreLoss when score tokens are enabled."""
170
+
171
+ def __init__(self, *args, score_loss: ScoreLoss = None, tokenizer=None, **kwargs):
172
+ super().__init__(*args, **kwargs)
173
+ self.score_loss = score_loss
174
+ self._decode_tokenizer = tokenizer # kept separately for decoding
175
+
176
+ def compute_loss(self, model, inputs, return_outputs=False, **kwargs):
177
+ if self.score_loss is None:
178
+ return super().compute_loss(model, inputs, return_outputs=return_outputs, **kwargs)
179
+
180
+ labels = inputs.pop("labels", None)
181
+ sample_type = inputs.pop("sample_type", None) # (B,) int tensor or None
182
+ gt_score = inputs.pop("gt_score", None) # (B,) float tensor or None
183
+ if gt_score is not None:
184
+ gt_score = gt_score.float()
185
+ outputs = model(**inputs)
186
+ logits = outputs.logits
187
+
188
+ loss = self.score_loss(logits, labels, sample_type=sample_type, gt_score=gt_score)
189
+
190
+ # ── per-batch text monitor (rank-0 only, every N steps) ───────────────
191
+ step = self.state.global_step
192
+ if (step != getattr(self, '_last_print_step', -1)
193
+ and self.args.local_rank in (-1, 0)
194
+ and self._decode_tokenizer is not None):
195
+ self._last_print_step = step
196
+ tok = self._decode_tokenizer
197
+ input_text = tok.decode(
198
+ inputs["input_ids"][0], skip_special_tokens=False
199
+ )
200
+ pred_ids = logits[0].argmax(dim=-1)
201
+ label_row = labels[0]
202
+ mask = label_row != -100
203
+ if mask.any():
204
+ gt_ids = label_row[mask]
205
+ pred_at_label_pos = pred_ids[mask.roll(-1)][:-1] if mask.sum() > 1 else pred_ids[mask]
206
+ gt_text = tok.decode(gt_ids, skip_special_tokens=False)
207
+ pred_text = tok.decode(pred_at_label_pos, skip_special_tokens=False)
208
+ if torch.distributed.get_rank() == 0:
209
+ print(f"\n[step {step}] INPUT : {input_text[-200:]}")
210
+ print(f"[step {step}] GT : {gt_text[-300:]}")
211
+ print(f"[step {step}] PRED : {pred_text[-300:]}\n", flush=True)
212
+
213
+ return (loss, outputs) if return_outputs else loss
214
+
215
+
216
+
217
+ def safe_save_model_for_hf_trainer(trainer: transformers.Trainer, output_dir: str):
218
+ """Collects the state dict and dump to disk."""
219
+
220
+ if trainer.deepspeed:
221
+ torch.cuda.synchronize()
222
+ trainer.save_model(output_dir)
223
+ return
224
+
225
+ state_dict = trainer.model.state_dict()
226
+ if trainer.args.should_save:
227
+ cpu_state_dict = {key: value.cpu() for key, value in state_dict.items()}
228
+ del state_dict
229
+ trainer._save(output_dir, state_dict=cpu_state_dict) # noqa
230
+
231
+
232
+ def set_model(model_args, model):
233
+ if model_args.tune_mm_vision:
234
+ for n, p in model.visual.named_parameters():
235
+ p.requires_grad = True
236
+ else:
237
+ for n, p in model.visual.named_parameters():
238
+ p.requires_grad = False
239
+
240
+ if model_args.tune_mm_mlp:
241
+ for n, p in model.visual.merger.named_parameters():
242
+ p.requires_grad = True
243
+ else:
244
+ for n, p in model.visual.merger.named_parameters():
245
+ p.requires_grad = False
246
+
247
+ if model_args.tune_mm_llm:
248
+ for n, p in model.language_model.named_parameters():
249
+ p.requires_grad = True
250
+ model.lm_head.requires_grad = True
251
+ else:
252
+ for n, p in model.language_model.named_parameters():
253
+ p.requires_grad = False
254
+ model.lm_head.requires_grad = False
255
+
256
+
257
+ def train(attn_implementation="flash_attention_2"):
258
+ global local_rank
259
+
260
+ parser = transformers.HfArgumentParser(
261
+ (ModelArguments, DataArguments, TrainingArguments)
262
+ )
263
+ model_args, data_args, training_args = parser.parse_args_into_dataclasses()
264
+
265
+ local_rank = training_args.local_rank
266
+ os.makedirs(training_args.output_dir, exist_ok=True)
267
+
268
+ # if "qwen3" in model_args.model_name_or_path.lower() and "a" in Path(model_args.model_name_or_path.rstrip("/")).name.lower():
269
+ # model = Qwen3VLMoeForConditionalGeneration.from_pretrained(
270
+ # model_args.model_name_or_path,
271
+ # cache_dir=training_args.cache_dir,
272
+ # attn_implementation=attn_implementation,
273
+ # dtype=(torch.bfloat16 if training_args.bf16 else None),
274
+ # )
275
+ # data_args.model_type = "qwen3vl"
276
+ if "qwen3" in model_args.model_name_or_path.lower():
277
+ model = Qwen3VLForConditionalGeneration.from_pretrained(
278
+ model_args.model_name_or_path,
279
+ cache_dir=training_args.cache_dir,
280
+ attn_implementation=attn_implementation,
281
+ dtype=(torch.bfloat16 if training_args.bf16 else None),
282
+ )
283
+ data_args.model_type = "qwen3vl"
284
+ elif "qwen2.5" in model_args.model_name_or_path.lower():
285
+ model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
286
+ model_args.model_name_or_path,
287
+ cache_dir=training_args.cache_dir,
288
+ attn_implementation=attn_implementation,
289
+ dtype=(torch.bfloat16 if training_args.bf16 else None),
290
+ )
291
+ data_args.model_type = "qwen2.5vl"
292
+ else:
293
+ model = Qwen3VLForConditionalGeneration.from_pretrained(
294
+ model_args.model_name_or_path,
295
+ cache_dir=training_args.cache_dir,
296
+ attn_implementation=attn_implementation,
297
+ dtype=(torch.bfloat16 if training_args.bf16 else None),
298
+ )
299
+ data_args.model_type = "qwen3vl"
300
+
301
+ print(f'the initlized model is {model_args.model_name_or_path} the class is {model.__class__.__name__}')
302
+ print(f'attn_implementation: {model.config._attn_implementation}')
303
+ processor = AutoProcessor.from_pretrained(
304
+ model_args.model_name_or_path,
305
+ )
306
+
307
+ # Expand vocabulary with score tokens
308
+ score_loss_fn = None
309
+ crit_registry = None
310
+ total_registry = None
311
+ if data_args.use_score_tokens:
312
+ if data_args.use_existing_vocab_tokens:
313
+ import json as _json_vocab
314
+ vocab_path = os.path.join(model_args.model_name_or_path, "vocab.json")
315
+ with open(vocab_path, "r") as f:
316
+ vocab = _json_vocab.load(f)
317
+ valid_combos = get_single_token_two_letter_combos(vocab)
318
+ n_crit = data_args.crit_score_bins
319
+ n_total = data_args.total_score_bins
320
+ assert len(valid_combos) >= n_crit + n_total, (
321
+ f"Need {n_crit + n_total} single-token combos but only {len(valid_combos)} available"
322
+ )
323
+ crit_tokens = valid_combos[:n_crit]
324
+ total_tokens = valid_combos[n_crit:n_crit + n_total]
325
+
326
+ crit_registry = ExistingVocabScoreTokenRegistry(
327
+ token_list=crit_tokens,
328
+ score_min=1.0,
329
+ score_max=10.0,
330
+ prefix="crit",
331
+ use_placeholder=data_args.use_placeholder,
332
+ )
333
+ total_registry = ExistingVocabScoreTokenRegistry(
334
+ token_list=total_tokens,
335
+ score_min=0.0,
336
+ score_max=100.0,
337
+ prefix="total",
338
+ use_placeholder=data_args.use_placeholder,
339
+ )
340
+ model, processor.tokenizer = expand_vocab_for_existing_tokens(
341
+ model, processor.tokenizer, [crit_registry, total_registry]
342
+ )
343
+ else:
344
+ crit_registry = ScoreTokenRegistry(
345
+ n_bins=data_args.crit_score_bins,
346
+ score_min=1.0,
347
+ score_max=10.0,
348
+ prefix="score",
349
+ use_placeholder=data_args.use_placeholder,
350
+ )
351
+ total_registry = ScoreTokenRegistry(
352
+ n_bins=data_args.total_score_bins,
353
+ score_min=0.0,
354
+ score_max=100.0,
355
+ prefix="total",
356
+ use_placeholder=data_args.use_placeholder,
357
+ )
358
+ model, processor.tokenizer = expand_vocab_for_scoring(
359
+ model, processor.tokenizer, [crit_registry, total_registry]
360
+ )
361
+ score_loss_fn = ScoreLoss(
362
+ crit_registry=crit_registry,
363
+ total_registry=total_registry,
364
+ tokenizer=processor.tokenizer,
365
+ ce_weight=training_args.score_ce_weight,
366
+ crit_reg_weight=training_args.crit_reg_weight,
367
+ total_reg_weight=training_args.total_reg_weight,
368
+ regression_loss=training_args.score_regression_loss,
369
+ )
370
+
371
+ if data_args.data_flatten or data_args.data_packing:
372
+ replace_qwen2_vl_attention_class()
373
+ model.config.use_cache = False
374
+
375
+ if training_args.gradient_checkpointing:
376
+ if hasattr(model, "enable_input_require_grads"):
377
+ model.enable_input_require_grads()
378
+ else:
379
+
380
+ def make_inputs_require_grad(module, input, output):
381
+ output.requires_grad_(True)
382
+
383
+ model.get_input_embeddings().register_forward_hook(make_inputs_require_grad)
384
+
385
+ tokenizer = transformers.AutoTokenizer.from_pretrained(
386
+ model_args.model_name_or_path,
387
+ cache_dir=training_args.cache_dir,
388
+ model_max_length=training_args.model_max_length,
389
+ padding_side="right",
390
+ use_fast=False,
391
+ )
392
+ if data_args.use_score_tokens:
393
+ tokenizer = processor.tokenizer
394
+
395
+ if training_args.lora_enable:
396
+ from peft import LoraConfig, get_peft_model, TaskType
397
+ print("LoRA enabled")
398
+
399
+ for p in model.parameters():
400
+ p.requires_grad = False
401
+
402
+ lora_config = LoraConfig(
403
+ r=training_args.lora_r or 64,
404
+ lora_alpha=training_args.lora_alpha or 128,
405
+ lora_dropout=training_args.lora_dropout or 0.05,
406
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], # Qwen ηš„ attention 线性层
407
+ bias="none",
408
+ task_type=TaskType.CAUSAL_LM,
409
+ )
410
+ model = get_peft_model(model, lora_config)
411
+ else:
412
+ set_model(model_args, model)
413
+
414
+ if torch.distributed.get_rank() == 0:
415
+ model.visual.print_trainable_parameters()
416
+ model.model.print_trainable_parameters()
417
+ if torch.distributed.get_rank() == 0:
418
+ for name, param in model.named_parameters():
419
+ print(f"{name}:{param.requires_grad}")
420
+
421
+ if data_args.use_score_tokens:
422
+ from qwenvl.data.data_processor import update_processor_pixels
423
+ processor = update_processor_pixels(processor, data_args)
424
+ from qwenvl.data import data_dict as _data_dict
425
+ _ds_cfg = _data_dict.get("aesthetic_track1", {})
426
+ annotation_path = os.environ.get("AESTHETIC_ANNOTATION", _ds_cfg["annotation_path"])
427
+ image_dir = os.environ.get("AESTHETIC_IMAGE_DIR", _ds_cfg["data_path"])
428
+ train_dataset = AestheticScoringDataset(
429
+ processor=processor,
430
+ annotation_path=annotation_path,
431
+ image_dir=image_dir,
432
+ data_args=data_args,
433
+ crit_registry=crit_registry,
434
+ total_registry=total_registry,
435
+ )
436
+ from qwenvl.data.data_processor import DataCollatorForSupervisedDataset
437
+ base_collator = DataCollatorForSupervisedDataset(processor.tokenizer)
438
+ data_collator = AestheticDataCollator(base_collator)
439
+ data_module = dict(train_dataset=train_dataset, eval_dataset=None, data_collator=data_collator)
440
+ else:
441
+ data_module = make_supervised_data_module(processor, data_args=data_args)
442
+
443
+ # Build a small fixed sample set for generation logging (rank-0 only)
444
+ _sample_items = []
445
+ if data_args.use_score_tokens and local_rank in (-1, 0):
446
+ import json as _json
447
+ _ann_path = os.environ.get("AESTHETIC_ANNOTATION", "")
448
+ _img_root = os.environ.get("AESTHETIC_IMAGE_DIR", "")
449
+ try:
450
+ _all_raw = _json.load(open(_ann_path))
451
+ _raw = [x for x in _all_raw if "question" not in x][:4]
452
+ from qwenvl.data.aesthetic_dataset import CRITERIA_ORDER, CRITERIA_QUESTIONS
453
+ for _item in _raw:
454
+ _img = str(Path(_img_root) / _item["image_path"])
455
+ _crit = CRITERIA_ORDER[0]
456
+ _sample_items.append({
457
+ "image_path": _img,
458
+ "question": CRITERIA_QUESTIONS[_crit],
459
+ "gt_score": _item["criteria"].get(_crit, {}).get("score", "?"),
460
+ })
461
+ except Exception as _e:
462
+ rank0_print(f"[LocalLoggingCallback] Could not build sample items: {_e}")
463
+
464
+ _logging_cb = LocalLoggingCallback(
465
+ processor=processor,
466
+ sample_dataset=_sample_items,
467
+ sample_every_steps=training_args.sample_every_steps,
468
+ output_dir=training_args.output_dir,
469
+ )
470
+
471
+ trainer = AestheticTrainer(
472
+ model=model,
473
+ processing_class=tokenizer,
474
+ args=training_args,
475
+ score_loss=score_loss_fn,
476
+ tokenizer=tokenizer,
477
+ callbacks=[_logging_cb],
478
+ **data_module,
479
+ )
480
+
481
+ if list(pathlib.Path(training_args.output_dir).glob("checkpoint-*")):
482
+ logging.info("checkpoint found, resume training")
483
+ trainer.train(resume_from_checkpoint=True)
484
+ else:
485
+ trainer.train()
486
+ trainer.save_state()
487
+
488
+ model.config.use_cache = True
489
+
490
+ safe_save_model_for_hf_trainer(trainer=trainer, output_dir=training_args.output_dir)
491
+
492
+ processor.save_pretrained(training_args.output_dir)
493
+
494
+
495
+ if __name__ == "__main__":
496
+ train(attn_implementation="flash_attention_2")
scripts/sft_aesthetic.sh ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # ──────────────────────────────────────────────────────────────────────────────
3
+ # Training script for aesthetic scoring with custom score tokens.
4
+ #
5
+ # Each image is expanded into 14 independent samples:
6
+ # - 13 per-criterion samples (score range 1–10, bins=crit_score_bins)
7
+ # - 1 total-score sample (score range 0–100, bins=total_score_bins)
8
+ #
9
+ # Assistant output format (per sample):
10
+ # <think>{reason}</think>
11
+ # The score is <scoreK> ← criteria sample
12
+ #
13
+ # The score is <totalK> ← total sample
14
+ #
15
+ # Loss = CE_loss + crit_reg_weight * Huber(criteria) + total_reg_weight * Huber(total)
16
+ # ──────────────────────────────────────────────────────────────────────────────
17
+ # export CUDA_VISIBLE_DEVICES=0
18
+ # ── Distributed configuration (auto-detect) ──────────────────────────────────
19
+ MASTER_ADDR=${MASTER_ADDR:-"127.0.0.1"}
20
+ MASTER_PORT=${MASTER_PORT:-$(shuf -i 20001-29999 -n 1)}
21
+ NNODES=${WORLD_SIZE:-1}
22
+
23
+ if [ -z "$NPROC_PER_NODE" ]; then
24
+ if command -v nvidia-smi &> /dev/null; then
25
+ NPROC_PER_NODE=$(nvidia-smi --list-gpus | wc -l)
26
+ else
27
+ NPROC_PER_NODE=1
28
+ fi
29
+ fi
30
+
31
+
32
+ export DS_BUILD_SHM_COMM=0 # Disable building shared memory communication ops
33
+ export DS_BUILD_OPS=0 # Disable building ops to avoid compilation errors
34
+ # ── Paths ─────────────────────────────────────────────────────────────────────
35
+ # Base model: Qwen3-VL-8B or a previously fine-tuned checkpoint
36
+ # LLM=/mnt/hdfs/zw04mlnn01/checkpoint/llm_platform/hldy/model/Qwen/Qwen3-VL-8B-Instruct/main
37
+ LLM=
38
+ # Image root directory (must contain all image_path entries in track_1_train.json)
39
+ IMAGE_DIR=zijielou/PortraitCraft/main
40
+
41
+ # Annotation file (image_path uses subdir format: images_00/xxx.jpg)
42
+ ANNOTATION=PortraitCraft/track_1_train_subdir.json
43
+
44
+ # Output
45
+ RUN_NAME="qwen3vl_aesthetic_score_tokens_0509"
46
+ OUTPUT_DIR=result/portrait/${RUN_NAME}
47
+
48
+ # DeepSpeed config
49
+ DEEPSPEED=./scripts/zero2.json
50
+
51
+ # ── Hyper-parameters ──────────────────────────────────────────────────────────
52
+ LR=2e-5
53
+ BATCH_SIZE=2 # per GPU
54
+ GRAD_ACCUM=16 # effective batch = NPROC_PER_NODE * BATCH_SIZE * GRAD_ACCUM
55
+ NUM_EPOCHS=3
56
+
57
+ # ── Score token settings ──────────────────────────────────────────────────────
58
+ CRIT_BINS=20 # <score1>…<score20> covering 1.0–10.0
59
+ TOTAL_BINS=101 # <total1>…<total101> covering 0–100
60
+
61
+ # ── Loss weights ──────────────────────────────────────────────────────────────
62
+ CE_WEIGHT=1.0
63
+ CRIT_REG_WEIGHT=0.5 # regression loss weight for criteria samples
64
+ TOTAL_REG_WEIGHT=0.5 # regression loss weight for total-score samples
65
+ REG_LOSS_TYPE=huber # huber | mse
66
+
67
+ # ── Image resolution ──────────────────────────────────────────────────────────
68
+ MAX_PIXELS=$((32 * 32 * 1024)) # ~451k pixels (~672px side)
69
+ MIN_PIXELS=$((32 * 32 * 224)) # ~12k pixels
70
+ IMAGE_MAX_TOKEN_NUM=1024
71
+ # ── Entry point ───────────────────────────────────────────────────────────────
72
+ ENTRY=qwenvl/train/train_qwen.py
73
+
74
+ # ── Update aesthetic data path in data registry ───────────────────────────────
75
+ # Patch the image_dir at runtime via environment variable so we don't hard-code
76
+ # it inside __init__.py. The AestheticScoringDataset reads IMAGE_DIR if set.
77
+ export AESTHETIC_IMAGE_DIR=${IMAGE_DIR}
78
+ export AESTHETIC_ANNOTATION=${ANNOTATION}
79
+ export WANDB_DISABLED=true
80
+
81
+ # ── Auto-compute save_steps = half epoch ──────────────────────────────────────
82
+ NUM_IMAGES=$(python -c "import json; print(len(json.load(open('${ANNOTATION}'))))")
83
+ SAMPLES_PER_IMAGE=14 # 13 criteria + 1 total
84
+ TOTAL_SAMPLES=$((NUM_IMAGES * SAMPLES_PER_IMAGE))
85
+ EFFECTIVE_BATCH=$((NPROC_PER_NODE * NNODES * BATCH_SIZE * GRAD_ACCUM))
86
+ STEPS_PER_EPOCH=$(( (TOTAL_SAMPLES + EFFECTIVE_BATCH - 1) / EFFECTIVE_BATCH ))
87
+ SAVE_STEPS=$(( STEPS_PER_EPOCH / 2 ))
88
+ [ ${SAVE_STEPS} -lt 1 ] && SAVE_STEPS=1
89
+ echo "[auto] ${NUM_IMAGES} images Γ— ${SAMPLES_PER_IMAGE} = ${TOTAL_SAMPLES} samples"
90
+ echo "[auto] effective_batch=${EFFECTIVE_BATCH}, steps/epoch=${STEPS_PER_EPOCH}, save_steps=${SAVE_STEPS} (half epoch)"
91
+
92
+ # ── Training arguments ────────────────────────────────────────────────────────
93
+ ARGS="
94
+ --model_name_or_path ${LLM} \
95
+ --use_score_tokens True \
96
+ --crit_score_bins ${CRIT_BINS} \
97
+ --total_score_bins ${TOTAL_BINS} \
98
+ --score_ce_weight ${CE_WEIGHT} \
99
+ --crit_reg_weight ${CRIT_REG_WEIGHT} \
100
+ --total_reg_weight ${TOTAL_REG_WEIGHT} \
101
+ --tune_mm_vision True \
102
+ --tune_mm_mlp True \
103
+ --tune_mm_llm True \
104
+ --bf16 True \
105
+ --output_dir ${OUTPUT_DIR} \
106
+ --num_train_epochs ${NUM_EPOCHS} \
107
+ --per_device_train_batch_size ${BATCH_SIZE} \
108
+ --per_device_eval_batch_size $((BATCH_SIZE * 2)) \
109
+ --gradient_accumulation_steps ${GRAD_ACCUM} \
110
+ --max_pixels ${MAX_PIXELS} \
111
+ --min_pixels ${MIN_PIXELS} \
112
+ --eval_strategy no \
113
+ --save_strategy steps \
114
+ --save_steps ${SAVE_STEPS} \
115
+ --save_total_limit 6 \
116
+ --learning_rate ${LR} \
117
+ --weight_decay 0.0 \
118
+ --warmup_ratio 0.03 \
119
+ --max_grad_norm 2.0 \
120
+ --lr_scheduler_type cosine \
121
+ --logging_steps 1 \
122
+ --model_max_length 4096 \
123
+ --gradient_checkpointing False \
124
+ --dataloader_num_workers 4 \
125
+ --run_name ${RUN_NAME} \
126
+ --sample_every_steps 500 \
127
+ --report_to none \
128
+ --deepspeed ${DEEPSPEED}
129
+ "
130
+
131
+ # ── Launch ────────────────────────────────────────────────────────────────────
132
+ mkdir -p ${OUTPUT_DIR}
133
+
134
+ echo "============================================================"
135
+ echo " GPUs: ${NPROC_PER_NODE}, Nodes: ${NNODES}"
136
+ echo " Master: ${MASTER_ADDR}:${MASTER_PORT}"
137
+ echo " Output: ${OUTPUT_DIR}"
138
+ echo "============================================================"
139
+
140
+ torchrun \
141
+ --nproc_per_node=${NPROC_PER_NODE} \
142
+ --nnodes=${NNODES} \
143
+ --master_addr=${MASTER_ADDR} \
144
+ --master_port=${MASTER_PORT} \
145
+ ${ENTRY} ${ARGS} 2>&1 | tee ${OUTPUT_DIR}/train.log
146
+
scripts/zero2.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fp16": {
3
+ "enabled": "auto",
4
+ "loss_scale": 0,
5
+ "loss_scale_window": 1000,
6
+ "initial_scale_power": 16,
7
+ "hysteresis": 2,
8
+ "min_loss_scale": 1
9
+ },
10
+ "bf16": {
11
+ "enabled": "auto"
12
+ },
13
+ "train_micro_batch_size_per_gpu": "auto",
14
+ "train_batch_size": "auto",
15
+ "gradient_accumulation_steps": "auto",
16
+ "zero_optimization": {
17
+ "stage": 2,
18
+ "overlap_comm": true,
19
+ "contiguous_gradients": true,
20
+ "sub_group_size": 1e9,
21
+ "reduce_bucket_size": "auto"
22
+ }
23
+ }