Jack04810 commited on
Commit
36d0b76
·
verified ·
1 Parent(s): 534c64f

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. config/__pycache__/config.cpython-310.pyc +0 -0
  2. config/__pycache__/config.cpython-312.pyc +0 -0
  3. config/__pycache__/config_trimode.cpython-312.pyc +0 -0
  4. config/config.py +193 -0
  5. config/config_change.py +79 -0
  6. config/config_trimode_antidegen.py +61 -0
  7. config/loader.py +66 -0
  8. configs/deepspeed/zero2_bf16.json +40 -0
  9. data_utils/__pycache__/paths.cpython-310.pyc +0 -0
  10. data_utils/aokvqa/download.py +279 -0
  11. data_utils/aokvqa/evaluator.py +21 -0
  12. data_utils/aokvqa/patch_aokvqa.py +101 -0
  13. data_utils/chart/__pycache__/evaluator.cpython-310.pyc +0 -0
  14. data_utils/chart/pre_refinement.py +134 -0
  15. data_utils/chart/prompts.py +114 -0
  16. data_utils/clevr/clevr_processor.py +461 -0
  17. data_utils/clevr/run_single_test.py +194 -0
  18. data_utils/paths.py +58 -0
  19. default_config_zero2.yaml +20 -0
  20. main_7B.py +192 -0
  21. opsd_utils/__init__.py +18 -0
  22. opsd_utils/__pycache__/__init__.cpython-310.pyc +0 -0
  23. opsd_utils/__pycache__/__init__.cpython-312.pyc +0 -0
  24. opsd_utils/__pycache__/constants.cpython-312.pyc +0 -0
  25. opsd_utils/__pycache__/debug_log.cpython-310.pyc +0 -0
  26. opsd_utils/__pycache__/health_monitor.cpython-310.pyc +0 -0
  27. opsd_utils/__pycache__/leakage.cpython-310.pyc +0 -0
  28. opsd_utils/__pycache__/mode_router.cpython-310.pyc +0 -0
  29. opsd_utils/__pycache__/mode_router.cpython-312.pyc +0 -0
  30. opsd_utils/__pycache__/opsd_loss.cpython-310.pyc +0 -0
  31. opsd_utils/__pycache__/prompt_builder.cpython-310.pyc +0 -0
  32. opsd_utils/__pycache__/recoverability.cpython-310.pyc +0 -0
  33. opsd_utils/__pycache__/recoverability.cpython-312.pyc +0 -0
  34. opsd_utils/__pycache__/vocab_align.cpython-310.pyc +0 -0
  35. opsd_utils/constants.py +40 -0
  36. opsd_utils/debug_log.py +426 -0
  37. opsd_utils/deepspeed_utils.py +141 -0
  38. opsd_utils/diagnostics.py +1351 -0
  39. opsd_utils/leakage.py +53 -0
  40. opsd_utils/opsd_loss.py +439 -0
  41. opsd_utils/privileged/__pycache__/__init__.cpython-310.pyc +0 -0
  42. opsd_utils/privileged/__pycache__/__init__.cpython-312.pyc +0 -0
  43. opsd_utils/privileged/__pycache__/base.cpython-310.pyc +0 -0
  44. opsd_utils/privileged/__pycache__/base.cpython-312.pyc +0 -0
  45. opsd_utils/privileged/__pycache__/image_utils.cpython-310.pyc +0 -0
  46. opsd_utils/privileged/__pycache__/providers.cpython-312.pyc +0 -0
  47. opsd_utils/privileged/__pycache__/registry.cpython-310.pyc +0 -0
  48. opsd_utils/privileged/__pycache__/registry.cpython-312.pyc +0 -0
  49. opsd_utils/privileged/debug_artifacts.py +104 -0
  50. opsd_utils/privileged/providers.py +127 -0
config/__pycache__/config.cpython-310.pyc ADDED
Binary file (3.71 kB). View file
 
config/__pycache__/config.cpython-312.pyc ADDED
Binary file (3.16 kB). View file
 
config/__pycache__/config_trimode.cpython-312.pyc ADDED
Binary file (1.06 kB). View file
 
config/config.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import torch
3
+
4
+ from data_utils.paths import OUTPUTS_DIR, project_path
5
+
6
+ # ====== Model Configuration ======
7
+ MODEL_CONFIG = {
8
+ "pretrained_model_path": "llava-hf/llava-onevision-qwen2-0.5b-ov-hf", # two-stage grpo
9
+ "use_flash_attention_2": True,
10
+ "torch_dtype": "bfloat16",
11
+ }
12
+
13
+ # ====== Training Configuration ======
14
+ TRAINING_CONFIG = {
15
+ "task": 'chart',
16
+ "num_gpus": 8, # 使用的 GPU 数量
17
+ "num_client": 8, # 并发客户端数量,通常与 GPU 数量相同
18
+ # RL阶段的参数 (根据原脚本的rl_args)
19
+ "dyme_args": {
20
+ "output_dir": os.path.join(OUTPUTS_DIR, "dyme-k-8"),
21
+ "logging_steps": 1,
22
+ "num_generations": 8, # RL 阶段可以生成多个响应进行比较
23
+ "max_completion_length": 200,
24
+ "temperature": 0.8,
25
+ "repetition_penalty": 1.15,
26
+ "per_device_train_batch_size": 2,
27
+ "gradient_accumulation_steps": 16,
28
+ "num_train_epochs": 10,
29
+ "learning_rate": 8e-5,
30
+ "bf16": True, # 使用 bf16 而不是 fp16
31
+ "gradient_checkpointing": False,
32
+ "ddp_find_unused_parameters": False,
33
+ "max_grad_norm": 1.0,
34
+ "save_strategy": "epoch",
35
+ "weight_decay": 0.01,
36
+ "warmup_steps": 0,
37
+ "beta": 0.0, # DyMETrainer requires beta=0 (no reference-model KL path yet)
38
+ "loss_type": 'grpo', # GRPO specific
39
+ "seed": 42,
40
+ },
41
+ "sft_args": {
42
+ "output_dir": os.path.join(OUTPUTS_DIR, "sft-chart-llava_cot"),
43
+ "logging_steps": 1,
44
+ "per_device_train_batch_size": 2,
45
+ "gradient_accumulation_steps": 4,
46
+ "num_train_epochs": 10,
47
+ "learning_rate": 1e-5,
48
+ "bf16": True, # 使用 bf16 而不是 fp16
49
+ "gradient_checkpointing": False,
50
+ "ddp_find_unused_parameters": False,
51
+ "max_grad_norm": 1.0,
52
+ # "save_steps": 100,
53
+ "save_strategy": "epoch",
54
+ "weight_decay": 0.01,
55
+ "warmup_steps": 0,
56
+ "seed": 42,
57
+ "remove_unused_columns": False
58
+ },
59
+ "grpo_args":{
60
+ "output_dir": os.path.join(OUTPUTS_DIR, "grpo-chart-llava-beta"),
61
+ "logging_steps": 1,
62
+ "num_generations": 4, # RL 阶段可以生成多个响应进行比较
63
+ "max_completion_length": 576,
64
+ "max_prompt_length": None,
65
+ "per_device_train_batch_size": 2,
66
+ "gradient_accumulation_steps": 4,
67
+ "num_train_epochs": 10,
68
+ "learning_rate": 1e-5,
69
+ "bf16": True, # 使用 bf16 而不是 fp16
70
+ "gradient_checkpointing": False,
71
+ "ddp_find_unused_parameters": False,
72
+ "max_grad_norm": 1.0,
73
+ "save_strategy": "epoch",
74
+ "weight_decay": 0.01,
75
+ "warmup_steps": 0,
76
+ "beta": 0.04, # GRPO specific
77
+ "loss_type": 'grpo', # GRPO specific
78
+ "seed": 42,
79
+ }
80
+
81
+ }
82
+
83
+ RL_CONFIG = {
84
+ "answer_flag": "Answer:",
85
+ "end_flag": "<|im_end|>"
86
+ }
87
+
88
+ # OPSD / TriMode training (disabled by default -> original DyME behavior)
89
+ DYME_OPSD_CONFIG = {
90
+ "enabled": False,
91
+ "mode": "dyme",
92
+ "privileged_profile": "hybrid",
93
+ "privileged_providers": ["text"],
94
+ "privileged_image": {
95
+ "mode": "single",
96
+ "crop_strategy": "bbox_then_center",
97
+ "bbox_coord": "normalized",
98
+ "margin_ratio": 0.25,
99
+ },
100
+ "privileged_debug": {
101
+ "save_images": True,
102
+ "image_subdir": "logs/images",
103
+ "max_samples_per_detail": 2,
104
+ },
105
+ "gate": {
106
+ "correct_threshold": 0.5,
107
+ "teacher_recoverable": "privileged_available",
108
+ "recoverable_tau": 0.5,
109
+ "use_edge_mask": False,
110
+ # TriMode: OPSD only on completions that are correct + well-formatted (not whole group).
111
+ "per_completion_opsd": True,
112
+ "require_format_for_opsd": True,
113
+ "skip_degenerate_for_opsd": True,
114
+ },
115
+ "loss": {
116
+ "beta": 0.5,
117
+ "opsd_weight": 2.0,
118
+ "grpo_weight": 1.0,
119
+ "sft_weight": 1.0,
120
+ },
121
+ # Optional: [format, context, accuracy] — default uniform; antidegen uses [0.5, 1.5, 1.0]
122
+ "reward_weights": [1.0, 1.0, 1.0],
123
+ "debug": {
124
+ # Full weak-signal diagnostic bundle every N global steps (rank 0). 0 = off.
125
+ "detail_every": 10,
126
+ # Lightweight [OPSD-PROBE] on every (re)generate (rank 0). Independent of OPSD-DEBUG.
127
+ "probe_on_generate": False,
128
+ "probe_sample_count": 4,
129
+ # Deep generate diagnostics ([OPSD-GENDBG]) — prompt tail, first-token logits, model context.
130
+ "probe_first_token_logits": True,
131
+ "probe_prompt_tail_tokens": 16,
132
+ "probe_log_model_context": True,
133
+ "health_monitor": {
134
+ "enabled": True,
135
+ "window": 20,
136
+ "log_on_generate": True,
137
+ "log_every_step": True,
138
+ "log_detail_bundle": True,
139
+ "log_alerts_immediately": True,
140
+ "metrics_every_step": True,
141
+ },
142
+ },
143
+ }
144
+
145
+ # ====== Client Configuration for Reward Calculation ======
146
+ CLIENT_CONFIG = {
147
+ "client_type": "openai", # 客户端主机地址
148
+ "api_key": "none", # 客户端主机
149
+ "api_base": "http://127.0.0.1:%s/v1", # 客户端,如果是本地服务需要预留端口
150
+ "timeout": 60, # 请求超时时间
151
+ "model_id": "Qwen/Qwen2.5-14B-Instruct-AWQ", # 使用的模型ID
152
+ "init_port": 23333, # 或者none代表在线服务
153
+ "num_server": 8
154
+ }
155
+
156
+ # ====== Dataset Configuration ======
157
+ DATASET_CONFIG = {
158
+ "train_dataset": project_path("data/chartqa/train_medium_vf_full.json"),
159
+ # 训练数据路径
160
+ "eval_dataset": "HuggingFaceM4/ChartQA", # 验证数据路径
161
+ }
162
+
163
+ # ====== DePlot offline preprocessing (ChartQA F2) ======
164
+ DEPLOT_CONFIG = {
165
+ "enabled": True,
166
+ "model_id": "google/deplot",
167
+ "batch_size": 8,
168
+ "max_new_tokens": 384,
169
+ "cache_path": project_path("data/chartqa/deplot_cache.json"),
170
+ "prompt": "Generate underlying data table of the figure below:",
171
+ }
172
+
173
+ # ====== Full Configuration ======
174
+ CONFIG = {
175
+ "model": MODEL_CONFIG,
176
+ "training": TRAINING_CONFIG,
177
+ "rl": RL_CONFIG,
178
+ "opsd": DYME_OPSD_CONFIG,
179
+ "client": CLIENT_CONFIG,
180
+ "dataset": DATASET_CONFIG,
181
+ "deplot": DEPLOT_CONFIG,
182
+ }
183
+
184
+ # Save configuration to a file for reference
185
+ def save_config(config, config_path="./config.json"):
186
+ import json
187
+ with open(config_path, "w") as f:
188
+ json.dump(config, f, indent=4)
189
+
190
+ # Example usage to save config
191
+ if __name__ == "__main__":
192
+ save_config(CONFIG)
193
+
config/config_change.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import torch
3
+
4
+ # ====== Model Configuration ======
5
+ MODEL_CONFIG = {
6
+ "pretrained_model_path": "llava-hf/llava-onevision-qwen2-0.5b-ov-hf", # two-stage grpo
7
+ "use_flash_attention_2": True,
8
+ "torch_dtype": "bfloat16",
9
+ }
10
+
11
+ # ====== Training Configuration ======
12
+ TRAINING_CONFIG = {
13
+ "task": 'chart',
14
+ "num_gpus": 8,
15
+ "num_client": 8,
16
+
17
+ "dyme_args": {
18
+ "output_dir": '/path/to/dyme-llavaov-chart-change3',
19
+ "logging_steps": 1,
20
+ "num_generations": 4,
21
+ "max_completion_length": 200,
22
+ "per_device_train_batch_size": 1,
23
+ "gradient_accumulation_steps": 16, # norm 16; 20 for sft hybrid
24
+ "num_train_epochs": 10,
25
+ "learning_rate": 8e-5,
26
+ "bf16": True,
27
+ "gradient_checkpointing": False,
28
+ "ddp_find_unused_parameters": False,
29
+ "max_grad_norm": 1.0,
30
+ "save_strategy": "epoch",
31
+ "weight_decay": 0.01,
32
+ "warmup_steps": 0,
33
+ "beta": 0.0, # GRPO specific
34
+ "loss_type": 'grpo', # GRPO specific
35
+ "seed": 42,
36
+ },
37
+ }
38
+
39
+ RL_CONFIG = {
40
+ "answer_flag": "Answer:",
41
+ "end_flag": "<|im_end|>"
42
+ }
43
+
44
+ # ====== Client Configuration for Reward Calculation ======
45
+ CLIENT_CONFIG = {
46
+ "client_type": "openai",
47
+ "api_key": "none", # 客户端主机
48
+ "api_base": "http://127.0.0.1:%s/v1",
49
+ "timeout": 60,
50
+ "model_id": "Qwen/Qwen2.5-14B-Instruct-AWQ",
51
+ "init_port": 23333,
52
+ "num_server": 8
53
+ }
54
+
55
+ # ====== Dataset Configuration ======
56
+ DATASET_CONFIG = {
57
+ "train_dataset": "/path/to/data/chartqa_output/json/train_new_prerefine.json",
58
+ "eval_dataset": "HuggingFaceM4/ChartQA",
59
+ }
60
+
61
+ # ====== Full Configuration ======
62
+ CONFIG = {
63
+ "model": MODEL_CONFIG,
64
+ "training": TRAINING_CONFIG,
65
+ "rl": RL_CONFIG,
66
+ "client": CLIENT_CONFIG,
67
+ "dataset": DATASET_CONFIG,
68
+ }
69
+
70
+ # Save configuration to a file for reference
71
+ def save_config(config, config_path="./config.json"):
72
+ import json
73
+ with open(config_path, "w") as f:
74
+ json.dump(config, f, indent=4)
75
+
76
+ # Example usage to save config
77
+ if __name__ == "__main__":
78
+ save_config(CONFIG)
79
+
config/config_trimode_antidegen.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TriMode ChartQA config with anti-degeneration overrides (log-validated).
3
+
4
+ Based on train_trimode_4gpu_20260610_173637.log analysis:
5
+ - decoding: shorter max length, lower temperature, higher repetition penalty
6
+ - training: lower LR + warmup to mitigate step-1 collapse
7
+ - OPSD gate: require_format_for_opsd=False to raise opsd_mask coverage
8
+ - reward_weights: emphasize continuous context F1 for group reward spread
9
+ """
10
+ import os
11
+
12
+ import config.config_trimode as trimode
13
+ from data_utils.paths import OUTPUTS_DIR
14
+
15
+ MODEL_CONFIG = dict(trimode.MODEL_CONFIG)
16
+
17
+ TRAINING_CONFIG = {
18
+ **trimode.TRAINING_CONFIG,
19
+ "dyme_args": {
20
+ **trimode.TRAINING_CONFIG["dyme_args"],
21
+ "output_dir": os.environ.get(
22
+ "DYME_OUTPUT_DIR",
23
+ os.path.join(OUTPUTS_DIR, "dyme-trimode-antidegen"),
24
+ ),
25
+ "max_completion_length": 150,
26
+ "temperature": 0.7,
27
+ "repetition_penalty": 1.25,
28
+ "learning_rate": 5e-5,
29
+ "warmup_steps": 50,
30
+ },
31
+ }
32
+
33
+ _reward_weights_raw = os.environ.get("DYME_REWARD_WEIGHTS", "0.5,1.5,1.0")
34
+ try:
35
+ _reward_weights = [float(x.strip()) for x in _reward_weights_raw.split(",") if x.strip()]
36
+ if len(_reward_weights) != 3:
37
+ raise ValueError("expected 3 weights")
38
+ except ValueError:
39
+ _reward_weights = [0.5, 1.5, 1.0]
40
+
41
+ _require_format_raw = os.environ.get("DYME_OPSD_REQUIRE_FORMAT", "0").strip().lower()
42
+ _require_format_for_opsd = _require_format_raw not in ("0", "false", "no", "off")
43
+
44
+ DYME_OPSD_CONFIG = {
45
+ **trimode.DYME_OPSD_CONFIG,
46
+ "gate": {
47
+ **trimode.DYME_OPSD_CONFIG.get("gate", {}),
48
+ "require_format_for_opsd": _require_format_for_opsd,
49
+ "skip_degenerate_for_opsd": True,
50
+ },
51
+ "reward_weights": _reward_weights,
52
+ }
53
+
54
+ CONFIG = {
55
+ "model": MODEL_CONFIG,
56
+ "training": TRAINING_CONFIG,
57
+ "rl": trimode.RL_CONFIG,
58
+ "opsd": DYME_OPSD_CONFIG,
59
+ "client": trimode.CLIENT_CONFIG,
60
+ "dataset": trimode.DATASET_CONFIG,
61
+ }
config/loader.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib
2
+ import importlib.util
3
+ import os
4
+ from typing import Any
5
+
6
+ _CONFIG_ALIASES: dict[str, str] = {
7
+ "norm": "config.config",
8
+ "trimode": "config.config_trimode",
9
+ "trimode_antidegen": "config.config_trimode_antidegen",
10
+ "rlsd": "config.config_rlsd_chartqa",
11
+ "rlsd_chartqa": "config.config_rlsd_chartqa",
12
+ "opd_7b": "config.config_opd_7b_chartqa",
13
+ "opd_7b_chartqa": "config.config_opd_7b_chartqa",
14
+ "llavacot": "config.config_llavacot",
15
+ "low": "config.config_low",
16
+ "aok": "config.config_aok",
17
+ "change": "config.config_change",
18
+ "7b": "config.config_7B",
19
+ "llm": "config.config_llm",
20
+ }
21
+
22
+
23
+ def _resolve_config_path(config_arg: str) -> str:
24
+ if os.path.isfile(config_arg):
25
+ return os.path.abspath(config_arg)
26
+
27
+ project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
28
+ candidate = os.path.join(project_root, config_arg)
29
+ if os.path.isfile(candidate):
30
+ return os.path.abspath(candidate)
31
+
32
+ raise FileNotFoundError(f"Config file not found: {config_arg}")
33
+
34
+
35
+ def load_config(config_arg: str) -> dict[str, Any]:
36
+ """
37
+ Load CONFIG from a Python config file path or shorthand alias.
38
+
39
+ Examples:
40
+ load_config("config/config.py")
41
+ load_config("config/config_trimode.py")
42
+ load_config("norm")
43
+ load_config("trimode")
44
+ """
45
+ if config_arg.endswith(".py"):
46
+ path = _resolve_config_path(config_arg)
47
+ module_name = f"_dyme_runtime_config_{abs(hash(path)) % 10 ** 8}"
48
+ spec = importlib.util.spec_from_file_location(module_name, path)
49
+ if spec is None or spec.loader is None:
50
+ raise ImportError(f"Cannot load config module from {path}")
51
+ module = importlib.util.module_from_spec(spec)
52
+ spec.loader.exec_module(module)
53
+ if not hasattr(module, "CONFIG"):
54
+ raise ValueError(f"{path} must define a CONFIG dict")
55
+ return module.CONFIG
56
+
57
+ module_path = _CONFIG_ALIASES.get(config_arg)
58
+ if module_path is None:
59
+ raise ValueError(
60
+ f"Unknown config alias '{config_arg}'. "
61
+ f"Use a .py path (e.g. config/config.py) or one of: {', '.join(sorted(_CONFIG_ALIASES))}"
62
+ )
63
+ module = importlib.import_module(module_path)
64
+ if not hasattr(module, "CONFIG"):
65
+ raise ValueError(f"{module_path} must define a CONFIG dict")
66
+ return module.CONFIG
configs/deepspeed/zero2_bf16.json ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "bf16": {
3
+ "enabled": "auto"
4
+ },
5
+ "optimizer": {
6
+ "type": "AdamW",
7
+ "params": {
8
+ "lr": "auto",
9
+ "betas": "auto",
10
+ "eps": "auto",
11
+ "weight_decay": "auto",
12
+ "torch_adam": true,
13
+ "adam_w_mode": true
14
+ }
15
+ },
16
+ "scheduler": {
17
+ "type": "WarmupDecayLR",
18
+ "params": {
19
+ "warmup_min_lr": "auto",
20
+ "warmup_max_lr": "auto",
21
+ "warmup_num_steps": "auto",
22
+ "total_num_steps": "auto"
23
+ }
24
+ },
25
+ "zero_optimization": {
26
+ "stage": 2,
27
+ "allgather_partitions": true,
28
+ "allgather_bucket_size": 200000000.0,
29
+ "overlap_comm": true,
30
+ "reduce_scatter": true,
31
+ "reduce_bucket_size": "auto",
32
+ "contiguous_gradients": true
33
+ },
34
+ "gradient_accumulation_steps": "auto",
35
+ "gradient_clipping": "auto",
36
+ "train_batch_size": "auto",
37
+ "train_micro_batch_size_per_gpu": "auto",
38
+ "steps_per_print": 2000,
39
+ "wall_clock_breakdown": false
40
+ }
data_utils/__pycache__/paths.cpython-310.pyc ADDED
Binary file (1.57 kB). View file
 
data_utils/aokvqa/download.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import base64
4
+ import io
5
+ import multiprocessing
6
+ from functools import partial
7
+ from PIL import Image
8
+ from datasets import load_dataset
9
+ from tqdm import tqdm
10
+ from openai import OpenAI, BadRequestError
11
+ import time
12
+
13
+ from data_utils.paths import AOKVQA_DIR
14
+
15
+ # Ensure PIL can handle large images
16
+ Image.MAX_IMAGE_PIXELS = None
17
+
18
+ # --- API and MLLM prompt configuration ---
19
+
20
+ API_PORTS = list(range(23333, 23333 + 8))
21
+ API_URL_TEMPLATE = "http://127.0.0.1:{port}/v1/"
22
+
23
+ # 2. Define prompts used to obtain visual_fact
24
+ VISUAL_FACT_SYSTEM_PROMPT = """
25
+ You are a helpful assistant that analyzes images and provides visual facts.
26
+ Your response MUST be a single, valid JSON object.
27
+ The JSON object should contain:
28
+ 1. "description": A detailed and accurate description of the image.
29
+ 2. "objects": A list of key objects, including their name, attributes, and approximate position in the image.
30
+
31
+ Example format:
32
+ {
33
+ "description": "A person riding a bicycle on a city street.... (detailed description here)",
34
+ "objects": [
35
+ {"name": "person", "attributes": ["wearing helmet", "blue shirt"], "position": "center"},
36
+ {"name": "bicycle", "attributes": ["red", "mountain bike"], "position": "center"},
37
+ {"name": "street", "attributes": ["asphalt", "wet"], "position": "bottom"}
38
+ ]
39
+ }
40
+ """
41
+
42
+ VISUAL_FACT_USER_PROMPT = """
43
+ Analyze the attached image and provide the visual facts in the required JSON format.
44
+ For context, the user will be asked this question about the image (do not answer the question, just use it for context):
45
+ "{question}"
46
+ """
47
+
48
+
49
+ def encode_image_to_base64(pil_image):
50
+ """Convert a PIL Image object to a Base64-encoded string"""
51
+ buffered = io.BytesIO()
52
+ if pil_image.mode == "RGBA" or "transparency" in pil_image.info:
53
+ pil_image.save(buffered, format="PNG")
54
+ mime_type = "image/png"
55
+ else:
56
+ pil_image.save(buffered, format="JPEG")
57
+ mime_type = "image/jpeg"
58
+
59
+ img_str = base64.b64encode(buffered.getvalue()).decode('utf-8')
60
+ return f"data:{mime_type};base64,{img_str}"
61
+
62
+
63
+ def get_visual_fact(api_url, pil_image, question):
64
+ """
65
+ Call an external MLLM API to obtain visual_fact.
66
+ """
67
+ try:
68
+ client = OpenAI(base_url=api_url, api_key="DUMMY_KEY")
69
+ image_url = encode_image_to_base64(pil_image)
70
+
71
+ messages = [
72
+ {"role": "system", "content": VISUAL_FACT_SYSTEM_PROMPT},
73
+ {
74
+ "role": "user",
75
+ "content": [
76
+ {"type": "image_url", "image_url": {"url": image_url,}},
77
+ {"type": "text", "text": VISUAL_FACT_USER_PROMPT.format(question=question)}
78
+ ]
79
+ }
80
+ ]
81
+
82
+ try:
83
+ response = client.chat.completions.create(
84
+ model="Qwen/Qwen2.5-VL-32B-Instruct-AWQ",
85
+ messages=messages,
86
+ max_tokens=1024,
87
+ temperature=0.0,
88
+ )
89
+ response_content = response.choices[0].message.content
90
+ return response_content
91
+
92
+ except (BadRequestError, Exception):
93
+ response = client.chat.completions.create(
94
+ model="Qwen/Qwen2.5-VL-32B-Instruct-AWQ",
95
+ messages=messages,
96
+ max_tokens=1024,
97
+ temperature=0.0,
98
+ )
99
+ response_content = response.choices[0].message.content
100
+
101
+ if response_content.startswith("```json"):
102
+ response_content = response_content[7:].strip("` \n")
103
+
104
+ return response_content
105
+
106
+ except json.JSONDecodeError as e:
107
+ print(f"!! JSON parse failed: {e}. API: {api_url}. Raw response: {response_content[:100]}...")
108
+ return {"error": "Failed to parse JSON response", "raw_response": response_content}
109
+ except Exception as e:
110
+ print(f"!! API call failed: {e}. API: {api_url}")
111
+ time.sleep(1)
112
+ return {"error": f"API call failed: {str(e)}"}
113
+
114
+
115
+ # --- (This is the modified function) ---
116
+ def process_example_worker(example_with_index, split, image_output_dir, api_ports_list, fetch_visual_facts):
117
+ """
118
+ Worker function for multiprocessing.
119
+ Process a single example: save image and (optionally) call the API.
120
+ """
121
+ i, example = example_with_index
122
+
123
+ try:
124
+ # 1. Extract metadata
125
+ question = example["question"]
126
+ pil_image = example["image"]
127
+
128
+ # --- (A) Extract new fields ---
129
+ choices_list = example.get("choices")
130
+ correct_idx = example.get("correct_choice_idx")
131
+ direct_answers_list = example.get("direct_answers")
132
+ rationales_list = example.get("rationales") # A-OKVQA has a 'rationales' field
133
+
134
+ # --- (B) Determine "answer" (prefer choice as requested) ---
135
+ answer = None
136
+ if choices_list and correct_idx is not None and 0 <= correct_idx < len(choices_list):
137
+ answer = choices_list[correct_idx]
138
+ elif direct_answers_list:
139
+ answer = direct_answers_list[0] # Fallback to direct_answers
140
+
141
+ # --- (C) Determine "hint" (the longest rationale) ---
142
+ hint = None
143
+ if rationales_list:
144
+ try:
145
+ # Ensure rationales_list is a non-empty list of strings
146
+ if isinstance(rationales_list, list) and len(rationales_list) > 0:
147
+ string_rationales = [r for r in rationales_list if isinstance(r, str)]
148
+ if string_rationales:
149
+ hint = max(string_rationales, key=len) # Choose the longest string
150
+ except Exception as e:
151
+ print(f"!! Warning: Failed to compute 'hint' (index {i}). Error: {e}")
152
+ pass # hint remains None
153
+
154
+ # --- (D) End of extraction ---
155
+ # 2. Generate and save image filename
156
+ generated_filename = f"{split}_{i:07d}.png"
157
+ image_save_path = os.path.join(image_output_dir, generated_filename)
158
+
159
+ # 3. Save image (I/O operation)
160
+ if not os.path.exists(image_save_path):
161
+ pil_image.save(image_save_path)
162
+
163
+ # 4. (Key step) Get Visual Fact
164
+ visual_fact_data = None
165
+ if fetch_visual_facts:
166
+ # Use API ports in a round-robin manner according to index 'i'
167
+ port_to_use = api_ports_list[i % len(api_ports_list)]
168
+ api_url = API_URL_TEMPLATE.format(port=port_to_use)
169
+
170
+ visual_fact_data = get_visual_fact(api_url, pil_image, question)
171
+
172
+ # 5. Build metadata dict (using the new format you specified)
173
+ metadata = {
174
+ "question": question,
175
+ "question_wo_prompt": question,
176
+ "answer": answer,
177
+ "choices": choices_list, # Store the full options list
178
+ "image": image_save_path,
179
+ "visual_fact": visual_fact_data,
180
+ "hint": hint
181
+ }
182
+
183
+ return metadata
184
+
185
+ except Exception as e:
186
+ print(f"!! Fatal error in worker (index {i}): {e}")
187
+ return None # The main process will filter out None
188
+
189
+
190
+ def save_aokvqa_with_facts(base_output_dir=None, fetch_visual_facts=None):
191
+ """
192
+ Load A-OKVQA, save images with multiprocessing, and optionally obtain visual_facts for the training split.
193
+
194
+ Set env FETCH_VISUAL_FACTS=1 to call local MLLM APIs; default is images-only download.
195
+ """
196
+ if base_output_dir is None:
197
+ base_output_dir = AOKVQA_DIR
198
+ if fetch_visual_facts is None:
199
+ fetch_visual_facts = os.environ.get("FETCH_VISUAL_FACTS", "0") == "1"
200
+
201
+ # 1. Define output directories
202
+ base_dir_abs = os.path.abspath(base_output_dir)
203
+ image_output_dir = os.path.join(base_dir_abs, "images")
204
+ json_output_dir = os.path.join(base_dir_abs, "json")
205
+
206
+ # 2. Create directories
207
+ os.makedirs(image_output_dir, exist_ok=True)
208
+ os.makedirs(json_output_dir, exist_ok=True)
209
+
210
+ print(f"Images will be saved to (absolute path): {image_output_dir}")
211
+ print(f"JSON will be saved to (absolute path): {json_output_dir}")
212
+ print(f"Using {len(API_PORTS)} API ports: {API_PORTS}")
213
+
214
+ # 3. Load dataset
215
+ print("Loading HuggingFaceM4/A-OKVQA dataset...")
216
+ try:
217
+ dataset = load_dataset("HuggingFaceM4/A-OKVQA")
218
+ # Use a small subset for testing
219
+ # dataset['train'] = dataset['train'].select(range(100))
220
+ except Exception as e:
221
+ print(f"Failed to load dataset: {e}")
222
+ return
223
+
224
+ print(f"Dataset splits: {list(dataset.keys())}")
225
+
226
+ # 4. Define number of worker processes
227
+ num_workers = 64
228
+ print(f"Starting {num_workers} worker processes...")
229
+
230
+ # 5. Iterate over each split
231
+ for split in dataset.keys():
232
+
233
+ fetch_facts = fetch_visual_facts and (split == 'train')
234
+
235
+ if fetch_facts:
236
+ print(f"\n--- Processing split {split} (will call MLLM API) ---")
237
+ else:
238
+ print(f"\n--- Processing split {split} (images only, visual_fact=None) ---")
239
+
240
+ metadata_list = []
241
+
242
+ worker_func = partial(
243
+ process_example_worker,
244
+ split=split,
245
+ image_output_dir=image_output_dir,
246
+ api_ports_list=API_PORTS,
247
+ fetch_visual_facts=fetch_facts
248
+ )
249
+
250
+ tasks = list(enumerate(dataset[split]))
251
+ total_count = len(tasks)
252
+
253
+ # 6. Use multiprocessing pool
254
+ with multiprocessing.Pool(processes=num_workers) as pool:
255
+ for result in tqdm(pool.imap_unordered(worker_func, tasks), total=total_count, desc=f"Processing {split}"):
256
+ if result:
257
+ metadata_list.append(result)
258
+
259
+ print(f" Split {split} finished. Success: {len(metadata_list)} / {total_count}.")
260
+
261
+ # 7. (Optional) Sort
262
+ metadata_list.sort(key=lambda x: x['image'])
263
+
264
+ # 8. Write JSON file
265
+ json_filename = os.path.join(json_output_dir, f"{split}.json")
266
+ print(f"Saving {len(metadata_list)} metadata entries to {json_filename}...")
267
+
268
+ with open(json_filename, 'w', encoding='utf-8') as f:
269
+ json.dump(metadata_list, f, indent=4, ensure_ascii=False)
270
+
271
+ print(f"\n--- All processing completed! ---")
272
+ print(f"All image files saved in: '{image_output_dir}'")
273
+ print(f"All JSON files saved in: '{json_output_dir}'")
274
+
275
+
276
+ if __name__ == "__main__":
277
+ multiprocessing.set_start_method("spawn", force=True)
278
+
279
+ save_aokvqa_with_facts()
data_utils/aokvqa/evaluator.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- New: A-OKVQA evaluation function ---
2
+ def eval_aokvqa_direct(prediction, ground_truth_list):
3
+ """
4
+ Simple VQA accuracy: returns 1 if the prediction is in the direct_answers list, otherwise 0.
5
+ Matching is case-insensitive.
6
+ """
7
+ if not ground_truth_list: # Handle empty ground truth list
8
+ return 0.0
9
+
10
+ # Clean predicted answer
11
+ pred_cleaned = prediction.lower().strip().strip('.').strip()
12
+
13
+ # Create a cleaned set of ground truth answers
14
+ gt_cleaned_set = set(ans.lower().strip().strip('.').strip() for ans in ground_truth_list)
15
+
16
+ # Check if prediction is in the set
17
+ if pred_cleaned in gt_cleaned_set:
18
+ return 1.0
19
+
20
+ # (Optional) You can add more sophisticated VQA matching logic, but simple "in" check is the baseline
21
+ return 0.0
data_utils/aokvqa/patch_aokvqa.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from datasets import load_dataset
4
+ from tqdm import tqdm
5
+ import shutil
6
+
7
+
8
+ def patch_json_with_direct_answers(base_output_dir):
9
+ """
10
+ Load existing JSON files and the original A-OKVQA dataset,
11
+ and add the 'direct_answers' field back into the JSON files.
12
+
13
+ This script assumes that the entries in your generated JSON files
14
+ (e.g., train.json) are ordered according to the original dataset indices.
15
+ Your original script's
16
+ `metadata_list.sort(key=lambda x: x['image'])` ensures this alignment.
17
+ """
18
+
19
+ json_output_dir = os.path.join(base_output_dir, "json")
20
+ print(f"JSON directory to patch: {json_output_dir}")
21
+
22
+ # 1. Load the original dataset
23
+ print("Loading HuggingFaceM4/A-OKVQA dataset (metadata only)...")
24
+ try:
25
+ # Specify a cache directory to avoid repeated downloads
26
+ cache_dir = os.path.join(base_output_dir, ".cache")
27
+ os.makedirs(cache_dir, exist_ok=True)
28
+ dataset = load_dataset("HuggingFaceM4/A-OKVQA", cache_dir=cache_dir)
29
+ except Exception as e:
30
+ print(f"Failed to load original dataset: {e}")
31
+ return
32
+
33
+ print(f"Available splits in dataset: {list(dataset.keys())}")
34
+
35
+ # 2. Iterate over each split
36
+ for split in dataset.keys():
37
+ json_filename = os.path.join(json_output_dir, f"{split}.json")
38
+
39
+ if not os.path.exists(json_filename):
40
+ print(f"!! Warning: {json_filename} not found, skipping split '{split}'.")
41
+ continue
42
+
43
+ print(f"\n--- Patching split {split} ({json_filename}) ---")
44
+
45
+ # 3. Load existing (incomplete) JSON data
46
+ try:
47
+ with open(json_filename, 'r', encoding='utf-8') as f:
48
+ generated_data_list = json.load(f)
49
+ print(f" Loaded {len(generated_data_list)} processed entries.")
50
+ except Exception as e:
51
+ print(f" !! Error: Failed to load {json_filename}: {e}")
52
+ continue
53
+
54
+ # 4. Load original split data
55
+ original_split_data = dataset[split]
56
+ print(f" Loaded {len(original_split_data)} original entries.")
57
+
58
+ # 5. Sanity check (ensure counts match)
59
+ if len(generated_data_list) != len(original_split_data):
60
+ print(f" !! Critical error: Mismatch in number of entries!")
61
+ print(f" JSON ({split}.json) has {len(generated_data_list)} records.")
62
+ print(f" Original dataset ('{split}') has {len(original_split_data)} records.")
63
+ print(f" Skipping this split.")
64
+ continue
65
+
66
+ # 6. Core logic: merge data using zip (relying on aligned ordering)
67
+ # Your 'image' field (e.g., "train_0000001.png") ensures that
68
+ # the order of 'generated_data_list' matches 'original_split_data'.
69
+
70
+ print(f" Merging 'direct_answers'...")
71
+ for generated_metadata, original_example in \
72
+ tqdm(zip(generated_data_list, original_split_data),
73
+ total=len(generated_data_list),
74
+ desc=f"Merging {split}"):
75
+ # Add (or overwrite) the missing field
76
+ generated_metadata['direct_answers'] = original_example.get('direct_answers')
77
+
78
+ # 7. Backup and overwrite
79
+ backup_filename = os.path.join(json_output_dir, f"{split}.backup.json")
80
+ try:
81
+ if not os.path.exists(backup_filename): # Backup only once
82
+ shutil.copyfile(json_filename, backup_filename)
83
+ print(f" Backed up original file to {backup_filename}")
84
+ else:
85
+ print(f" Backup file {backup_filename} already exists, will overwrite {json_filename} directly")
86
+ except Exception as e:
87
+ print(f" !! Warning: Failed to create backup: {e}. Will overwrite directly.")
88
+
89
+ print(f" Writing {len(generated_data_list)} updated metadata entries back to {json_filename}...")
90
+ with open(json_filename, 'w', encoding='utf-8') as f:
91
+ # generated_data_list has already been modified in memory
92
+ json.dump(generated_data_list, f, indent=4, ensure_ascii=False)
93
+
94
+ print("\n--- Patching completed! ---")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ from data_utils.paths import AOKVQA_DIR
99
+
100
+ print(f"Target root directory: {AOKVQA_DIR}")
101
+ patch_json_with_direct_answers(base_output_dir=AOKVQA_DIR)
data_utils/chart/__pycache__/evaluator.cpython-310.pyc ADDED
Binary file (8.35 kB). View file
 
data_utils/chart/pre_refinement.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # process_json_distributed.py
2
+
3
+ import json
4
+ import multiprocessing
5
+ import os
6
+ from copy import deepcopy
7
+ from tqdm import tqdm
8
+ import sys
9
+
10
+ from data_utils.paths import CHARTQA_JSON_DIR, PROJECT_ROOT
11
+ sys.path.append(PROJECT_ROOT)
12
+ from client_utils.openai_api import OpenAIClient
13
+ from data_utils.chart.prompts import prompt_refine
14
+ from data_utils.commom_util import prompt_ic
15
+
16
+ class ContextRefiner:
17
+
18
+ def __init__(self, CLIENT_CONFIG, gpu_id=0):
19
+ self.refine_templetes = ["""Goal: [State the user's objective, e.g., Find the year with the highest sales]
20
+ Observation: [List key data points from the chart, e.g., 2020: 150, 2021: 200, 2022: 180]
21
+ Reasoning: [State the logical step, e.g., Compare the values. 200 is the maximum.]
22
+ Conclusion: [Draw the conclusion, e.g., The year with the highest sales was 2021.]
23
+ """]
24
+ if CLIENT_CONFIG['client_type'] == 'openai':
25
+
26
+ self.client = OpenAIClient(config=CLIENT_CONFIG)
27
+ else:
28
+ raise ValueError(f"Client type '{CLIENT_CONFIG['client_type']}' not supported.")
29
+
30
+ def refine_hint(self, question: str, hint: str, reference_answer: str, task: str):
31
+ if not hint:
32
+ return hint
33
+ system_prompt = None
34
+ if 'chart' in task:
35
+ system_prompt = 'You are a seasoned professional in the field of chart analysis...'
36
+ else:
37
+ raise Exception('Unknown expert task')
38
+ try:
39
+ in_context_example = self.client.get_completion(prompt_ic % hint, system_prompt=system_prompt,
40
+ max_tokens=5000)
41
+ if 'chart' in task:
42
+ evaluation_prompt = prompt_refine % (in_context_example, question, reference_answer,
43
+ self.refine_templetes[0])
44
+ output = self.client.get_completion(evaluation_prompt, system_prompt=system_prompt, max_tokens=1000)
45
+ return output
46
+ else:
47
+ raise ValueError(f"Task '{task}' not supported for thinking reward.")
48
+ except Exception as e:
49
+ print(f"Error occurred while processing '{question}': {e}")
50
+ return hint
51
+
52
+
53
+ refiner_instance = None
54
+
55
+
56
+ def worker_initializer(base_client_config):
57
+ global refiner_instance
58
+
59
+ # Key change: get the unique ID of the current worker process (starting from 1)
60
+ # This is the variable we use to simulate gpu_id
61
+ worker_id = multiprocessing.current_process()._identity[0] - 1
62
+
63
+ # Create a deep copy of the config to avoid interference between processes
64
+ worker_config = deepcopy(base_client_config)
65
+
66
+ # Key change: implement your port calculation logic
67
+ if worker_config.get('init_port') is not None and worker_config.get('num_server') is not None:
68
+ num_server = int(worker_config['num_server'])
69
+ # server_id decides which port to use
70
+ server_id = worker_id % num_server
71
+ port = worker_config['init_port'] + server_id
72
+
73
+ # Format api_base to assign a fixed port for this process
74
+ worker_config['api_base'] = worker_config['api_base'] % str(port)
75
+
76
+ print(f"Process {os.getpid()} (Worker-{worker_id}) initializing... connecting to {worker_config['api_base']}")
77
+ else:
78
+ print(f"Process {os.getpid()} (Worker-{worker_id}) initializing... using default api_base")
79
+
80
+ # Use the customized config for this specific process to create the instance
81
+ refiner_instance = ContextRefiner(worker_config, gpu_id=worker_id)
82
+
83
+
84
+ def process_item_worker(item):
85
+ """Function executed by a single worker process (unchanged)"""
86
+ global refiner_instance
87
+ if refiner_instance is None:
88
+ raise Exception("Refiner has not been initialized in the worker process!")
89
+
90
+ new_hint = refiner_instance.refine_hint(
91
+ question=item['question'],
92
+ hint=item['hint'],
93
+ reference_answer=item['answer'],
94
+ task='chart'
95
+ )
96
+ item['hint'] = new_hint
97
+ return item
98
+
99
+
100
+ # ---------------- Main logic ----------------
101
+ def main():
102
+ # Configuration that contains port and server count information
103
+ from config import CLIENT_CONFIG
104
+ input_filename = os.path.join(CHARTQA_JSON_DIR, 'train.json')
105
+ output_filename = os.path.join(CHARTQA_JSON_DIR, 'train_new_prerefine.json')
106
+
107
+ NUM_PROCESSES = 64
108
+ print(f"Using {NUM_PROCESSES} processes and distributing requests to {CLIENT_CONFIG['num_server']} servers...")
109
+
110
+ try:
111
+ with open(input_filename, 'r', encoding='utf-8') as f:
112
+ data = json.load(f)
113
+ except FileNotFoundError:
114
+ print(f"Error: input file '{input_filename}' not found.")
115
+ return
116
+
117
+ processed_data = []
118
+
119
+ # Key point: pass the base configuration to the initializer of each process
120
+ with multiprocessing.Pool(processes=NUM_PROCESSES, initializer=worker_initializer,
121
+ initargs=(CLIENT_CONFIG,)) as pool:
122
+ with tqdm(total=len(data), desc="Processing JSON in parallel") as pbar:
123
+ for result in pool.imap_unordered(process_item_worker, data):
124
+ processed_data.append(result)
125
+ pbar.update(1)
126
+
127
+ with open(output_filename, 'w', encoding='utf-8') as f:
128
+ json.dump(processed_data, f, ensure_ascii=False, indent=4)
129
+
130
+ print(f"\nProcessing completed! Results saved to '{output_filename}'.")
131
+
132
+
133
+ if __name__ == "__main__":
134
+ main()
data_utils/chart/prompts.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ prompt_thinking_reward = """
2
+ Given
3
+ <IC>: the data of an image
4
+ <Q>: a question
5
+ <A>: a reference answer
6
+ <R>: a reasoning text
7
+
8
+ Goal:
9
+ Assess whether <R> correctly and reasonably uses visible data in <IC> to justify that the correct answer to <Q> is <A>. Rate the quality as low / medium / high according to:
10
+ (a) low: Does not use data from <IC> at all, or the language is not fluent/natural, or it fails to indicate the answer to <Q> is <A>, or the structure is not good.
11
+ (b) medium: Uses data from <IC> and is written fluently, but the reasoning is overly brief or insufficiently clear.
12
+ (c) high: Uses data from <IC> and is written fluently; Has a very strong CoT structure (e.g. clear and logical steps); the reasoning progresses step by step with depth, each step is correct and reasonable; the data from <IC> appears exactly where it should; overall, the reasoning text provides very strong support that the answer to <Q> is <A>.
13
+
14
+ Example:
15
+ <IC>: [
16
+ {"object": "bar", "attributes": ["~120k", "Q4"], "label": "Product A"},
17
+ {"object": "bar", "attributes": ["~150k", "Q4"], "label": "Product B"},
18
+ {"object": "bar", "attributes": ["~90k", "Q4"], "label": "Product C"},
19
+ {"title": "Quarterly Revenue"}
20
+ ]
21
+ <Q>: Which product has the highest revenue in Q4?
22
+ <A>: product b
23
+ <R>:
24
+ [Extraction] Reads Q4 bar heights: A ~120k, B ~150k, C ~90k.
25
+ [Calculation] Compares values: B > A and B > C.
26
+ [Conclusion] Therefore, Product B is highest, matching the answer "product b".
27
+
28
+ <Output>: medium
29
+
30
+ According to the requirements and examples above, score the input into three categories. Please give me the result directly without any explanation or description.
31
+
32
+ <IC>: %s
33
+ <Q>: %s
34
+ <A>: %s
35
+ <R>: %s
36
+ <Output>:
37
+ """
38
+
39
+
40
+ prompt_refine = """Given:
41
+ <IC>: the data of an image
42
+ <Q>: a question
43
+ <A>: a reference answer
44
+ <T>: a writing template
45
+
46
+ Goal:
47
+ Transform the visual information in <IC> into a textualized data description and incorporate it into a smooth, natural explanation that reasons why the correct answer to <Q> is <A>, using the format and tone defined by <T>.
48
+
49
+ Example:
50
+ <IC>: [
51
+ {"Year": 2011, "Favorable": 0, "Unfavorable": 3.1},
52
+ {"Year": 2012, "Favorable": 56, "Unfavorable": 38.0},
53
+ {"Year": 2013, "Favorable": 0, "Unfavorable": 0.0},
54
+ {"Year": 2014, "Favorable": 51, "Unfavorable": 48.0},
55
+ {"Year": 2015, "Favorable": 0, "Unfavorable": 53.0}
56
+ ]
57
+ <Q>: In which year the value was 51?
58
+ <A>: 2014
59
+ <T>:
60
+ Goal: [State the user's objective, e.g., Find the year with the highest sales]
61
+ Observation: [List key data points from the chart, e.g., 2020: 150, 2021: 200, 2022: 180]
62
+ Reasoning: [State the logical step, e.g., Compare the values. 200 is the maximum.]
63
+ Conclusion: [Draw the conclusion, e.g., The year with the highest sales was 2021.]
64
+
65
+ <Output>:
66
+ Goal: Find the year in which the 'Favorable' value was 51.
67
+ Observation: The data shows the 'Favorable' values for each year are: 2011: 0, 2012: 56, 2013: 0, 2014: 51, and 2015: 0.
68
+ Reasoning: Scanning the 'Favorable' column for the number 51 leads to the corresponding year in that row.
69
+ Conclusion: The value 51 occurred in the year 2014.
70
+
71
+ Now, according to the requirements and the examples above, convert my input into the target reasoning text. Please give me the result directly without any explanation or description.
72
+
73
+ <IC>: %s
74
+ <Q>: %s
75
+ <A>: %s
76
+ <T>: %s
77
+ <Output>:
78
+ """
79
+
80
+ prompt_template = """Analyze the preceding text (which is a Chain of Thought, or "CoT").
81
+
82
+ **Your Task:**
83
+ 1. **Evaluate Structure and Logic:** First, determine if the CoT is **well-structured** and **logical**. A "well-structured" CoT contains clear, distinct, and labeled reasoning steps (e.g., "Goal:", "Observation:", "Reasoning:", "Conclusion:").
84
+ 2. **Extract Template:** If (and only if) the CoT is **well-structured**, extract a **generic reasoning template** from it. In this template, use brackets `[ ]` to describe the general purpose of each step (as shown in Example 1).
85
+ 3. **Output None:** If the CoT is **unstructured** (e.g., it reads like a single conversational paragraph), lacks clear steps, or is logically unsound, you must output **None** (as shown in Example 2).
86
+
87
+ ---
88
+
89
+ **Example 1: [Well-Structured CoT]**
90
+
91
+ **Input:**
92
+ "Goal: Find the lowest value of the red graph.\nObservation: The data for the 'Rep/Lean Rep' category across the years are: 2018: 72, 2019: 70, and 2020: 77.\nReasoning: Comparing the values, the minimum value is 70.\nConclusion: The lowest value of the red graph is 70."
93
+
94
+ **Output:**
95
+ Goal: [State the user's objective, e.g., Find the year with the highest sales]
96
+ Observation: [List key data points from the chart, e.g., 2020: 150, 2021: 200, 2022: 180]
97
+ Reasoning: [State the logical step, e.g., Compare the values. 200 is the maximum.]
98
+ Conclusion: [Draw the conclusion, e.g., The year with the highest sales was 2021.]
99
+
100
+ ---
101
+
102
+ **Example 2: [Poorly-Structured / Conversational CoT]**
103
+
104
+ **Input:**
105
+ I'm trying to figure out the year when the unfavorable view reaches its highest point. Looking at the data, I see that the values for each year are pretty low until 2016, where it jumps to 55. But the peak doesn't happen until 2017, when the value spikes to 64. So, it seems like the unfavorable view really hits its maximum in 2017.
106
+
107
+ **Output:**
108
+ None
109
+
110
+ Now, according to the requirements and the examples above, give me the result directly without any explanation or description.
111
+ **Input:**
112
+ %s
113
+ **Output:**
114
+ """
data_utils/clevr/clevr_processor.py ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ from accelerate.utils import gather_object
4
+
5
+ try:
6
+ from transformers import activations
7
+
8
+ activations.PytorchGELUTanh = activations.GELUTanh
9
+ except ImportError:
10
+ print("Note: Unable to apply PytorchGELUTanh patch. If you encounter an ImportError, please check the transformers version.")
11
+ # --- End of patch ---
12
+
13
+ import os
14
+ import shutil
15
+ import json
16
+ import re
17
+ import numpy as np
18
+ from PIL import Image
19
+ from tqdm import tqdm
20
+ from datasets import load_dataset
21
+ from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor
22
+ from accelerate import Accelerator
23
+
24
+ # (qwen_vl_utils import and fallback remain unchanged)
25
+ try:
26
+ from qwen_vl_utils import process_vision_info
27
+ except ImportError:
28
+ print("Warning: Failed to import 'qwen_vl_utils.process_vision_info'.")
29
+
30
+
31
+ def process_vision_info(messages):
32
+ images = []
33
+ for msg in messages:
34
+ if msg['role'] == 'user':
35
+ for content in msg['content']:
36
+ if content['type'] == 'image':
37
+ images.append(content['image'])
38
+ return images, None # Return (images, videos)
39
+
40
+ Image.MAX_IMAGE_PIXELS = None
41
+
42
+ # (RexOmni dependency and DummyRex remain unchanged)
43
+ try:
44
+ from rex_omni import RexOmniWrapper
45
+ except ImportError:
46
+ print("Warning: 'from rex_omni import RexOmniWrapper' failed.")
47
+ print("Using a dummy RexOmniWrapper (DummyRex) for testing only.")
48
+
49
+
50
+ class DummyRex:
51
+ def __init__(self, *args, **kwargs):
52
+ print("INFO: DUMMY: Using DummyRex detector.")
53
+
54
+ def inference(self, images, task, categories, **kwargs):
55
+ print("INFO: DUMMY: DummyRex returning fake center boxes.")
56
+
57
+ # Batch-supporting Dummy
58
+ results = []
59
+
60
+ # Ensure images is a list
61
+ if not isinstance(images, list):
62
+ images = [images]
63
+
64
+ for img in images:
65
+ if isinstance(img, Image.Image):
66
+ w, h = img.size
67
+ else:
68
+ w, h = 800, 600
69
+ x0, y0 = w * 0.25, h * 0.25
70
+ x1, y1 = w * 0.75, h * 0.75
71
+ results.append({"extracted_predictions": {"anything": [{"type": "box", "coords": [x0, y0, x1, y1]}]}})
72
+ return results
73
+
74
+
75
+ RexOmniWrapper = DummyRex
76
+
77
+
78
+ def _strip_tags(text, tag_name):
79
+ # (unchanged)
80
+ if not isinstance(text, str):
81
+ text = str(text)
82
+ text = re.sub(rf'<{tag_name}>', '', text, flags=re.IGNORECASE)
83
+ text = re.sub(rf'</{tag_name}>', '', text, flags=re.IGNORECASE)
84
+ return text.strip()
85
+
86
+
87
+ # --- Core VQA helper functions (moved to global scope) ---
88
+
89
+ def _crop_and_expand_box(image, box, padding_pixels=20):
90
+ # (unchanged)
91
+ x0, y0, x1, y1 = [int(c) for c in box]
92
+ img_w, img_h = image.size
93
+ x0_new = max(0, x0 - padding_pixels)
94
+ y0_new = max(0, y0 - padding_pixels)
95
+ x1_new = min(img_w, x1 + padding_pixels)
96
+ y1_new = min(img_h, y1 + padding_pixels)
97
+ return image.crop((x0_new, y0_new, x1_new, y1_new))
98
+
99
+
100
+ # --- ★★★ Optimization 1: Change VQA queries to batched processing ★★★ ---
101
+ def _query_qwen_vl_BATCH(crop_images_list, model, processor, accelerator):
102
+ """
103
+ Use Qwen-VL to query cropped image patches in batch and return a list of JSON strings.
104
+ """
105
+ if not crop_images_list:
106
+ return []
107
+
108
+ prompt = """This is an object from a CLEVR scene. Analyze the primary object in the image.
109
+ Respond *strictly* with a JSON list (containing one dictionary) in the following format:
110
+ [
111
+ {"object": "object_name", "attributes": ["attr1", "attr2"]}
112
+ ]
113
+ - "object": The shape of the object (e.g., "sphere", "cube", "cylinder", "cone").
114
+ - "attributes": A list of visual attributes (e.g., ["blue", "large", "metal", "shiny", "rubber"]).
115
+ Provide only the JSON list:"""
116
+
117
+ # 1. Create messages for each image in the batch
118
+ template_messages = [
119
+ {
120
+ "role": "user",
121
+ "content": [
122
+ {"type": "image", "image": "placeholder.jpg"}, # Placeholder
123
+ {"type": "text", "text": prompt},
124
+ ],
125
+ }
126
+ ]
127
+
128
+ try:
129
+ # 2. Generate chat prompt text once
130
+ chat_prompt_text = processor.apply_chat_template(
131
+ template_messages, tokenize=False, add_generation_prompt=True
132
+ )
133
+
134
+ num_crops = len(crop_images_list)
135
+ batch_text = [chat_prompt_text] * num_crops
136
+ batch_images = crop_images_list
137
+
138
+ unwrapped_model = accelerator.unwrap_model(model)
139
+
140
+ # 3. Use text list and image list for batched processing
141
+ inputs = processor(
142
+ text=batch_text,
143
+ images=batch_images,
144
+ padding=True, # Important: enable padding to handle batching
145
+ return_tensors="pt",
146
+ ).to(unwrapped_model.device)
147
+
148
+ # 4. Batch generation
149
+ generated_ids = unwrapped_model.generate(**inputs, max_new_tokens=256, do_sample=False)
150
+
151
+ generated_ids_trimmed = [
152
+ out_ids[len(in_ids):] for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
153
+ ]
154
+
155
+ # 5. Batch decode
156
+ output_texts_list = processor.batch_decode(
157
+ generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
158
+ )
159
+
160
+ return output_texts_list
161
+
162
+ except Exception as e:
163
+ print(f"Qwen-VL batched inference failed: {e}")
164
+ # Return a list of empty JSON placeholders to avoid zip errors
165
+ return ["[]"] * len(crop_images_list)
166
+
167
+
168
+ def _parse_qwen_json(response_text):
169
+ # (unchanged)
170
+ try:
171
+ match = re.search(r'```json\s*(\[.*\])\s*```', response_text, re.DOTALL)
172
+ if match:
173
+ json_str = match.group(1)
174
+ return json.loads(json_str)
175
+ match = re.search(r'(\[.*\])', response_text, re.DOTALL)
176
+ if match:
177
+ json_str = match.group(1)
178
+ return json.loads(json_str)
179
+ return []
180
+ except json.JSONDecodeError:
181
+ print(f"Failed to parse JSON: {response_text}")
182
+ return []
183
+ except Exception as e:
184
+ print(f"Unknown error occurred while parsing JSON: {e}")
185
+ return []
186
+
187
+
188
+ def _load_and_preprocess_data(base_output_dir, image_output_dir):
189
+ # (unchanged)
190
+ print("Loading 'MMInstruction/Clevr_CoGenT_TrainA_R1'...")
191
+ try:
192
+ dataset = load_dataset("MMInstruction/Clevr_CoGenT_TrainA_R1", split='train')
193
+ except Exception as e:
194
+ print(f"Failed to load dataset 'MMInstruction/Clevr_CoGenT_TrainA_R1': {e}")
195
+ return []
196
+
197
+ # Only use the first few samples for testing (still 100 here)
198
+ # dataset = dataset.select(range(100))
199
+ print(f"Loaded {len(dataset)} samples.")
200
+
201
+ job_list = []
202
+ print("Preprocessing data (saving images and parsing text)...")
203
+ for i, example in enumerate(tqdm(dataset, desc="Preprocessing progress")):
204
+ prompt = example['problem']
205
+ hint = _strip_tags(example['thinking'], 'think')
206
+ answer = _strip_tags(example['solution'], 'answer')
207
+
208
+ image = example['image']
209
+ if not isinstance(image, Image.Image):
210
+ print(f"Warning: sample {i} is not a PIL image, skipped.")
211
+ continue
212
+
213
+ image_filename = f"clevr_cogent_trainA_r1_{i:07d}.jpg"
214
+ destination_image_path = os.path.join(image_output_dir, image_filename)
215
+
216
+ try:
217
+ os.makedirs(os.path.dirname(destination_image_path), exist_ok=True)
218
+ if not os.path.exists(destination_image_path):
219
+ image.convert("RGB").save(destination_image_path, "JPEG")
220
+ except Exception as e:
221
+ print(f"Warning: failed to save image for sample {i}, skipped. Error: {e}")
222
+ continue
223
+
224
+ job_list.append({
225
+ "prompt": prompt,
226
+ "answer": answer,
227
+ "hint": hint,
228
+ "destination_image_path": destination_image_path
229
+ })
230
+
231
+ print(f"Successfully preprocessed {len(job_list)} items.")
232
+
233
+ job_list_path = os.path.join(base_output_dir, "job_list.json")
234
+ with open(job_list_path, 'w', encoding='utf-8') as f:
235
+ json.dump(job_list, f)
236
+
237
+ print(f"Job list saved to: {job_list_path}")
238
+ return job_list
239
+
240
+
241
+ def main():
242
+ # (1. Initialize Accelerator - unchanged)
243
+ accelerator = Accelerator()
244
+
245
+ # (0. Define configuration - unchanged)
246
+ MODEL_CONFIGS = {
247
+ "rex_path": "IDEA-Research/Rex-Omni",
248
+ "qwen_path": "Qwen/Qwen2.5-VL-32B-Instruct-AWQ"
249
+ }
250
+ OUTPUT_DIR = "/path/to/data/clevr_cogent_output"
251
+ IMAGE_OUTPUT_DIR = os.path.join(OUTPUT_DIR, "images")
252
+ JSON_OUTPUT_DIR = os.path.join(OUTPUT_DIR, "json")
253
+
254
+ os.makedirs(IMAGE_OUTPUT_DIR, exist_ok=True)
255
+ os.makedirs(JSON_OUTPUT_DIR, exist_ok=True)
256
+
257
+ # (2. Preprocessing (run only on main process) - unchanged)
258
+ job_list_path = os.path.join(OUTPUT_DIR, "job_list.json")
259
+ if accelerator.is_main_process:
260
+ print("Main process [Pre-processing]: loading and preprocessing data...")
261
+ _load_and_preprocess_data(OUTPUT_DIR, IMAGE_OUTPUT_DIR)
262
+
263
+ # (3. Synchronization - unchanged)
264
+ accelerator.wait_for_everyone()
265
+
266
+ # (4. Load and distribute jobs - unchanged)
267
+ if not accelerator.is_main_process:
268
+ print(f"Process {accelerator.process_index}: loading job_list.json...")
269
+ try:
270
+ with open(job_list_path, 'r', encoding='utf-8') as f:
271
+ all_jobs = json.load(f)
272
+ except Exception as e:
273
+ print(f"Process {accelerator.process_index} failed to load job_list.json: {e}")
274
+ return
275
+ total_jobs = len(all_jobs)
276
+ num_processes = accelerator.num_processes
277
+ jobs_per_process = total_jobs // num_processes
278
+ start_index = accelerator.process_index * jobs_per_process
279
+ end_index = (accelerator.process_index + 1) * jobs_per_process
280
+ if accelerator.is_last_process:
281
+ end_index = total_jobs
282
+ my_jobs = all_jobs[start_index:end_index]
283
+ print(f"[Process {accelerator.process_index}]:"
284
+ f" assigned {len(my_jobs)} jobs (indices from {start_index} to {end_index}).")
285
+
286
+ # (5. Load models (each process loads its own copy) - unchanged)
287
+ try:
288
+ try:
289
+ from transformers import activations
290
+ activations.PytorchGELUTanh = activations.GELUTanh
291
+ except ImportError:
292
+ pass
293
+
294
+ print(f"[Process {accelerator.process_index}]: loading RexOmni...")
295
+ rex_model = RexOmniWrapper(
296
+ model_path=MODEL_CONFIGS['rex_path'],
297
+ backend="transformers",
298
+ max_tokens=2048,
299
+ temperature=0.0,
300
+ )
301
+
302
+ print(f"[Process {accelerator.process_index}]: loading Qwen-VL...")
303
+ qwen_model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
304
+ MODEL_CONFIGS['qwen_path'],
305
+ torch_dtype="float16",
306
+ device_map="cuda",
307
+ attn_implementation="flash_attention_2"
308
+ )
309
+ qwen_processor = AutoProcessor.from_pretrained(MODEL_CONFIGS['qwen_path'])
310
+
311
+ # Note: RexOmniWrapper (if not Dummy) may need .to(accelerator.device),
312
+ # but Qwen-VL already has its device specified via device_map="cuda".
313
+ # accelerator.prepare is still a good way to manage models.
314
+ qwen_model, rex_model = accelerator.prepare(qwen_model, rex_model)
315
+
316
+ print(f"[Process {accelerator.process_index}]: models loaded.")
317
+ except Exception as e:
318
+ print(f"[Process {accelerator.process_index}]: failed to load models: {e}")
319
+ import traceback
320
+ traceback.print_exc()
321
+ return
322
+
323
+ # 1. Define a batch size (Batch Size)
324
+ REX_BATCH_SIZE = 16 # <-- Adjust this value according to your VRAM
325
+
326
+ print(f"[Process {accelerator.process_index}]:"
327
+ f" starting to process {len(my_jobs)} jobs with Rex batch size {REX_BATCH_SIZE}.")
328
+
329
+ processed_metadata_list = []
330
+
331
+ # 2. Modify main loop: iterate with step REX_BATCH_SIZE
332
+ for i in tqdm(range(0, len(my_jobs), REX_BATCH_SIZE),
333
+ desc=f"Worker {accelerator.process_index} batch progress",
334
+ disable=not accelerator.is_main_process):
335
+
336
+ # 3. Prepare jobs and images for this batch
337
+ batch_jobs = my_jobs[i: i + REX_BATCH_SIZE]
338
+ batch_images = []
339
+ batch_image_paths = [] # for debugging
340
+
341
+ valid_jobs_in_batch = []
342
+
343
+ for job in batch_jobs:
344
+ try:
345
+ img_path = job['destination_image_path']
346
+ batch_image_paths.append(img_path)
347
+ batch_images.append(Image.open(img_path).convert("RGB"))
348
+ valid_jobs_in_batch.append(job) # only jobs with successfully loaded images are valid
349
+ except Exception as e:
350
+ print(f"[Process {accelerator.process_index}]:"
351
+ f" failed to load image {img_path}: {e}, skipping this image in this batch.")
352
+ # We do not add the image or job, keeping batch_images and valid_jobs_in_batch in sync
353
+
354
+ if not batch_images: # if all images in this batch failed to load
355
+ continue
356
+
357
+ try:
358
+ # 4. ★ Key: run RexOmni in batch
359
+ # (we only pass successfully loaded images)
360
+ all_rex_results = rex_model.inference(
361
+ images=batch_images, # pass the image list
362
+ task="detection",
363
+ categories=["anything"]
364
+ )
365
+
366
+ # 5. Iterate over results in this batch
367
+ # all_rex_results length should equal batch_images (and valid_jobs_in_batch)
368
+ if len(all_rex_results) != len(valid_jobs_in_batch):
369
+ print(f"[Process {accelerator.process_index}]: Warning: RexOmni "
370
+ f"returned {len(all_rex_results)} results, but "
371
+ f"{len(valid_jobs_in_batch)} inputs were provided. Skipping this batch.")
372
+ continue
373
+
374
+ for job, image, rex_result in zip(valid_jobs_in_batch, batch_images, all_rex_results):
375
+
376
+ predictions = rex_result["extracted_predictions"]
377
+ detected_boxes = predictions.get("anything", [])
378
+
379
+ visual_facts = []
380
+ crops_to_process = []
381
+ box_coords_list = []
382
+
383
+ # 6. Collect all crops to be processed (from this image)
384
+ for annotation in detected_boxes:
385
+ if annotation.get("type") == "box" and len(annotation.get("coords", [])) == 4:
386
+ coords = annotation["coords"]
387
+ crop_image = _crop_and_expand_box(image, coords)
388
+ crops_to_process.append(crop_image)
389
+ box_coords_list.append(coords)
390
+
391
+ # 7. Batch VQA (logic remains the same, still batch *per image* crops)
392
+ if crops_to_process:
393
+ json_str_list = _query_qwen_vl_BATCH(
394
+ crops_to_process, qwen_model, qwen_processor, accelerator
395
+ )
396
+
397
+ # 8. Iterate over batched results and parse (logic unchanged)
398
+ for json_str, coords in zip(json_str_list, box_coords_list):
399
+ json_obj_list = _parse_qwen_json(json_str)
400
+ if json_obj_list:
401
+ try:
402
+ obj_dict = json_obj_list[0]
403
+ obj_dict["bounding_box"] = [round(c, 2) for c in coords]
404
+ visual_facts.append(obj_dict)
405
+ except (IndexError, TypeError, KeyError) as e:
406
+ print(f"[Process {accelerator.process_index}]: "
407
+ f"Error while parsing batched result: {e} | JSON: {json_str}")
408
+
409
+ # 9. Aggregate results for this job (logic unchanged)
410
+ processed_metadata_list.append({
411
+ "question": job['prompt'],
412
+ "answer": job['answer'],
413
+ "question_wo_prompt": job['prompt'],
414
+ "hint": job['hint'],
415
+ "image": job['destination_image_path'],
416
+ "visual_fact": visual_facts
417
+ })
418
+ # --- End of inner loop logic ---
419
+
420
+ except Exception as e:
421
+ print(f"[Process {accelerator.process_index}]: "
422
+ f"Error while processing batch {i // REX_BATCH_SIZE} (images {batch_image_paths}): {e}")
423
+ import traceback
424
+ traceback.print_exc()
425
+
426
+ # --- End of loop ---
427
+
428
+ print(f"[Process {accelerator.process_index}]:"
429
+ f" process finished, handled {len(processed_metadata_list)} items.")
430
+
431
+ # (7. Gather all results - unchanged)
432
+ print(f"[Process {accelerator.process_index}]: gathering results...")
433
+ all_results_list_of_lists = gather_object(processed_metadata_list)
434
+
435
+ # (8. Save (only on main process) - ★★★ using fixed GATHER logic ★★★)
436
+ if accelerator.is_main_process:
437
+ print("Main process [Saving]: aggregating and saving all results...")
438
+
439
+ # --- Key fix ---
440
+ # gather_object already returns a flattened list of dictionaries (List[dict]).
441
+ final_metadata_list = all_results_list_of_lists
442
+ # --- End of fix ---
443
+
444
+ json_filename = os.path.join(JSON_OUTPUT_DIR, "clevr_cogent_trainA_r1_processed.json")
445
+
446
+ # Verify the count
447
+ print(f"Total number of aggregated items: {len(final_metadata_list)}")
448
+ if len(final_metadata_list) > 0:
449
+ print(f"Type of first item: {type(final_metadata_list[0])}")
450
+
451
+ print(f"\nSaving {len(final_metadata_list)} metadata entries to {json_filename}...")
452
+ with open(json_filename, 'w', encoding='utf-8') as f:
453
+ json.dump(final_metadata_list, f, indent=4, ensure_ascii=False)
454
+
455
+ print(f"\n--- Processing completed! ---")
456
+ print(f"All image files have been saved in: '{IMAGE_OUTPUT_DIR}'")
457
+ print(f"Final JSON file has been saved in: '{JSON_OUTPUT_DIR}'")
458
+
459
+
460
+ if __name__ == "__main__":
461
+ main()
data_utils/clevr/run_single_test.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ from transformers import activations
4
+ activations.PytorchGELUTanh = activations.GELUTanh
5
+ import os
6
+ import json
7
+ from PIL import Image
8
+ from datasets import load_dataset
9
+ from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor
10
+
11
+ # Import model wrapper
12
+ try:
13
+ from rex_omni import RexOmniWrapper
14
+ except ImportError:
15
+ # Import DummyRex matching the one in clevr_processor.py
16
+ print("Warning: 'from rex_omni import RexOmniWrapper' failed.")
17
+ print("Using a dummy RexOmniWrapper (DummyRex) for testing only.")
18
+
19
+
20
+ class DummyRex:
21
+ def __init__(self, *args, **kwargs):
22
+ print("INFO: DUMMY: Using DummyRex detector.")
23
+
24
+ def inference(self, images, task, categories, **kwargs):
25
+ print("INFO: DUMMY: DummyRex returning a fake center box.")
26
+ if isinstance(images, Image.Image):
27
+ w, h = images.size
28
+ else:
29
+ w, h = 800, 600
30
+ x0, y0 = w * 0.25, h * 0.25
31
+ x1, y1 = w * 0.75, h * 0.75
32
+ return [{"extracted_predictions": {"anything": [{"type": "box", "coords": [x0, y0, x1, y1]}]}}]
33
+
34
+
35
+ RexOmniWrapper = DummyRex
36
+
37
+ try:
38
+ from qwen_vl_utils import process_vision_info
39
+ except ImportError:
40
+ print("Warning: Failed to import 'qwen_vl_utils.process_vision_info'.")
41
+
42
+
43
+ def process_vision_info(messages):
44
+ images = []
45
+ for msg in messages:
46
+ if msg['role'] == 'user':
47
+ for content in msg['content']:
48
+ if content['type'] == 'image':
49
+ images.append(content['image'])
50
+ return images, None
51
+
52
+ from clevr_processor import ClevrFactExtractor, _strip_tags
53
+
54
+
55
+ def run_test(configs, paths, gpu_id=0, sample_index=0):
56
+ """
57
+ Run the test pipeline on a single sample.
58
+ """
59
+ print("--- Starting single-run test (CoGenT) ---")
60
+
61
+ # --- 1. Set environment and load models ---
62
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(gpu_id)
63
+ print(f"Set CUDA_VISIBLE_DEVICES={gpu_id}")
64
+
65
+ try:
66
+ print(f"Loading RexOmni... ({configs['rex_path']})")
67
+ rex_model = RexOmniWrapper(
68
+ model_path=configs['rex_path'],
69
+ backend="transformers",
70
+ max_tokens=2048,
71
+ temperature=0.0,
72
+ )
73
+
74
+ print(f"Loading Qwen-VL... ({configs['qwen_path']})")
75
+ qwen_model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
76
+ configs['qwen_path'],
77
+ torch_dtype="float16",
78
+ device_map="cuda:0",
79
+ attn_implementation="flash_attention_2"
80
+ )
81
+ qwen_processor = AutoProcessor.from_pretrained(configs['qwen_path'])
82
+
83
+ print("Models loaded.")
84
+ except Exception as e:
85
+ print(f"Failed to load models: {e}")
86
+ return
87
+
88
+ print("Loading dataset metadata...")
89
+ try:
90
+ dataset = load_dataset("MMInstruction/Clevr_CoGenT_TrainA_R1", split='train', streaming=True)
91
+ example_iter = iter(dataset)
92
+ for _ in range(sample_index + 1):
93
+ example = next(example_iter)
94
+
95
+ except Exception as e:
96
+ print(f"Failed to load or filter dataset: {e}")
97
+ return
98
+
99
+ print(f"Processing sample {sample_index}...")
100
+
101
+ try:
102
+ # 1. Preprocessing
103
+ prompt = example['problem']
104
+ hint = _strip_tags(example['thinking'], 'think')
105
+ answer = _strip_tags(example['solution'], 'answer')
106
+ image = example['image'].convert("RGB") # Get PIL image and convert to RGB
107
+
108
+ # Save image for testing
109
+ destination_image_path = os.path.join(paths['output_dir'], "images", f"test_sample_{sample_index}.jpg")
110
+ os.makedirs(os.path.dirname(destination_image_path), exist_ok=True)
111
+ image.save(destination_image_path, "JPEG")
112
+ print(f"Loaded and saved test image: {destination_image_path}")
113
+
114
+ # --- Stage 1: RexOmni detection ---
115
+ print("Running RexOmni detection...")
116
+ rex_results = rex_model.inference(images=image, task="detection", categories=["anything"])
117
+ predictions = rex_results[0]["extracted_predictions"]
118
+ detected_boxes = predictions.get("anything", [])
119
+ print(f"RexOmni detected {len(detected_boxes)} 'anything' boxes.")
120
+
121
+ visual_facts = []
122
+
123
+ # --- Stage 2: Qwen-VL VQA ---
124
+ for i, annotation in enumerate(detected_boxes):
125
+ if annotation.get("type") == "box" and len(annotation.get("coords", [])) == 4:
126
+
127
+ coords = annotation["coords"]
128
+ print(f" Processing box {i}: {coords}")
129
+
130
+ crop_image = ClevrFactExtractor._crop_and_expand_box(image, coords)
131
+
132
+ # Save cropped image for debugging
133
+ crop_filename = f"./test_crop_{sample_index}_{i}.jpg"
134
+ crop_image.save(crop_filename)
135
+ print(f" -> Saved cropped image for inspection: {crop_filename}")
136
+
137
+ json_str = ClevrFactExtractor._query_qwen_vl(
138
+ crop_image, qwen_model, qwen_processor
139
+ )
140
+
141
+ json_obj_list = ClevrFactExtractor._parse_qwen_json(json_str)
142
+
143
+ if json_obj_list:
144
+ obj_dict = json_obj_list[0]
145
+ obj_dict["bounding_box"] = [round(c, 2) for c in coords]
146
+ visual_facts.append(obj_dict)
147
+ print(f" -> Qwen-VL result: {obj_dict}")
148
+ else:
149
+ print(f" -> Qwen-VL did not return valid JSON.")
150
+
151
+ # --- 4. Print final result ---
152
+ final_result = {
153
+ "prompt": prompt,
154
+ "answer": answer,
155
+ "hint": hint,
156
+ "image": destination_image_path,
157
+ "visual_fact": visual_facts
158
+ }
159
+
160
+ print("\n" + "=" * 30)
161
+ print("--- Single test result ---")
162
+ print(json.dumps(final_result, indent=4, ensure_ascii=False))
163
+ print("=" * 30 + "\n")
164
+
165
+ except Exception as e:
166
+ print(f"Error while processing sample {sample_index}: {e}")
167
+ import traceback
168
+ traceback.print_exc()
169
+
170
+
171
+ if __name__ == "__main__":
172
+ # --- 1. Model configs ---
173
+ MODEL_CONFIGS = {
174
+ "rex_path": "IDEA-Research/Rex-Omni",
175
+ "qwen_path": "Qwen/Qwen2.5-VL-32B-Instruct-AWQ"
176
+ }
177
+
178
+ # --- 2. Paths config ---
179
+ PATHS = {
180
+ # !! Change this to the directory where you want to save images and JSON !!
181
+ "output_dir": "./clevr_cogent_output"
182
+ }
183
+
184
+ # --- 3. Test parameters ---
185
+ GPU_ID_TO_USE = 0
186
+ SAMPLE_INDEX_TO_TEST = 0 # Test the first CLEVR sample
187
+
188
+ # --- 4. Run test ---
189
+ run_test(
190
+ configs=MODEL_CONFIGS,
191
+ paths=PATHS,
192
+ gpu_id=GPU_ID_TO_USE,
193
+ sample_index=SAMPLE_INDEX_TO_TEST
194
+ )
data_utils/paths.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Project-relative data paths for DyME."""
2
+ import os
3
+
4
+ PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
5
+
6
+ DATA_DIR = os.path.join(PROJECT_ROOT, "data")
7
+ DATA_IMAGES_DIR = os.path.join(DATA_DIR, "images")
8
+
9
+ CHARTQA_DIR = os.path.join(DATA_IMAGES_DIR, "chartqa")
10
+ CHARTQA_IMAGES_DIR = os.path.join(CHARTQA_DIR, "images")
11
+ CHARTQA_JSON_DIR = os.path.join(CHARTQA_DIR, "json")
12
+
13
+ AOKVQA_DIR = os.path.join(DATA_IMAGES_DIR, "aokvqa")
14
+ AOKVQA_IMAGES_DIR = os.path.join(AOKVQA_DIR, "images")
15
+ AOKVQA_JSON_DIR = os.path.join(AOKVQA_DIR, "json")
16
+
17
+ OUTPUTS_DIR = os.path.join(PROJECT_ROOT, "outputs")
18
+
19
+ # Legacy absolute prefixes in JSON files -> canonical project directories
20
+ _LEGACY_PREFIX_MAP = [
21
+ ("/chartqa_output/", CHARTQA_DIR + os.sep),
22
+ ("/path/to/chartqa_output/", CHARTQA_DIR + os.sep),
23
+ ("/path/to/data/chartqa_output/", CHARTQA_DIR + os.sep),
24
+ ("/path/to/data/aokvqa/", AOKVQA_DIR + os.sep),
25
+ ]
26
+
27
+
28
+ def project_path(*parts: str) -> str:
29
+ return os.path.join(PROJECT_ROOT, *parts)
30
+
31
+
32
+ def resolve_image_path(path: str) -> str:
33
+ """Resolve a stored image path to an existing file under the project tree."""
34
+ if not path:
35
+ return path
36
+ if os.path.exists(path):
37
+ return path
38
+
39
+ candidates = []
40
+ for old, new in _LEGACY_PREFIX_MAP:
41
+ if old in path:
42
+ candidates.append(path.replace(old, new))
43
+
44
+ if not os.path.isabs(path):
45
+ candidates.append(os.path.join(PROJECT_ROOT, path))
46
+
47
+ basename = os.path.basename(path)
48
+ candidates.extend([
49
+ os.path.join(CHARTQA_IMAGES_DIR, basename),
50
+ os.path.join(AOKVQA_IMAGES_DIR, basename),
51
+ os.path.join(PROJECT_ROOT, "chartqa_output", "images", basename),
52
+ ])
53
+
54
+ for candidate in candidates:
55
+ if os.path.exists(candidate):
56
+ return os.path.abspath(candidate)
57
+
58
+ return path
default_config_zero2.yaml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ compute_environment: LOCAL_MACHINE
2
+ debug: false
3
+ deepspeed_config:
4
+ deepspeed_config_file: configs/deepspeed/zero2_bf16.json
5
+ zero3_init_flag: false
6
+ distributed_type: DEEPSPEED
7
+ downcast_bf16: 'no'
8
+ enable_cpu_affinity: false
9
+ machine_rank: 0
10
+ main_training_function: main
11
+ num_machines: 1
12
+ # ZeRO-2 student sharding (HF Accelerate + DeepSpeed JSON). Pair with DYME_TEACHER_DEVICE_MAP=auto.
13
+ # Do NOT set mixed_precision / zero_stage here when using deepspeed_config_file (Accelerate requirement).
14
+ num_processes: 2
15
+ rdzv_backend: static
16
+ same_network: true
17
+ tpu_env: []
18
+ tpu_use_cluster: false
19
+ tpu_use_sudo: false
20
+ use_cpu: false
main_7B.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # train_grpo.py
2
+ """
3
+ Main script for training a Llava-based model using the custom MyGRPOTrainer.
4
+
5
+ This script handles:
6
+ 1. Configuration loading.
7
+ 2. Initialization of Weights & Biases (wandb) and Hugging Face Accelerate.
8
+ 3. Loading the model (with PEFT/LoRA) and processor.
9
+ 4. Preparing the training and evaluation datasets.
10
+ 5. Setting up and running the GPRO trainer.
11
+ """
12
+
13
+ import os
14
+ from functools import partial
15
+ from typing import Dict, Any
16
+
17
+ import torch
18
+ import wandb
19
+ from accelerate import Accelerator
20
+ from datasets import Dataset, load_dataset
21
+ from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration
22
+ from trl import GRPOConfig
23
+ from peft import LoraConfig, get_peft_model, TaskType # NEW: Import PEFT modules
24
+
25
+ from config.config_7B import CONFIG
26
+ from data_utils.commom_util import collate_fn, define_task_data_func
27
+ from trainer.DyMETrainer_7B import DyMETrainer
28
+ from reward_utils.checker import RewardCalculator, RewardCalculatorLocal
29
+ from reward_utils.refiner import ContextRefiner, ContextRefinerLocal
30
+
31
+
32
+ def setup_accelerator_and_wandb(bf16) -> Accelerator:
33
+ """
34
+ Initializes Weights & Biases and the Hugging Face Accelerator.
35
+
36
+ Returns:
37
+ Accelerator: The configured accelerator instance.
38
+ """
39
+ wandb_key = os.environ.get("WANDB_API_KEY")
40
+ if wandb_key:
41
+ wandb.login(key=wandb_key)
42
+ if bf16:
43
+ accelerator = Accelerator(mixed_precision="bf16", log_with="wandb")
44
+ else:
45
+ accelerator = Accelerator(log_with="wandb")
46
+ return accelerator
47
+
48
+
49
+ # NEW: Updated function to accept peft_config
50
+ def load_model_and_processor(model_config: Dict[str, Any], peft_config: Dict[str, Any]):
51
+ """
52
+ Loads the pre-trained vision-language model, applies PEFT/LoRA, and loads its processor.
53
+
54
+ Args:
55
+ model_config (Dict[str, Any]): Configuration dictionary for the model.
56
+ peft_config (Dict[str, Any]): Configuration dictionary for PEFT/LoRA.
57
+
58
+ Returns:
59
+ Tuple[PeftModel, PreTrainedProcessor]: The loaded PEFT-enabled model and processor.
60
+ """
61
+ model_id = model_config['pretrained_model_path']
62
+
63
+ model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
64
+ model_id,
65
+ torch_dtype=getattr(torch, model_config['torch_dtype']),
66
+ attn_implementation='flash_attention_2' if model_config['use_flash_attention_2'] else 'sdpa',
67
+ low_cpu_mem_usage=True,
68
+ )
69
+
70
+ # Freeze the vision tower to save memory and computation
71
+ model.model.visual.requires_grad_(False)
72
+
73
+ # NEW: Create and apply LoRA configuration
74
+ print("Applying LoRA configuration...")
75
+ lora_config = peft_config
76
+
77
+ model = get_peft_model(model, lora_config)
78
+
79
+ # NEW: Print trainable parameters to verify LoRA is active
80
+ # This should show a very small percentage of trainable parameters.
81
+ model.print_trainable_parameters()
82
+
83
+ processor = AutoProcessor.from_pretrained(model_id)
84
+ processor.tokenizer.padding_side = "left"
85
+ # image_token_id = processor.tokenizer.additional_special_tokens_ids[
86
+ # processor.tokenizer.additional_special_tokens.index("<image>")]
87
+
88
+ return model, processor
89
+
90
+
91
+ def prepare_datasets(task: str, dataset_config: Dict[str, Any]) -> (Dataset, Dataset):
92
+ """
93
+ Prepares the training and evaluation datasets based on the specified task.
94
+
95
+ Args:
96
+ task (str): The name of the task (e.g., 'chartqa').
97
+ dataset_config (Dict[str, Any]): Configuration for datasets.
98
+
99
+ Returns:
100
+ Tuple[Dataset, Dataset]: The training and evaluation datasets.
101
+ """
102
+ data_func = define_task_data_func(task)
103
+
104
+ # Create training dataset
105
+ train_data_list = data_func(json_path=dataset_config['train_dataset'])
106
+ train_dataset = Dataset.from_list(train_data_list)
107
+
108
+ # Create evaluation dataset
109
+ if 'chart' in task:
110
+ eval_dataset = load_dataset(dataset_config['eval_dataset'])['test']
111
+ # Note: You can uncomment the line below for quick testing/debugging.
112
+ # eval_dataset = eval_dataset.select(range(1000, 1100))
113
+ else:
114
+ # Extend this section for other tasks if needed in the future.
115
+ raise NotImplementedError(f"Task '{task}' is not supported for evaluation in this script.")
116
+
117
+ return train_dataset, eval_dataset
118
+
119
+
120
+ def main():
121
+ """
122
+ Main function to orchestrate the model training pipeline.
123
+ """
124
+
125
+ # 1. Load Configurations
126
+ model_config = CONFIG['model']
127
+ training_config = CONFIG['training']
128
+ rl_config = CONFIG['rl']
129
+ client_config = CONFIG['client']
130
+ dataset_config = CONFIG['dataset']
131
+ peft_config = LoraConfig(
132
+ r=64,
133
+ lora_alpha=128,
134
+ lora_dropout=0.05,
135
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
136
+ use_rslora=True,
137
+ bias="none",
138
+ task_type="CAUSAL_LM",
139
+ )
140
+
141
+ task = training_config['task']
142
+
143
+ # 2. Setup Environment
144
+ accelerator = setup_accelerator_and_wandb(bf16=training_config['dyme_args']['bf16'])
145
+ device_id = accelerator.process_index
146
+
147
+ # 3. Initialize Model and Processor
148
+ # NEW: Pass peft_config to the loading function
149
+ model, processor = load_model_and_processor(model_config, peft_config)
150
+
151
+ # 4. Prepare Datasets
152
+ train_dataset, eval_dataset = prepare_datasets(task, dataset_config)
153
+
154
+ # 5. Initialize Reward Calculator
155
+
156
+ checker = RewardCalculatorLocal(rl_config, client_config.copy(), gpu_id=device_id)
157
+ refiner = ContextRefinerLocal(rl_config, client_config.copy(), gpu_id=device_id)
158
+ # 6. Define Training Arguments
159
+ training_args = GRPOConfig(**training_config['dyme_args'])
160
+
161
+ collate_fn_with_processor = partial(collate_fn, processor=processor)
162
+ # 7. Initialize the Trainer
163
+ dyme_trainer = DyMETrainer(
164
+ model=model, # NEW: This is now a PeftModel
165
+ checker=checker,
166
+ refiner=refiner,
167
+ args=training_args,
168
+ train_dataset=train_dataset,
169
+ eval_dataset=eval_dataset,
170
+ processing_class=processor,
171
+ processing_func=collate_fn_with_processor,
172
+ task_name=task,
173
+ end_flag=rl_config['end_flag'],
174
+ )
175
+
176
+ # 8. Start Training
177
+ dyme_trainer.train()
178
+
179
+ output_dir = training_args.output_dir
180
+ output_dir = os.path.join(output_dir, "final_checkpoint")
181
+
182
+ # NEW: save_model will now save only the LoRA adapter weights
183
+ dyme_trainer.save_model(output_dir)
184
+
185
+ if accelerator.is_main_process:
186
+ processor.save_pretrained(output_dir)
187
+ # NEW: The saved model is just the adapter, not the full model.
188
+ print(f"LoRA adapter and processor saved to {output_dir}")
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()
opsd_utils/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from opsd_utils.constants import MODE_GRPO, MODE_OPSD, MODE_SFT
2
+ from opsd_utils.mode_router import route_prompt_modes, expand_modes_to_completions
3
+ from opsd_utils.opsd_loss import compute_vlm_opsd_loss
4
+ from opsd_utils.privileged import build_privileged_context
5
+ from opsd_utils.recoverability import estimate_recoverable_flags
6
+ from opsd_utils.prompt_builder import build_teacher_prompt_batch
7
+
8
+ __all__ = [
9
+ "MODE_GRPO",
10
+ "MODE_OPSD",
11
+ "MODE_SFT",
12
+ "route_prompt_modes",
13
+ "expand_modes_to_completions",
14
+ "compute_vlm_opsd_loss",
15
+ "build_privileged_context",
16
+ "estimate_recoverable_flags",
17
+ "build_teacher_prompt_batch",
18
+ ]
opsd_utils/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (693 Bytes). View file
 
opsd_utils/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (731 Bytes). View file
 
opsd_utils/__pycache__/constants.cpython-312.pyc ADDED
Binary file (577 Bytes). View file
 
opsd_utils/__pycache__/debug_log.cpython-310.pyc ADDED
Binary file (14.4 kB). View file
 
opsd_utils/__pycache__/health_monitor.cpython-310.pyc ADDED
Binary file (12.9 kB). View file
 
opsd_utils/__pycache__/leakage.cpython-310.pyc ADDED
Binary file (1.5 kB). View file
 
opsd_utils/__pycache__/mode_router.cpython-310.pyc ADDED
Binary file (4.25 kB). View file
 
opsd_utils/__pycache__/mode_router.cpython-312.pyc ADDED
Binary file (2.89 kB). View file
 
opsd_utils/__pycache__/opsd_loss.cpython-310.pyc ADDED
Binary file (10.8 kB). View file
 
opsd_utils/__pycache__/prompt_builder.cpython-310.pyc ADDED
Binary file (7.06 kB). View file
 
opsd_utils/__pycache__/recoverability.cpython-310.pyc ADDED
Binary file (5.19 kB). View file
 
opsd_utils/__pycache__/recoverability.cpython-312.pyc ADDED
Binary file (5.87 kB). View file
 
opsd_utils/__pycache__/vocab_align.cpython-310.pyc ADDED
Binary file (5.47 kB). View file
 
opsd_utils/constants.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MODE_GRPO = 0
2
+ MODE_OPSD = 1
3
+ MODE_SFT = 2
4
+
5
+ DEFAULT_OPSD_CONFIG = {
6
+ "enabled": False,
7
+ "mode": "dyme",
8
+ "privileged_profile": "hybrid",
9
+ "privileged_providers": ["text"],
10
+ "privileged_image": {
11
+ "mode": "single",
12
+ "crop_strategy": "bbox_then_center",
13
+ "bbox_coord": "normalized",
14
+ "margin_ratio": 0.25,
15
+ },
16
+ "privileged_debug": {
17
+ "save_images": True,
18
+ "image_subdir": "logs/images",
19
+ "max_samples_per_detail": 2,
20
+ },
21
+ "gate": {
22
+ "correct_threshold": 0.5,
23
+ "teacher_recoverable": "privileged_available",
24
+ "recoverable_tau": 0.5,
25
+ "recoverable_without_privilege": False,
26
+ "use_edge_mask": False,
27
+ "per_completion_opsd": True,
28
+ "require_format_for_opsd": True,
29
+ "skip_degenerate_for_opsd": True,
30
+ },
31
+ "loss": {
32
+ "beta": 0.5,
33
+ "opsd_weight": 2.0,
34
+ "grpo_weight": 1.0,
35
+ "sft_weight": 1.0,
36
+ "acc_gate": True,
37
+ },
38
+ "text_include_gold": True,
39
+ "reward_weights": [1.0, 1.0, 1.0],
40
+ }
opsd_utils/debug_log.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verbose debug logging for the OPSD / TriMode training pipeline."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import time
7
+ import traceback
8
+ from contextlib import contextmanager
9
+ from typing import Any, Optional
10
+
11
+ _DEBUG_ENABLED = False
12
+ _DETAIL_EVERY = 10
13
+ _PROBE_ON_GENERATE = False
14
+ _PROBE_FIRST_TOKEN_LOGITS = True
15
+ _PROBE_PROMPT_TAIL_TOKENS = 16
16
+ _PROBE_LOG_MODEL_CONTEXT = True
17
+ _HEALTH_MONITOR_ENABLED = True
18
+ _HEALTH_LOG_ON_GENERATE = True
19
+ _HEALTH_LOG_EVERY_STEP = True
20
+ _HEALTH_LOG_DETAIL_BUNDLE = True
21
+ _HEALTH_LOG_ALERTS_IMMEDIATELY = True
22
+ _RANK = 0
23
+ _WORLD_SIZE = 1
24
+ _STEP_LABEL = "init"
25
+ _DETAIL_STEP: Optional[int] = None
26
+ _CALL_COUNTER = 0
27
+
28
+ MODE_NAMES = {0: "GRPO", 1: "OPSD", 2: "SFT"}
29
+
30
+
31
+ def _env_debug_enabled() -> bool:
32
+ return os.environ.get("DYME_OPSD_DEBUG", "").strip().lower() in ("1", "true", "yes", "on")
33
+
34
+
35
+ def _env_detail_every() -> int:
36
+ raw = os.environ.get("DYME_OPSD_DETAIL_EVERY", "").strip()
37
+ if not raw:
38
+ return 10
39
+ try:
40
+ return max(0, int(raw))
41
+ except ValueError:
42
+ return 10
43
+
44
+
45
+ def _env_probe_on_generate() -> Optional[bool]:
46
+ raw = os.environ.get("DYME_OPSD_PROBE_ON_GENERATE", "").strip().lower()
47
+ if not raw:
48
+ return None
49
+ return raw in ("1", "true", "yes", "on")
50
+
51
+
52
+ def _env_probe_first_token_logits() -> Optional[bool]:
53
+ raw = os.environ.get("DYME_OPSD_PROBE_FIRST_TOKEN_LOGITS", "").strip().lower()
54
+ if not raw:
55
+ return None
56
+ return raw in ("1", "true", "yes", "on")
57
+
58
+
59
+ def _env_probe_prompt_tail_tokens() -> Optional[int]:
60
+ raw = os.environ.get("DYME_OPSD_PROBE_PROMPT_TAIL_TOKENS", "").strip()
61
+ if not raw:
62
+ return None
63
+ try:
64
+ return max(1, int(raw))
65
+ except ValueError:
66
+ return 16
67
+
68
+
69
+ def _env_probe_log_model_context() -> Optional[bool]:
70
+ raw = os.environ.get("DYME_OPSD_PROBE_LOG_MODEL_CONTEXT", "").strip().lower()
71
+ if not raw:
72
+ return None
73
+ return raw in ("1", "true", "yes", "on")
74
+
75
+
76
+ def _env_health_monitor_enabled() -> Optional[bool]:
77
+ raw = os.environ.get("DYME_OPSD_HEALTH_MONITOR", "").strip().lower()
78
+ if not raw:
79
+ return None
80
+ return raw in ("1", "true", "yes", "on")
81
+
82
+
83
+ def configure(
84
+ *,
85
+ enabled: Optional[bool] = None,
86
+ detail_every: Optional[int] = None,
87
+ probe_on_generate: Optional[bool] = None,
88
+ probe_first_token_logits: Optional[bool] = None,
89
+ probe_prompt_tail_tokens: Optional[int] = None,
90
+ probe_log_model_context: Optional[bool] = None,
91
+ health_monitor_enabled: Optional[bool] = None,
92
+ health_log_on_generate: Optional[bool] = None,
93
+ health_log_every_step: Optional[bool] = None,
94
+ health_log_detail_bundle: Optional[bool] = None,
95
+ health_log_alerts_immediately: Optional[bool] = None,
96
+ rank: Optional[int] = None,
97
+ world_size: Optional[int] = None,
98
+ ) -> bool:
99
+ """Configure global OPSD debug logging. Returns whether debug is enabled."""
100
+ global _DEBUG_ENABLED, _DETAIL_EVERY, _PROBE_ON_GENERATE
101
+ global _PROBE_FIRST_TOKEN_LOGITS, _PROBE_PROMPT_TAIL_TOKENS, _PROBE_LOG_MODEL_CONTEXT
102
+ global _HEALTH_MONITOR_ENABLED, _HEALTH_LOG_ON_GENERATE, _HEALTH_LOG_EVERY_STEP
103
+ global _HEALTH_LOG_DETAIL_BUNDLE, _HEALTH_LOG_ALERTS_IMMEDIATELY
104
+ global _RANK, _WORLD_SIZE
105
+ if enabled is None:
106
+ enabled = _env_debug_enabled()
107
+ _DEBUG_ENABLED = bool(enabled)
108
+ if detail_every is not None:
109
+ _DETAIL_EVERY = max(0, int(detail_every))
110
+ elif _env_detail_every() != 10 or os.environ.get("DYME_OPSD_DETAIL_EVERY"):
111
+ _DETAIL_EVERY = _env_detail_every()
112
+ env_probe = _env_probe_on_generate()
113
+ if probe_on_generate is not None:
114
+ _PROBE_ON_GENERATE = bool(probe_on_generate)
115
+ elif env_probe is not None:
116
+ _PROBE_ON_GENERATE = env_probe
117
+ env_first_logits = _env_probe_first_token_logits()
118
+ if probe_first_token_logits is not None:
119
+ _PROBE_FIRST_TOKEN_LOGITS = bool(probe_first_token_logits)
120
+ elif env_first_logits is not None:
121
+ _PROBE_FIRST_TOKEN_LOGITS = env_first_logits
122
+ env_tail = _env_probe_prompt_tail_tokens()
123
+ if probe_prompt_tail_tokens is not None:
124
+ _PROBE_PROMPT_TAIL_TOKENS = max(1, int(probe_prompt_tail_tokens))
125
+ elif env_tail is not None:
126
+ _PROBE_PROMPT_TAIL_TOKENS = env_tail
127
+ env_model_ctx = _env_probe_log_model_context()
128
+ if probe_log_model_context is not None:
129
+ _PROBE_LOG_MODEL_CONTEXT = bool(probe_log_model_context)
130
+ elif env_model_ctx is not None:
131
+ _PROBE_LOG_MODEL_CONTEXT = env_model_ctx
132
+ env_health = _env_health_monitor_enabled()
133
+ if health_monitor_enabled is not None:
134
+ _HEALTH_MONITOR_ENABLED = bool(health_monitor_enabled)
135
+ elif env_health is not None:
136
+ _HEALTH_MONITOR_ENABLED = env_health
137
+ if health_log_on_generate is not None:
138
+ _HEALTH_LOG_ON_GENERATE = bool(health_log_on_generate)
139
+ if health_log_every_step is not None:
140
+ _HEALTH_LOG_EVERY_STEP = bool(health_log_every_step)
141
+ if health_log_detail_bundle is not None:
142
+ _HEALTH_LOG_DETAIL_BUNDLE = bool(health_log_detail_bundle)
143
+ if health_log_alerts_immediately is not None:
144
+ _HEALTH_LOG_ALERTS_IMMEDIATELY = bool(health_log_alerts_immediately)
145
+ if rank is not None:
146
+ _RANK = rank
147
+ if world_size is not None:
148
+ _WORLD_SIZE = world_size
149
+ return _DEBUG_ENABLED
150
+
151
+
152
+ def detail_every() -> int:
153
+ return _DETAIL_EVERY
154
+
155
+
156
+ def probe_on_generate() -> bool:
157
+ return _PROBE_ON_GENERATE
158
+
159
+
160
+ def probe_first_token_logits() -> bool:
161
+ return _PROBE_FIRST_TOKEN_LOGITS
162
+
163
+
164
+ def probe_prompt_tail_tokens() -> int:
165
+ return _PROBE_PROMPT_TAIL_TOKENS
166
+
167
+
168
+ def probe_log_model_context() -> bool:
169
+ return _PROBE_LOG_MODEL_CONTEXT
170
+
171
+
172
+ def should_log_probe() -> bool:
173
+ """True when lightweight per-generate probe should run (rank 0 only)."""
174
+ return _PROBE_ON_GENERATE and _RANK == 0
175
+
176
+
177
+ def should_log_detail(global_step: Optional[int]) -> bool:
178
+ """True when a full diagnostic bundle should be emitted (rank 0 only)."""
179
+ if _DETAIL_EVERY <= 0 or _RANK != 0:
180
+ return False
181
+ if global_step is None:
182
+ return False
183
+ return int(global_step) % _DETAIL_EVERY == 0
184
+
185
+
186
+ def is_enabled() -> bool:
187
+ return _DEBUG_ENABLED
188
+
189
+
190
+ def set_step_label(label: str) -> None:
191
+ global _STEP_LABEL
192
+ _STEP_LABEL = label
193
+
194
+
195
+ def set_detail_step(global_step: Optional[int]) -> None:
196
+ global _DETAIL_STEP
197
+ _DETAIL_STEP = global_step
198
+
199
+
200
+ def get_detail_step() -> Optional[int]:
201
+ return _DETAIL_STEP
202
+
203
+
204
+ def _next_call_id(stage: str) -> str:
205
+ global _CALL_COUNTER
206
+ _CALL_COUNTER += 1
207
+ return f"{_CALL_COUNTER}:{stage}"
208
+
209
+
210
+ def _fmt(value: Any, max_len: int = 240) -> str:
211
+ if value is None:
212
+ return "None"
213
+ try:
214
+ import torch
215
+
216
+ if isinstance(value, torch.Tensor):
217
+ return (
218
+ f"Tensor(shape={tuple(value.shape)}, dtype={value.dtype}, "
219
+ f"device={value.device}, numel={value.numel()})"
220
+ )
221
+ except ImportError:
222
+ pass
223
+ if isinstance(value, (list, tuple)):
224
+ if len(value) > 12:
225
+ head = ", ".join(_fmt(v, max_len=40) for v in value[:6])
226
+ return f"[{head}, ... +{len(value) - 6} more, total={len(value)}]"
227
+ return "[" + ", ".join(_fmt(v, max_len=40) for v in value) + "]"
228
+ if isinstance(value, dict):
229
+ text = json.dumps(value, ensure_ascii=False, default=str)
230
+ else:
231
+ text = repr(value)
232
+ if len(text) > max_len:
233
+ return text[: max_len - 3] + "..."
234
+ return text
235
+
236
+
237
+ def _prefix(stage: str, call_id: Optional[str] = None) -> str:
238
+ cid = call_id or _next_call_id(stage)
239
+ ts = time.strftime("%Y-%m-%d %H:%M:%S")
240
+ return f"[OPSD-DEBUG][{ts}][rank={_RANK}/{_WORLD_SIZE}][step={_STEP_LABEL}][{cid}]"
241
+
242
+
243
+ def log(stage: str, msg: str, **fields: Any) -> None:
244
+ if not _DEBUG_ENABLED:
245
+ return
246
+ call_id = _next_call_id(stage)
247
+ extra = ""
248
+ if fields:
249
+ extra = " | " + " | ".join(f"{k}={_fmt(v)}" for k, v in fields.items())
250
+ print(f"{_prefix(stage, call_id)} {msg}{extra}", flush=True)
251
+
252
+
253
+ def _detail_prefix(global_step: int, section: str) -> str:
254
+ ts = time.strftime("%Y-%m-%d %H:%M:%S")
255
+ return (
256
+ f"[OPSD-DETAIL][{ts}][rank={_RANK}/{_WORLD_SIZE}]"
257
+ f"[step={global_step}][every={_DETAIL_EVERY}][{section}]"
258
+ )
259
+
260
+
261
+ def log_detail_banner(global_step: int, title: str) -> None:
262
+ if not should_log_detail(global_step):
263
+ return
264
+ bar = "=" * 20
265
+ print(f"{_detail_prefix(global_step, 'BANNER')} {bar} {title} {bar}", flush=True)
266
+
267
+
268
+ def log_detail(section: str, msg: str, global_step: Optional[int] = None, **fields: Any) -> None:
269
+ """Full-detail diagnostic line (periodic, rank 0). Independent of verbose OPSD-DEBUG."""
270
+ step = global_step if global_step is not None else _DETAIL_STEP
271
+ if step is None or isinstance(step, str):
272
+ return
273
+ if not should_log_detail(step):
274
+ return
275
+ extra = ""
276
+ if fields:
277
+ extra = " | " + " | ".join(f"{k}={_fmt(v, max_len=800)}" for k, v in fields.items())
278
+ print(f"{_detail_prefix(step, section)} {msg}{extra}", flush=True)
279
+
280
+
281
+ def _probe_prefix(section: str) -> str:
282
+ ts = time.strftime("%Y-%m-%d %H:%M:%S")
283
+ step = _DETAIL_STEP if _DETAIL_STEP is not None else "?"
284
+ return (
285
+ f"[OPSD-PROBE][{ts}][rank={_RANK}/{_WORLD_SIZE}]"
286
+ f"[global_step={step}][{_STEP_LABEL}][{section}]"
287
+ )
288
+
289
+
290
+ def log_probe(section: str, msg: str, **fields: Any) -> None:
291
+ """Lightweight per-generate diagnostic (rank 0). Independent of OPSD-DEBUG verbosity."""
292
+ if not should_log_probe():
293
+ return
294
+ extra = ""
295
+ if fields:
296
+ extra = " | " + " | ".join(f"{k}={_fmt(v, max_len=1200)}" for k, v in fields.items())
297
+ print(f"{_probe_prefix(section)} {msg}{extra}", flush=True)
298
+
299
+
300
+ def _gendbg_prefix(section: str) -> str:
301
+ ts = time.strftime("%Y-%m-%d %H:%M:%S")
302
+ step = _DETAIL_STEP if _DETAIL_STEP is not None else "?"
303
+ return (
304
+ f"[OPSD-GENDBG][{ts}][rank={_RANK}/{_WORLD_SIZE}]"
305
+ f"[global_step={step}][{_STEP_LABEL}][{section}]"
306
+ )
307
+
308
+
309
+ def should_log_gendbg() -> bool:
310
+ """True when deep generate diagnostics should run (rank 0 only)."""
311
+ return _PROBE_ON_GENERATE and _RANK == 0
312
+
313
+
314
+ def health_monitor_enabled() -> bool:
315
+ return _HEALTH_MONITOR_ENABLED and _RANK == 0
316
+
317
+
318
+ def should_log_health_on_generate() -> bool:
319
+ return health_monitor_enabled() and _HEALTH_LOG_ON_GENERATE and should_log_probe()
320
+
321
+
322
+ def should_log_health_every_step() -> bool:
323
+ return health_monitor_enabled() and _HEALTH_LOG_EVERY_STEP
324
+
325
+
326
+ def should_log_health_detail_bundle() -> bool:
327
+ return health_monitor_enabled() and _HEALTH_LOG_DETAIL_BUNDLE
328
+
329
+
330
+ def should_log_health_alerts_immediately() -> bool:
331
+ return health_monitor_enabled() and _HEALTH_LOG_ALERTS_IMMEDIATELY
332
+
333
+
334
+ def _health_prefix(section: str, global_step: Optional[int] = None) -> str:
335
+ ts = time.strftime("%Y-%m-%d %H:%M:%S")
336
+ step = global_step if global_step is not None else (_DETAIL_STEP if _DETAIL_STEP is not None else "?")
337
+ return f"[OPSD-HEALTH][{ts}][rank={_RANK}/{_WORLD_SIZE}][global_step={step}][{section}]"
338
+
339
+
340
+ def log_health(section: str, msg: str, global_step: Optional[int] = None, **fields: Any) -> None:
341
+ """L1/L2/L4 health lines (rank 0)."""
342
+ if not health_monitor_enabled():
343
+ return
344
+ extra = ""
345
+ if fields:
346
+ extra = " | " + " | ".join(f"{k}={_fmt(v, max_len=1200)}" for k, v in fields.items())
347
+ print(f"{_health_prefix(section, global_step)} {msg}{extra}", flush=True)
348
+
349
+
350
+ def log_health_detail_banner(global_step: int, title: str) -> None:
351
+ if not should_log_health_detail_bundle() or not should_log_detail(global_step):
352
+ return
353
+ bar = "=" * 20
354
+ print(f"{_detail_prefix(global_step, 'health')} {bar} {title} {bar}", flush=True)
355
+
356
+
357
+ def log_health_detail(section: str, msg: str, global_step: int, **fields: Any) -> None:
358
+ """L3 periodic health bundle (rank 0, same cadence as OPSD-DETAIL)."""
359
+ if not should_log_health_detail_bundle() or not should_log_detail(global_step):
360
+ return
361
+ extra = ""
362
+ if fields:
363
+ extra = " | " + " | ".join(f"{k}={_fmt(v, max_len=1200)}" for k, v in fields.items())
364
+ print(f"{_detail_prefix(global_step, section)} {msg}{extra}", flush=True)
365
+
366
+
367
+ def log_gendbg(section: str, msg: str, **fields: Any) -> None:
368
+ """Deep per-generate diagnostic (rank 0). Uses [OPSD-GENDBG] prefix."""
369
+ if not should_log_gendbg():
370
+ return
371
+ extra = ""
372
+ if fields:
373
+ extra = " | " + " | ".join(f"{k}={_fmt(v, max_len=1200)}" for k, v in fields.items())
374
+ print(f"{_gendbg_prefix(section)} {msg}{extra}", flush=True)
375
+
376
+
377
+ def log_config(stage: str, title: str, config: dict[str, Any]) -> None:
378
+ if not _DEBUG_ENABLED:
379
+ return
380
+ log(stage, title, config=_fmt(config, max_len=2000))
381
+
382
+
383
+ def log_sync_point(stage: str, msg: str, **fields: Any) -> None:
384
+ """Mark a point where all ranks must reach before a collective call."""
385
+ log(stage, f"[SYNC_POINT] {msg}", **fields)
386
+
387
+
388
+ def log_mode_summary(stage: str, prompt_modes: list[int], completion_modes: Optional[list[int]] = None) -> None:
389
+ if not _DEBUG_ENABLED:
390
+ return
391
+ prompt_names = [MODE_NAMES.get(m, str(m)) for m in prompt_modes]
392
+ fields: dict[str, Any] = {"prompt_modes": prompt_names}
393
+ if completion_modes is not None:
394
+ counts = {name: completion_modes.count(code) for code, name in MODE_NAMES.items()}
395
+ fields["completion_mode_counts"] = counts
396
+ log(stage, "mode routing summary", **fields)
397
+
398
+
399
+ def log_tensor(stage: str, name: str, tensor: Any) -> None:
400
+ if not _DEBUG_ENABLED:
401
+ return
402
+ log(stage, f"tensor `{name}`", value=_fmt(tensor))
403
+
404
+
405
+ def log_exception(stage: str, msg: str, exc: BaseException) -> None:
406
+ if not _DEBUG_ENABLED:
407
+ return
408
+ tb = traceback.format_exc()
409
+ log(stage, msg, error=repr(exc), traceback=_fmt(tb, max_len=4000))
410
+
411
+
412
+ @contextmanager
413
+ def timed(stage: str, msg: str = "", **fields: Any):
414
+ if not _DEBUG_ENABLED:
415
+ yield
416
+ return
417
+ t0 = time.perf_counter()
418
+ log(stage, f"START {msg}", **fields)
419
+ try:
420
+ yield
421
+ except Exception as exc:
422
+ log_exception(stage, f"FAILED {msg}", exc)
423
+ raise
424
+ finally:
425
+ elapsed = time.perf_counter() - t0
426
+ log(stage, f"END {msg}", elapsed_s=f"{elapsed:.4f}")
opsd_utils/deepspeed_utils.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers for DeepSpeed + Accelerate launch detection."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+
11
+ def _project_root() -> Path:
12
+ return Path(__file__).resolve().parents[1]
13
+
14
+
15
+ def resolve_accelerate_config_path(config_name: Optional[str] = None) -> Optional[Path]:
16
+ candidates: list[str] = []
17
+ if config_name:
18
+ candidates.append(str(config_name).strip())
19
+ for env_key in ("ACCELERATE_CONFIG", "ACCELERATE_CONFIG_FILE"):
20
+ val = os.environ.get(env_key, "").strip()
21
+ if val:
22
+ candidates.append(val)
23
+ for raw in candidates:
24
+ if not raw:
25
+ continue
26
+ path = Path(raw)
27
+ if not path.is_file():
28
+ path = _project_root() / raw
29
+ if path.is_file():
30
+ return path
31
+ return None
32
+
33
+
34
+ def uses_deepspeed_json_file(config_name: Optional[str] = None) -> bool:
35
+ """True when Accelerate loads DeepSpeed settings from an external JSON file."""
36
+ path = resolve_accelerate_config_path(config_name)
37
+ if path is None:
38
+ return False
39
+ return "deepspeed_config_file" in path.read_text(encoding="utf-8")
40
+
41
+
42
+ def _yaml_get_str(path: Path, key: str) -> Optional[str]:
43
+ pattern = re.compile(rf"^{re.escape(key)}\s*:\s*(.+?)\s*$", re.IGNORECASE)
44
+ for line in path.read_text(encoding="utf-8").splitlines():
45
+ m = pattern.match(line.strip())
46
+ if m:
47
+ return m.group(1).strip().strip("'\"")
48
+ return None
49
+
50
+
51
+ def is_deepspeed_accelerate_config(config_name: Optional[str] = None) -> bool:
52
+ path = resolve_accelerate_config_path(config_name)
53
+ if path is None:
54
+ return False
55
+ dist = (_yaml_get_str(path, "distributed_type") or "").upper()
56
+ return dist == "DEEPSPEED"
57
+
58
+
59
+ def deepspeed_zero_stage(config_name: Optional[str] = None) -> Optional[int]:
60
+ path = resolve_accelerate_config_path(config_name)
61
+ if path is None:
62
+ return None
63
+ text = path.read_text(encoding="utf-8")
64
+ m = re.search(r"zero_stage\s*:\s*(\d+)", text, re.IGNORECASE)
65
+ if m:
66
+ return int(m.group(1))
67
+ m = re.search(r"deepspeed_config_file\s*:\s*(\S+)", text, re.IGNORECASE)
68
+ if not m:
69
+ return None
70
+ json_path = Path(m.group(1).strip().strip("'\""))
71
+ if not json_path.is_file():
72
+ json_path = _project_root() / json_path
73
+ if not json_path.is_file():
74
+ return None
75
+ ds_json = json.loads(json_path.read_text(encoding="utf-8"))
76
+ stage = (ds_json.get("zero_optimization") or {}).get("stage")
77
+ return int(stage) if stage is not None else None
78
+
79
+
80
+ def should_colocate_teacher_with_student(device_map: Optional[str] = None) -> bool:
81
+ """True when frozen teacher should sit on the same GPU as the trainable student."""
82
+ raw = (device_map or os.environ.get("DYME_TEACHER_DEVICE_MAP", "")).strip().lower()
83
+ if raw in ("same", "colocate", "local"):
84
+ return True
85
+ if os.environ.get("DYME_DEEPSPEED_COLOCATE", "").strip().lower() in ("1", "true", "yes", "on"):
86
+ return True
87
+ if is_deepspeed_accelerate_config() and raw in ("", "auto"):
88
+ return True
89
+ return False
90
+
91
+
92
+ def gradient_checkpointing_enable_kwargs(config_name: Optional[str] = None) -> Optional[dict]:
93
+ """
94
+ Kwargs for ``model.gradient_checkpointing_enable``.
95
+
96
+ DeepSpeed ZeRO-1/2 + reentrant checkpointing runs backward twice per segment and
97
+ hits: "parameter ... has already been reduced".
98
+ """
99
+ if not is_deepspeed_accelerate_config(config_name):
100
+ return None
101
+ override = os.environ.get("DYME_GRADIENT_CHECKPOINTING_USE_REENTRANT", "").strip().lower()
102
+ if override in ("1", "true", "yes", "on"):
103
+ return {"use_reentrant": True}
104
+ if override in ("0", "false", "no", "off"):
105
+ return {"use_reentrant": False}
106
+ return {"use_reentrant": False}
107
+
108
+
109
+ def deepspeed_requires_single_student_forward(config_name: Optional[str] = None) -> bool:
110
+ """
111
+ DeepSpeed ZeRO-1/2 cannot reduce gradients when the student runs multiple
112
+ forwards in one backward (GRPO micro-chunks + OPSD loop).
113
+ """
114
+ stage = deepspeed_zero_stage(config_name)
115
+ return stage is not None and stage <= 2
116
+
117
+
118
+ def should_disable_gradient_checkpointing(config_name: Optional[str] = None) -> bool:
119
+ """Gradient checkpointing also triggers double reduction under ZeRO-1/2."""
120
+ return deepspeed_requires_single_student_forward(config_name)
121
+
122
+
123
+ def student_forward_chunk_size(
124
+ batch_size: int,
125
+ has_vision: bool,
126
+ config_name: Optional[str] = None,
127
+ ) -> int:
128
+ """
129
+ Micro-batch size for student forwards in ``_get_per_token_logps``.
130
+
131
+ Under ZeRO-1/2 we must use one forward per backward (full local batch by default).
132
+ Override with ``DYME_STUDENT_FORWARD_CHUNK`` only if you accept ZeRO-3+ or OOM risk.
133
+ """
134
+ if not has_vision:
135
+ return batch_size
136
+ if not deepspeed_requires_single_student_forward(config_name):
137
+ return 1
138
+ override = os.environ.get("DYME_STUDENT_FORWARD_CHUNK", "").strip()
139
+ if override.isdigit():
140
+ return max(1, min(batch_size, int(override)))
141
+ return batch_size
opsd_utils/diagnostics.py ADDED
@@ -0,0 +1,1351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Periodic full-detail diagnostics for weak reward / gradient signals."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ from typing import Any, Optional
7
+
8
+ import torch
9
+ import torch.nn as nn
10
+ import torch.nn.functional as F
11
+
12
+ from opsd_utils import debug_log as opsd_debug
13
+ from opsd_utils.vocab_align import align_cross_model_logits
14
+
15
+ PAREN_TOKEN_ID = 340
16
+
17
+ # Reuse logits from the OPSD loss path instead of running extra forwards.
18
+ _OPSD_JSD_DETAIL_CAPTURE: dict[str, Any] = {
19
+ "active": False,
20
+ "global_step": None,
21
+ "target_indices": set(),
22
+ "entries": [],
23
+ "skipped_memory": False,
24
+ "skip_reason": "",
25
+ "max_samples": 2,
26
+ }
27
+
28
+
29
+ def _detail_min_free_gib() -> float:
30
+ raw = os.environ.get("DYME_OPSD_DETAIL_MIN_FREE_GB", "").strip()
31
+ if not raw:
32
+ return 4.0
33
+ try:
34
+ return max(0.0, float(raw))
35
+ except ValueError:
36
+ return 4.0
37
+
38
+
39
+ def cuda_free_gib(device: Optional[torch.device | int] = None) -> Optional[float]:
40
+ if not torch.cuda.is_available():
41
+ return None
42
+ try:
43
+ if device is None:
44
+ free_bytes, _ = torch.cuda.mem_get_info()
45
+ else:
46
+ dev = torch.device(device) if not isinstance(device, torch.device) else device
47
+ with torch.cuda.device(dev):
48
+ free_bytes, _ = torch.cuda.mem_get_info()
49
+ return free_bytes / (1024**3)
50
+ except Exception:
51
+ return None
52
+
53
+
54
+ def check_detail_cuda_memory(
55
+ min_free_gib: Optional[float] = None,
56
+ device: Optional[torch.device | int] = None,
57
+ ) -> tuple[bool, str, Optional[float]]:
58
+ """Return (ok, reason, free_gib). Skips heavy detail work when GPU headroom is low."""
59
+ threshold = _detail_min_free_gib() if min_free_gib is None else max(0.0, float(min_free_gib))
60
+ if not torch.cuda.is_available():
61
+ return True, "", None
62
+ free_gib = cuda_free_gib(device)
63
+ if free_gib is None:
64
+ return True, "", None
65
+ if free_gib < threshold:
66
+ return (
67
+ False,
68
+ f"cuda_free_gib={free_gib:.2f} < min_free_gib={threshold:.2f}",
69
+ free_gib,
70
+ )
71
+ return True, "", free_gib
72
+
73
+
74
+ def begin_opsd_jsd_detail_capture(
75
+ global_step: int,
76
+ opsd_indices: list[int],
77
+ max_samples: int = 2,
78
+ ) -> None:
79
+ """Prepare to record JSD stats during OPSD loss (no extra model forwards)."""
80
+ _OPSD_JSD_DETAIL_CAPTURE.update(
81
+ active=False,
82
+ global_step=global_step,
83
+ target_indices=set(),
84
+ entries=[],
85
+ skipped_memory=False,
86
+ skip_reason="",
87
+ max_samples=max(1, int(max_samples)),
88
+ )
89
+ if not opsd_debug.should_log_detail(global_step) or not opsd_indices:
90
+ return
91
+
92
+ ok, reason, free_gib = check_detail_cuda_memory()
93
+ if not ok:
94
+ _OPSD_JSD_DETAIL_CAPTURE["skipped_memory"] = True
95
+ _OPSD_JSD_DETAIL_CAPTURE["skip_reason"] = reason
96
+ opsd_debug.log_detail(
97
+ "opsd_jsd",
98
+ "skip JSD detail capture (CUDA memory guard)",
99
+ global_step=global_step,
100
+ reason=reason,
101
+ cuda_free_gib=free_gib,
102
+ min_free_gib=_detail_min_free_gib(),
103
+ )
104
+ return
105
+
106
+ _OPSD_JSD_DETAIL_CAPTURE["active"] = True
107
+ _OPSD_JSD_DETAIL_CAPTURE["target_indices"] = set(opsd_indices[: _OPSD_JSD_DETAIL_CAPTURE["max_samples"]])
108
+
109
+
110
+ def maybe_capture_opsd_jsd_detail(
111
+ *,
112
+ global_idx: int,
113
+ student_logits: torch.Tensor,
114
+ teacher_logits: torch.Tensor,
115
+ completion_mask: torch.Tensor,
116
+ completion_ids: torch.Tensor,
117
+ beta: float,
118
+ tokenizer: Any = None,
119
+ student_prompt_len: Optional[int] = None,
120
+ teacher_prompt_len: Optional[int] = None,
121
+ ) -> None:
122
+ """Record token-level JSD stats from logits already computed in the loss path."""
123
+ capture = _OPSD_JSD_DETAIL_CAPTURE
124
+ if not capture["active"] or global_idx not in capture["target_indices"]:
125
+ return
126
+
127
+ try:
128
+ with torch.no_grad():
129
+ s_logits, t_logits = align_cross_model_logits(
130
+ student_logits.detach(),
131
+ teacher_logits.detach(),
132
+ )
133
+ stats = jsd_token_stats(s_logits, t_logits, completion_mask.float(), beta=beta)
134
+ stats["sample_index"] = global_idx
135
+ if student_prompt_len is not None:
136
+ stats["student_prompt_len"] = int(student_prompt_len)
137
+ if teacher_prompt_len is not None:
138
+ stats["teacher_prompt_len"] = int(teacher_prompt_len)
139
+ if tokenizer is not None:
140
+ decoded = tokenizer.decode(
141
+ completion_ids[0][completion_mask[0].bool()],
142
+ skip_special_tokens=True,
143
+ )
144
+ stats["completion_text"] = _preview_text(decoded)
145
+ capture["entries"].append(stats)
146
+ except RuntimeError as exc:
147
+ if "out of memory" in str(exc).lower():
148
+ if torch.cuda.is_available():
149
+ torch.cuda.empty_cache()
150
+ opsd_debug.log_detail(
151
+ "opsd_jsd",
152
+ f"skip JSD detail for sample[{global_idx}] (OOM during stats)",
153
+ global_step=capture.get("global_step"),
154
+ error=repr(exc),
155
+ )
156
+ return
157
+ raise
158
+
159
+
160
+ def _tensor_stats(t: torch.Tensor, name: str) -> dict[str, Any]:
161
+ if t is None or not isinstance(t, torch.Tensor) or t.numel() == 0:
162
+ return {name: "empty"}
163
+ with torch.no_grad():
164
+ flat = t.detach().float().reshape(-1)
165
+ return {
166
+ f"{name}/shape": tuple(t.shape),
167
+ f"{name}/mean": float(flat.mean().item()),
168
+ f"{name}/std": float(flat.std(unbiased=False).item()) if flat.numel() > 1 else 0.0,
169
+ f"{name}/min": float(flat.min().item()),
170
+ f"{name}/max": float(flat.max().item()),
171
+ f"{name}/abs_mean": float(flat.abs().mean().item()),
172
+ }
173
+
174
+
175
+ def _preview_text(text: str, max_len: int = 320) -> str:
176
+ text = (text or "").replace("\n", "\\n")
177
+ if len(text) > max_len:
178
+ return text[: max_len - 3] + "..."
179
+ return text or "<EMPTY>"
180
+
181
+
182
+ def _generation_config_summary(generation_config: Any) -> dict[str, Any]:
183
+ if generation_config is None:
184
+ return {}
185
+ keys = (
186
+ "max_new_tokens",
187
+ "do_sample",
188
+ "temperature",
189
+ "top_p",
190
+ "top_k",
191
+ "min_p",
192
+ "repetition_penalty",
193
+ "eos_token_id",
194
+ "pad_token_id",
195
+ "bos_token_id",
196
+ )
197
+ out: dict[str, Any] = {}
198
+ for k in keys:
199
+ if hasattr(generation_config, k):
200
+ v = getattr(generation_config, k)
201
+ out[k] = v.tolist() if isinstance(v, torch.Tensor) else v
202
+ return out
203
+
204
+
205
+ def _slice_generate_inputs(batch: dict[str, Any], index: int, batch_size: int) -> dict[str, Any]:
206
+ """Take one row from batched generate/forward tensors (VLM-safe)."""
207
+ sliced: dict[str, Any] = {}
208
+ for key, value in batch.items():
209
+ if isinstance(value, torch.Tensor) and value.dim() >= 1 and value.size(0) == batch_size:
210
+ sliced[key] = value[index : index + 1]
211
+ else:
212
+ sliced[key] = value
213
+ return sliced
214
+
215
+
216
+ def _last_valid_prompt_positions(prompt_mask: torch.Tensor) -> torch.Tensor:
217
+ """Return index of last valid (non-pad) prompt token per row; supports left padding."""
218
+ with torch.no_grad():
219
+ seq_len = prompt_mask.size(1)
220
+ positions = torch.arange(seq_len, device=prompt_mask.device).expand_as(prompt_mask)
221
+ masked = torch.where(prompt_mask.bool(), positions, torch.full_like(positions, -1))
222
+ return masked.max(dim=1).values
223
+
224
+
225
+ def _max_same_token_run(token_ids: list[int]) -> tuple[int, Optional[int]]:
226
+ """Longest run of identical consecutive token ids."""
227
+ if not token_ids:
228
+ return 0, None
229
+ best_run = 1
230
+ best_tok = token_ids[0]
231
+ run = 1
232
+ for i in range(1, len(token_ids)):
233
+ if token_ids[i] == token_ids[i - 1]:
234
+ run += 1
235
+ if run > best_run:
236
+ best_run = run
237
+ best_tok = token_ids[i]
238
+ else:
239
+ run = 1
240
+ return best_run, best_tok
241
+
242
+
243
+ def _detect_single_token_repeat(token_ids: list[int], min_run: int = 8) -> bool:
244
+ return _max_same_token_run(token_ids)[0] >= min_run
245
+
246
+
247
+ def _detect_char_repeat(text: str, min_run: int = 6) -> bool:
248
+ """Detect consecutive repeated characters (CJK or ASCII), e.g. 其其其."""
249
+ if not text or len(text) < min_run:
250
+ return False
251
+ run = 1
252
+ for i in range(1, len(text)):
253
+ if text[i] == text[i - 1] and not text[i].isspace():
254
+ run += 1
255
+ if run >= min_run:
256
+ return True
257
+ else:
258
+ run = 1
259
+ return False
260
+
261
+
262
+ def _count_char_repeat_samples(completions: Optional[list[str]], min_run: int = 6) -> int:
263
+ if not completions:
264
+ return 0
265
+ return sum(1 for c in completions if _detect_char_repeat(c or "", min_run=min_run))
266
+
267
+
268
+ def _detect_repeat_loop(token_ids: list[int], min_repeats: int = 4, ngram: int = 3) -> bool:
269
+ if len(token_ids) < ngram * min_repeats:
270
+ return False
271
+ last_start = len(token_ids) - ngram * min_repeats + 1
272
+ for start in range(max(0, last_start)):
273
+ gram = token_ids[start : start + ngram]
274
+ repeats = 1
275
+ pos = start + ngram
276
+ while pos + ngram <= len(token_ids) and token_ids[pos : pos + ngram] == gram:
277
+ repeats += 1
278
+ pos += ngram
279
+ if repeats >= min_repeats:
280
+ return True
281
+ return False
282
+
283
+
284
+ def _detect_degeneration(
285
+ token_ids: list[int],
286
+ text: str,
287
+ *,
288
+ answer_flag: str = "Answer:",
289
+ min_single_token_run: int = 8,
290
+ require_answer_flag: bool = True,
291
+ ) -> tuple[bool, list[str]]:
292
+ """Heuristics for repetitive / format-broken completions."""
293
+ reasons: list[str] = []
294
+ if _detect_single_token_repeat(token_ids, min_run=min_single_token_run):
295
+ run_len, tok = _max_same_token_run(token_ids)
296
+ reasons.append(f"SINGLE_TOKEN_REPEAT(run={run_len},tok={tok})")
297
+ if _detect_repeat_loop(token_ids):
298
+ reasons.append("NGRAM_REPEAT")
299
+ if len(token_ids) >= 16:
300
+ unique_ratio = len(set(token_ids)) / len(token_ids)
301
+ if unique_ratio < 0.12:
302
+ reasons.append(f"LOW_UNIQUE_RATIO({unique_ratio:.3f})")
303
+ if require_answer_flag:
304
+ answer_count = len(re.findall(f"(?i){re.escape(answer_flag)}", text or ""))
305
+ if answer_count != 1:
306
+ reasons.append(f"ANSWER_FLAG_COUNT({answer_count})")
307
+ if _detect_char_repeat(text or ""):
308
+ reasons.append("CHAR_REPEAT")
309
+ return bool(reasons), reasons
310
+
311
+
312
+ def _count_answer_flag(text: str, answer_flag: str = "Answer:") -> int:
313
+ return len(re.findall(f"(?i){re.escape(answer_flag)}", text or ""))
314
+
315
+
316
+ def is_degenerate_completion(
317
+ token_ids: list[int],
318
+ text: str,
319
+ *,
320
+ answer_flag: str = "Answer:",
321
+ min_single_token_run: int = 8,
322
+ require_answer_flag: bool = True,
323
+ ) -> bool:
324
+ """Return True when completion looks like a repetition / format-broken sample."""
325
+ is_deg, _ = _detect_degeneration(
326
+ token_ids,
327
+ text,
328
+ answer_flag=answer_flag,
329
+ min_single_token_run=min_single_token_run,
330
+ require_answer_flag=require_answer_flag,
331
+ )
332
+ return is_deg
333
+
334
+
335
+ def _count_paren_then_eos(
336
+ completion_ids: torch.Tensor,
337
+ completion_mask: torch.Tensor,
338
+ eos_id: Optional[int],
339
+ ) -> int:
340
+ if eos_id is None or completion_ids.size(0) == 0:
341
+ return 0
342
+ count = 0
343
+ with torch.no_grad():
344
+ lengths = completion_mask.sum(dim=1)
345
+ for i in range(completion_ids.size(0)):
346
+ eff = int(lengths[i].item())
347
+ if eff <= 0:
348
+ continue
349
+ first = int(completion_ids[i, 0].item())
350
+ if first != PAREN_TOKEN_ID:
351
+ continue
352
+ if eff <= 2:
353
+ count += 1
354
+ elif eff >= 2 and int(completion_ids[i, 1].item()) == eos_id:
355
+ count += 1
356
+ return count
357
+
358
+
359
+ def summarize_generate_probe_stats(
360
+ completion_ids: torch.Tensor,
361
+ completion_mask: torch.Tensor,
362
+ is_eos: torch.Tensor,
363
+ eos_id: Optional[int],
364
+ completions: Optional[list[str]] = None,
365
+ answer_flag: str = "Answer:",
366
+ max_completion_length: Optional[int] = None,
367
+ ) -> dict[str, Any]:
368
+ with torch.no_grad():
369
+ lengths = completion_mask.sum(dim=1).float()
370
+ has_eos = is_eos.any(dim=1)
371
+ paren_then_eos = _count_paren_then_eos(completion_ids, completion_mask, eos_id)
372
+ repeat_loop = 0
373
+ degenerate_count = 0
374
+ max_run_lengths: list[float] = []
375
+ unique_ratios: list[float] = []
376
+ answer_flag_ok = 0
377
+ clipped_count = 0
378
+ degenerate_format_count = 0
379
+ degenerate_repeat_count = 0
380
+ format_without_thinking = 0
381
+ for i in range(completion_ids.size(0)):
382
+ eff = int(lengths[i].item())
383
+ if eff <= 0:
384
+ continue
385
+ ids = completion_ids[i, :eff].tolist()
386
+ text = completions[i] if completions and i < len(completions) else ""
387
+ if _detect_repeat_loop(ids) or _detect_single_token_repeat(ids):
388
+ repeat_loop += 1
389
+ run_len, _ = _max_same_token_run(ids)
390
+ max_run_lengths.append(float(run_len))
391
+ unique_ratios.append(len(set(ids)) / max(len(ids), 1))
392
+ if _count_answer_flag(text, answer_flag) == 1:
393
+ answer_flag_ok += 1
394
+ thinking = (text or "").lower().split(answer_flag.lower())[0]
395
+ if len(thinking.strip()) < 8:
396
+ format_without_thinking += 1
397
+ else:
398
+ degenerate_format_count += 1
399
+ if max_completion_length is not None and eff >= max_completion_length - 1:
400
+ clipped_count += 1
401
+ is_deg, reasons = _detect_degeneration(ids, text, answer_flag=answer_flag)
402
+ if is_deg:
403
+ degenerate_count += 1
404
+ non_flag = [r for r in reasons if not r.startswith("ANSWER_FLAG")]
405
+ if non_flag:
406
+ degenerate_repeat_count += 1
407
+ char_repeat_count = _count_char_repeat_samples(completions)
408
+ n = max(completion_ids.size(0), 1)
409
+ return {
410
+ "effective_tokens_mean": float(lengths.mean().item()),
411
+ "char_repeat_count": char_repeat_count,
412
+ "one_token_count": int((lengths == 1).sum().item()),
413
+ "paren_then_eos_count": paren_then_eos,
414
+ "repeat_loop_count": repeat_loop,
415
+ "eos_terminated_rate": float(has_eos.float().mean().item()),
416
+ "degenerate_count": degenerate_count,
417
+ "degenerate_rate": degenerate_count / n,
418
+ "degenerate_rate_format": degenerate_format_count / n,
419
+ "degenerate_rate_repeat": degenerate_repeat_count / n,
420
+ "format_without_thinking_rate": format_without_thinking / n,
421
+ "max_token_run_mean": float(sum(max_run_lengths) / len(max_run_lengths)) if max_run_lengths else 0.0,
422
+ "unique_token_ratio_mean": float(sum(unique_ratios) / len(unique_ratios)) if unique_ratios else 0.0,
423
+ "answer_flag_exactly_once_rate": answer_flag_ok / n,
424
+ "clipped_count": clipped_count,
425
+ "clipped_rate": clipped_count / n,
426
+ }
427
+
428
+
429
+ def log_generate_context(
430
+ *,
431
+ global_step: int,
432
+ trainer_step: Optional[int],
433
+ generate_call_index: int,
434
+ model: Any,
435
+ model_wrapped: Any,
436
+ gradient_checkpointing: bool,
437
+ generation_config: Any,
438
+ is_fsdp_enabled: bool,
439
+ generate_runs_under_no_grad: bool,
440
+ ) -> None:
441
+ if not opsd_debug.should_log_gendbg() or not opsd_debug.probe_log_model_context():
442
+ return
443
+
444
+ opsd_debug.set_detail_step(global_step)
445
+ dropout_in_train = sum(
446
+ 1 for m in model.modules() if isinstance(m, nn.Dropout) and m.training
447
+ )
448
+ opsd_debug.log_gendbg(
449
+ "context",
450
+ "generate model context",
451
+ generate_call_index=generate_call_index,
452
+ trainer_step=trainer_step,
453
+ global_step=global_step,
454
+ model_training=bool(getattr(model, "training", None)),
455
+ model_wrapped_training=bool(getattr(model_wrapped, "training", None)),
456
+ gradient_checkpointing=gradient_checkpointing,
457
+ generation_use_cache=getattr(generation_config, "use_cache", None),
458
+ is_fsdp_enabled=is_fsdp_enabled,
459
+ generate_runs_under_no_grad=generate_runs_under_no_grad,
460
+ dropout_modules_in_train=dropout_in_train,
461
+ generation_config=_generation_config_summary(generation_config),
462
+ )
463
+
464
+
465
+ def log_prompt_tail_probe(
466
+ *,
467
+ global_step: int,
468
+ trainer_step: Optional[int],
469
+ generate_call_index: int,
470
+ prompt_ids: torch.Tensor,
471
+ prompt_mask: torch.Tensor,
472
+ tokenizer: Any,
473
+ sample_count: int = 4,
474
+ tail_tokens: Optional[int] = None,
475
+ ) -> None:
476
+ if not opsd_debug.should_log_gendbg():
477
+ return
478
+
479
+ opsd_debug.set_detail_step(global_step)
480
+ n_tail = tail_tokens if tail_tokens is not None else opsd_debug.probe_prompt_tail_tokens()
481
+ last_pos = _last_valid_prompt_positions(prompt_mask)
482
+ n = min(sample_count, prompt_ids.size(0))
483
+
484
+ for i in range(n):
485
+ end = int(last_pos[i].item()) + 1
486
+ start = max(0, end - n_tail)
487
+ tail_ids = prompt_ids[i, start:end].tolist()
488
+ eff_len = int(prompt_mask[i].sum().item())
489
+ tail_decode = tokenizer.decode(tail_ids, skip_special_tokens=False)
490
+ opsd_debug.log_gendbg(
491
+ "prompt_tail",
492
+ f"sample[{i}]",
493
+ generate_call_index=generate_call_index,
494
+ trainer_step=trainer_step,
495
+ prompt_effective_len=eff_len,
496
+ last_valid_idx=end - 1,
497
+ prompt_tail_token_ids=tail_ids,
498
+ prompt_tail_decode=_preview_text(tail_decode, 400),
499
+ )
500
+
501
+
502
+ def summarize_first_token_logits_stats(
503
+ p_greedy_values: list[float],
504
+ p_eos_values: list[float],
505
+ entropy_values: list[float],
506
+ p_answer_values: Optional[list[float]] = None,
507
+ ) -> dict[str, float]:
508
+ """Aggregate first-token logit probe scalars across samples."""
509
+ def _mean(vals: list[float]) -> float:
510
+ return float(sum(vals) / len(vals)) if vals else 0.0
511
+
512
+ out = {
513
+ "p_greedy_first": _mean(p_greedy_values),
514
+ "p_eos_first": _mean(p_eos_values),
515
+ "entropy_first": _mean(entropy_values),
516
+ }
517
+ if p_answer_values:
518
+ out["p_answer_first"] = _mean(p_answer_values)
519
+ return out
520
+
521
+
522
+ def answer_first_token_id(tokenizer: Any) -> Optional[int]:
523
+ for piece in ("Answer", "Answer:"):
524
+ ids = tokenizer.encode(piece, add_special_tokens=False)
525
+ if ids:
526
+ return int(ids[0])
527
+ return None
528
+
529
+
530
+ def log_first_token_logits_probe(
531
+ *,
532
+ global_step: int,
533
+ trainer_step: Optional[int],
534
+ generate_call_index: int,
535
+ unwrapped_model: Any,
536
+ prompt_inputs_generate: dict[str, Any],
537
+ prompt_mask: torch.Tensor,
538
+ tokenizer: Any,
539
+ sample_count: int = 4,
540
+ ) -> dict[str, Any]:
541
+ """Forward once before generate; return greedy ids and aggregated logit stats."""
542
+ greedy_by_sample: dict[int, int] = {}
543
+ p_greedy_vals: list[float] = []
544
+ p_eos_vals: list[float] = []
545
+ entropy_vals: list[float] = []
546
+ p_answer_vals: list[float] = []
547
+ if not opsd_debug.should_log_gendbg() or not opsd_debug.probe_first_token_logits():
548
+ return {
549
+ "greedy_by_sample": greedy_by_sample,
550
+ **summarize_first_token_logits_stats([], [], [], []),
551
+ }
552
+
553
+ opsd_debug.set_detail_step(global_step)
554
+ eos_id = getattr(tokenizer, "eos_token_id", None)
555
+ answer_tid = answer_first_token_id(tokenizer)
556
+ forward_inputs = {k: v for k, v in prompt_inputs_generate.items() if k != "labels"}
557
+ batch_size = prompt_mask.size(0)
558
+ n = min(sample_count, batch_size)
559
+ last_pos = _last_valid_prompt_positions(prompt_mask)
560
+
561
+ for i in range(n):
562
+ try:
563
+ sample_inputs = _slice_generate_inputs(forward_inputs, i, batch_size)
564
+ with torch.no_grad():
565
+ outputs = unwrapped_model(**sample_inputs, use_cache=False)
566
+ logits = outputs.logits
567
+ pos = int(last_pos[i].item())
568
+ next_logits = logits[0, pos, :].float()
569
+ probs = F.softmax(next_logits, dim=-1)
570
+ greedy_id = int(next_logits.argmax().item())
571
+ greedy_by_sample[i] = greedy_id
572
+ entropy = float(-(probs * (probs + 1e-12).log()).sum().item())
573
+ fields: dict[str, Any] = {
574
+ "generate_call_index": generate_call_index,
575
+ "trainer_step": trainer_step,
576
+ "last_prompt_idx": pos,
577
+ "greedy_token_id": greedy_id,
578
+ "p_greedy": float(probs[greedy_id].item()),
579
+ "entropy": entropy,
580
+ "probe_mode": "per_sample_forward",
581
+ }
582
+ if eos_id is not None:
583
+ fields["p_eos"] = float(probs[eos_id].item())
584
+ if answer_tid is not None and answer_tid < probs.size(0):
585
+ fields["p_answer_first"] = float(probs[answer_tid].item())
586
+ p_answer_vals.append(fields["p_answer_first"])
587
+ if PAREN_TOKEN_ID < probs.size(0):
588
+ fields["p_token_340"] = float(probs[PAREN_TOKEN_ID].item())
589
+ topk = torch.topk(probs, k=min(5, probs.size(0)))
590
+ fields["top5"] = [
591
+ (int(tid), float(p)) for tid, p in zip(topk.indices.tolist(), topk.values.tolist())
592
+ ]
593
+ p_greedy_vals.append(fields["p_greedy"])
594
+ entropy_vals.append(entropy)
595
+ if eos_id is not None:
596
+ p_eos_vals.append(fields["p_eos"])
597
+ opsd_debug.log_gendbg("first_token_logits", f"sample[{i}]", **fields)
598
+ except Exception as exc:
599
+ opsd_debug.log_gendbg(
600
+ "first_token_logits",
601
+ f"FAILED forward for sample[{i}]",
602
+ generate_call_index=generate_call_index,
603
+ error=repr(exc),
604
+ )
605
+ if torch.cuda.is_available():
606
+ torch.cuda.empty_cache()
607
+ logits_stats = summarize_first_token_logits_stats(
608
+ p_greedy_vals, p_eos_vals, entropy_vals, p_answer_vals
609
+ )
610
+ return {"greedy_by_sample": greedy_by_sample, **logits_stats}
611
+
612
+
613
+ def log_first_token_logits_match(
614
+ *,
615
+ generate_call_index: int,
616
+ completion_ids: torch.Tensor,
617
+ greedy_by_sample: dict[int, int],
618
+ sample_count: int = 4,
619
+ ) -> None:
620
+ """Compare pre-generate greedy next token vs actual first generated token."""
621
+ if not opsd_debug.should_log_gendbg() or not opsd_debug.probe_first_token_logits():
622
+ return
623
+
624
+ n = min(sample_count, completion_ids.size(0))
625
+ for i in range(n):
626
+ if i not in greedy_by_sample or completion_ids.size(1) == 0:
627
+ continue
628
+ greedy_id = greedy_by_sample[i]
629
+ actual_id = int(completion_ids[i, 0].item())
630
+ opsd_debug.log_gendbg(
631
+ "first_token_match",
632
+ f"sample[{i}]",
633
+ generate_call_index=generate_call_index,
634
+ greedy_token_id=greedy_id,
635
+ actual_first_token=actual_id,
636
+ greedy_matches_actual=(greedy_id == actual_id),
637
+ )
638
+
639
+
640
+ def log_generate_delta(
641
+ *,
642
+ generate_call_index: int,
643
+ current_stats: dict[str, Any],
644
+ previous_stats: Optional[dict[str, Any]],
645
+ ) -> None:
646
+ if not opsd_debug.should_log_gendbg():
647
+ return
648
+
649
+ fields: dict[str, Any] = {
650
+ "generate_call_index": generate_call_index,
651
+ "current": current_stats,
652
+ }
653
+ if previous_stats is not None:
654
+ prev_idx = previous_stats.get("generate_call_index", generate_call_index - 1)
655
+ fields["prev_generate_call_index"] = prev_idx
656
+ for key in (
657
+ "effective_tokens_mean",
658
+ "one_token_count",
659
+ "paren_then_eos_count",
660
+ "repeat_loop_count",
661
+ "eos_terminated_rate",
662
+ "degenerate_count",
663
+ "degenerate_rate",
664
+ "clipped_rate",
665
+ "answer_flag_exactly_once_rate",
666
+ ):
667
+ cur = current_stats.get(key)
668
+ prev = previous_stats.get(key)
669
+ if cur is not None and prev is not None:
670
+ if isinstance(cur, (int, float)) and isinstance(prev, (int, float)):
671
+ fields[f"delta_{key}"] = cur - prev
672
+ opsd_debug.log_gendbg("delta", "generate stats vs previous regenerate", **fields)
673
+
674
+
675
+ def log_generate_probe(
676
+ *,
677
+ global_step: int,
678
+ trainer_step: Optional[int],
679
+ prompt_length: int,
680
+ prompt_completion_ids: torch.Tensor,
681
+ completion_ids: torch.Tensor,
682
+ completion_mask: torch.Tensor,
683
+ is_eos: torch.Tensor,
684
+ eos_idx: torch.Tensor,
685
+ completions: list[str],
686
+ tokenizer: Any,
687
+ generation_config: Any,
688
+ max_completion_length: int,
689
+ num_generations: int,
690
+ sample_count: int = 4,
691
+ generate_call_index: Optional[int] = None,
692
+ answer_flag: str = "Answer:",
693
+ source: str = "generate",
694
+ ) -> dict[str, Any]:
695
+ """Emit [OPSD-PROBE] on every (re)generate — catches 1-token / empty completions early."""
696
+ stats = summarize_generate_probe_stats(
697
+ completion_ids,
698
+ completion_mask,
699
+ is_eos,
700
+ getattr(tokenizer, "eos_token_id", None),
701
+ completions=completions,
702
+ answer_flag=answer_flag,
703
+ max_completion_length=max_completion_length,
704
+ )
705
+ if generate_call_index is not None:
706
+ stats["generate_call_index"] = generate_call_index
707
+
708
+ if not opsd_debug.should_log_probe():
709
+ return stats
710
+
711
+ opsd_debug.set_detail_step(global_step)
712
+ tok = tokenizer
713
+ eos_id = getattr(tok, "eos_token_id", None)
714
+ pad_id = getattr(tok, "pad_token_id", None)
715
+ bos_id = getattr(tok, "bos_token_id", None)
716
+
717
+ with torch.no_grad():
718
+ lengths = completion_mask.sum(dim=1).float()
719
+ has_eos = is_eos.any(dim=1)
720
+ raw_gen_len = completion_ids.size(1)
721
+
722
+ opsd_debug.log_probe(
723
+ "generate",
724
+ "sft cold-start GT batch" if source == "sft_cold_start" else "raw generate summary",
725
+ trainer_step=trainer_step,
726
+ global_step=global_step,
727
+ source=source,
728
+ generate_call_index=generate_call_index,
729
+ prompt_length=prompt_length,
730
+ prompt_completion_shape=tuple(prompt_completion_ids.shape),
731
+ completion_ids_shape=tuple(completion_ids.shape),
732
+ raw_gen_tokens=raw_gen_len,
733
+ max_completion_length=max_completion_length,
734
+ num_generations=num_generations,
735
+ batch_size=completion_ids.size(0),
736
+ effective_tokens_mean=stats["effective_tokens_mean"],
737
+ effective_tokens_min=float(lengths.min().item()),
738
+ effective_tokens_max=float(lengths.max().item()),
739
+ zero_length_count=int((lengths == 0).sum().item()),
740
+ one_token_count=stats["one_token_count"],
741
+ paren_then_eos_count=stats["paren_then_eos_count"],
742
+ repeat_loop_count=stats["repeat_loop_count"],
743
+ eos_terminated_rate=stats["eos_terminated_rate"],
744
+ degenerate_count=stats["degenerate_count"],
745
+ degenerate_rate=stats["degenerate_rate"],
746
+ max_token_run_mean=stats["max_token_run_mean"],
747
+ unique_token_ratio_mean=stats["unique_token_ratio_mean"],
748
+ answer_flag_exactly_once_rate=stats["answer_flag_exactly_once_rate"],
749
+ clipped_count=stats["clipped_count"],
750
+ clipped_rate=stats["clipped_rate"],
751
+ tokenizer_eos_id=eos_id,
752
+ tokenizer_pad_id=pad_id,
753
+ tokenizer_bos_id=bos_id,
754
+ generation_config=_generation_config_summary(generation_config),
755
+ )
756
+
757
+ suspicious: list[str] = []
758
+ n = min(sample_count, len(completions))
759
+ for i in range(n):
760
+ eff = int(lengths[i].item())
761
+ eidx = int(eos_idx[i].item())
762
+ ids_head = completion_ids[i, : min(16, completion_ids.size(1))].tolist()
763
+ ids_all = completion_ids[i].tolist()
764
+ decode_skip = completions[i]
765
+ decode_keep = tok.decode(completion_ids[i], skip_special_tokens=False)
766
+ first_tok = int(completion_ids[i, 0].item()) if completion_ids.size(1) > 0 else None
767
+ first_is_eos = first_tok is not None and first_tok == eos_id
768
+ flags: list[str] = []
769
+ patterns: list[str] = []
770
+ if eff <= 0:
771
+ flags.append("ZERO_LEN")
772
+ if eff == 1:
773
+ flags.append("ONE_TOKEN")
774
+ if not (decode_skip or "").strip():
775
+ flags.append("EMPTY_DECODE")
776
+ if first_is_eos:
777
+ flags.append("FIRST_IS_EOS")
778
+ if eff <= 2 and first_tok == PAREN_TOKEN_ID:
779
+ patterns.append("PAREN_THEN_EOS")
780
+ if eff > 0:
781
+ ids_eff = completion_ids[i, :eff].tolist()
782
+ if _detect_repeat_loop(ids_eff):
783
+ patterns.append("REPEAT_LOOP")
784
+ if _detect_single_token_repeat(ids_eff):
785
+ run_len, repeat_tok_id = _max_same_token_run(ids_eff)
786
+ patterns.append(f"SINGLE_TOKEN_REPEAT({run_len}x{repeat_tok_id})")
787
+ is_deg, deg_reasons = _detect_degeneration(ids_eff, decode_skip, answer_flag=answer_flag)
788
+ if is_deg:
789
+ patterns.extend(deg_reasons)
790
+ if flags:
791
+ suspicious.append(f"sample[{i}]:{','.join(flags)}")
792
+
793
+ opsd_debug.log_probe(
794
+ "generate",
795
+ f"sample[{i}]",
796
+ group=i // num_generations if num_generations else 0,
797
+ effective_tokens=eff,
798
+ eos_idx=eidx,
799
+ has_eos=bool(has_eos[i].item()),
800
+ first_token_id=first_tok,
801
+ first_is_eos=first_is_eos,
802
+ token_ids_head=ids_head,
803
+ token_ids_all=ids_all if len(ids_all) <= 32 else ids_head + ["..."],
804
+ decode_skip_special=_preview_text(decode_skip, 400),
805
+ decode_keep_special=_preview_text(decode_keep, 400),
806
+ flags=flags or None,
807
+ patterns=patterns or None,
808
+ )
809
+
810
+ if suspicious:
811
+ opsd_debug.log_probe(
812
+ "generate",
813
+ "ALERT suspicious completions",
814
+ count=len(suspicious),
815
+ samples=suspicious,
816
+ hint="check eos_token_id, max_new_tokens, model.generate output, or training-time unwrap/FSDP",
817
+ )
818
+
819
+ if stats.get("degenerate_rate", 0) >= 0.25:
820
+ opsd_debug.log_probe(
821
+ "generate",
822
+ "ALERT repetition / format degeneration",
823
+ degenerate_count=stats["degenerate_count"],
824
+ degenerate_rate=stats["degenerate_rate"],
825
+ clipped_rate=stats["clipped_rate"],
826
+ answer_flag_exactly_once_rate=stats["answer_flag_exactly_once_rate"],
827
+ max_token_run_mean=stats["max_token_run_mean"],
828
+ hint="typical RL collapse: raise repetition_penalty, lower temperature, or shorten max_completion_length",
829
+ )
830
+
831
+ return stats
832
+
833
+
834
+ def log_cross_rank_generate_summary(
835
+ *,
836
+ accelerator: Any,
837
+ one_token_count: int,
838
+ effective_tokens_mean: float,
839
+ generate_call_index: int,
840
+ ) -> None:
841
+ """Gather per-rank generate stats; all ranks must call, log on rank 0 only."""
842
+ if not opsd_debug.probe_on_generate():
843
+ return
844
+
845
+ from accelerate.utils import gather as accel_gather
846
+
847
+ device = accelerator.device
848
+ world_size = accelerator.num_processes
849
+ local = torch.tensor(
850
+ [float(one_token_count), effective_tokens_mean],
851
+ dtype=torch.float32,
852
+ device=device,
853
+ )
854
+ # gather_for_metrics on 1D [2] concatenates ranks into [world_size*2]; use gather + view instead.
855
+ gathered = accel_gather(local.unsqueeze(0))
856
+ if not opsd_debug.should_log_gendbg():
857
+ return
858
+
859
+ if gathered.dim() == 1:
860
+ gathered = gathered.view(world_size, -1)
861
+ elif gathered.size(0) != world_size and gathered.numel() == world_size * 2:
862
+ gathered = gathered.reshape(world_size, 2)
863
+ one_tokens = gathered[:, 0].tolist()
864
+ means = gathered[:, 1].tolist()
865
+ opsd_debug.log_gendbg(
866
+ "cross_rank",
867
+ "generate summary across ranks",
868
+ generate_call_index=generate_call_index,
869
+ world_size=world_size,
870
+ one_token_count_per_rank=one_tokens,
871
+ effective_tokens_mean_per_rank=means,
872
+ one_token_count_total=int(sum(one_tokens)),
873
+ )
874
+
875
+
876
+ def log_routed_completion_probe(
877
+ *,
878
+ global_step: int,
879
+ trainer_step: Optional[int],
880
+ raw_completion_shape: tuple[int, ...],
881
+ final_completion_ids: torch.Tensor,
882
+ final_completion_mask: torch.Tensor,
883
+ opsd_mask_list: list[bool],
884
+ sample_count: int = 4,
885
+ tokenizer: Any,
886
+ sft_replaced_list: Optional[list[bool]] = None,
887
+ raw_completion_ids: Optional[torch.Tensor] = None,
888
+ ) -> None:
889
+ """Compare raw vs post-routing padded completion shapes (detect routing truncation)."""
890
+ if not opsd_debug.should_log_probe():
891
+ return
892
+
893
+ with torch.no_grad():
894
+ final_lengths = final_completion_mask.sum(dim=1).float()
895
+
896
+ opsd_debug.log_probe(
897
+ "route",
898
+ "post-routing completion shapes",
899
+ trainer_step=trainer_step,
900
+ global_step=global_step,
901
+ raw_completion_shape=raw_completion_shape,
902
+ final_completion_shape=tuple(final_completion_ids.shape),
903
+ final_mask_shape=tuple(final_completion_mask.shape),
904
+ final_effective_tokens_mean=float(final_lengths.mean().item()),
905
+ final_effective_tokens_min=float(final_lengths.min().item()),
906
+ final_effective_tokens_max=float(final_lengths.max().item()),
907
+ opsd_mask_true=sum(opsd_mask_list),
908
+ opsd_mask_false=len(opsd_mask_list) - sum(opsd_mask_list),
909
+ sft_replaced_count=sum(sft_replaced_list) if sft_replaced_list else None,
910
+ )
911
+
912
+ n = min(sample_count, final_completion_ids.size(0))
913
+ for i in range(n):
914
+ eff = int(final_lengths[i].item())
915
+ head = final_completion_ids[i, : min(12, final_completion_ids.size(1))].tolist()
916
+ text = tokenizer.decode(
917
+ final_completion_ids[i][final_completion_mask[i].bool()],
918
+ skip_special_tokens=True,
919
+ )
920
+ raw_head = None
921
+ raw_matches_final = None
922
+ if raw_completion_ids is not None and i < raw_completion_ids.size(0):
923
+ raw_eff = min(int(raw_completion_ids.size(1)), eff)
924
+ raw_head = raw_completion_ids[i, : min(12, raw_eff)].tolist()
925
+ raw_matches_final = raw_head == head if raw_head and head else None
926
+ opsd_debug.log_probe(
927
+ "route",
928
+ f"routed_sample[{i}]",
929
+ opsd_mask=opsd_mask_list[i] if i < len(opsd_mask_list) else None,
930
+ sft_replaced=sft_replaced_list[i] if sft_replaced_list and i < len(sft_replaced_list) else None,
931
+ effective_tokens=eff,
932
+ token_ids_head=head,
933
+ raw_token_ids_head=raw_head,
934
+ raw_matches_routed_head=raw_matches_final,
935
+ decode=_preview_text(text, 300),
936
+ )
937
+
938
+
939
+ def jsd_token_stats(
940
+ student_logits: torch.Tensor,
941
+ teacher_logits: torch.Tensor,
942
+ mask: torch.Tensor,
943
+ beta: float = 0.5,
944
+ top_k: int = 5,
945
+ ) -> dict[str, Any]:
946
+ """Token-level JSD breakdown without building the graph."""
947
+ with torch.no_grad():
948
+ student_log_probs = F.log_softmax(student_logits, dim=-1)
949
+ teacher_log_probs = F.log_softmax(teacher_logits, dim=-1)
950
+ student_probs = student_log_probs.exp()
951
+ teacher_probs = teacher_log_probs.exp()
952
+
953
+ if beta == 0:
954
+ jsd = F.kl_div(student_log_probs, teacher_log_probs, reduction="none", log_target=True)
955
+ elif beta == 1:
956
+ jsd = F.kl_div(teacher_log_probs, student_log_probs, reduction="none", log_target=True)
957
+ else:
958
+ beta_t = torch.tensor(beta, dtype=student_log_probs.dtype, device=student_log_probs.device)
959
+ mixture_log_probs = torch.logsumexp(
960
+ torch.stack([student_log_probs + torch.log1p(-beta_t), teacher_log_probs + torch.log(beta_t)]),
961
+ dim=0,
962
+ )
963
+ kl_teacher = F.kl_div(mixture_log_probs, teacher_log_probs, reduction="none", log_target=True)
964
+ kl_student = F.kl_div(mixture_log_probs, student_log_probs, reduction="none", log_target=True)
965
+ jsd = beta_t * kl_teacher + (1 - beta_t) * kl_student
966
+
967
+ per_token_jsd = jsd.sum(dim=-1)
968
+ m = mask.float()
969
+ valid = m.sum().clamp(min=1.0)
970
+ per_token_jsd_masked = per_token_jsd * m
971
+
972
+ # Top-1 agreement on completion tokens
973
+ s_top = student_logits.argmax(dim=-1)
974
+ t_top = teacher_logits.argmax(dim=-1)
975
+ agree = ((s_top == t_top).float() * m).sum() / valid
976
+
977
+ # Mean L2 distance of log-probs on gold completion tokens (if provided elsewhere)
978
+ logprob_l2 = ((student_log_probs - teacher_log_probs) ** 2).sum(dim=-1)
979
+ logprob_l2_masked = (logprob_l2 * m).sum() / valid
980
+
981
+ # Entropy gap
982
+ s_ent = -(student_probs * student_log_probs).sum(dim=-1)
983
+ t_ent = -(teacher_probs * teacher_log_probs).sum(dim=-1)
984
+ ent_gap = ((s_ent - t_ent).abs() * m).sum() / valid
985
+
986
+ stats: dict[str, Any] = {
987
+ "jsd_per_token_mean": float((per_token_jsd_masked.sum() / valid).item()),
988
+ "jsd_per_token_max": float(per_token_jsd[m.bool()].max().item()) if m.any() else 0.0,
989
+ "jsd_per_token_min": float(per_token_jsd[m.bool()].min().item()) if m.any() else 0.0,
990
+ "top1_agreement": float(agree.item()),
991
+ "logprob_l2_mean": float(logprob_l2_masked.item()),
992
+ "entropy_gap_mean": float(ent_gap.item()),
993
+ "mask_valid_tokens": int(valid.item()),
994
+ }
995
+
996
+ if m.any():
997
+ idx = int((per_token_jsd * m).argmax().item())
998
+ pos = idx % per_token_jsd.size(-1)
999
+ stats["max_jsd_token_pos"] = pos
1000
+ stats["max_jsd_value"] = float(per_token_jsd.reshape(-1)[idx].item())
1001
+ s_topk = torch.topk(student_probs[0, pos], k=min(top_k, student_probs.size(-1)))
1002
+ t_topk = torch.topk(teacher_probs[0, pos], k=min(top_k, teacher_probs.size(-1)))
1003
+ stats["student_topk_at_max_jsd"] = [
1004
+ (int(i), float(p)) for i, p in zip(s_topk.indices.tolist(), s_topk.values.tolist())
1005
+ ]
1006
+ stats["teacher_topk_at_max_jsd"] = [
1007
+ (int(i), float(p)) for i, p in zip(t_topk.indices.tolist(), t_topk.values.tolist())
1008
+ ]
1009
+ return stats
1010
+
1011
+
1012
+ def log_generation_diagnostics(
1013
+ *,
1014
+ global_step: int,
1015
+ completions: list[str],
1016
+ completion_ids: torch.Tensor,
1017
+ completion_mask: torch.Tensor,
1018
+ is_eos: torch.Tensor,
1019
+ max_completion_length: int,
1020
+ num_generations: int,
1021
+ sample_count: int = 4,
1022
+ ) -> None:
1023
+ if not opsd_debug.should_log_detail(global_step):
1024
+ return
1025
+
1026
+ opsd_debug.log_detail_banner(global_step, "GENERATION & COMPLETION")
1027
+
1028
+ with torch.no_grad():
1029
+ lengths = completion_mask.sum(dim=1).float()
1030
+ has_eos = is_eos.any(dim=1)
1031
+ eos_rate = has_eos.float().mean().item()
1032
+ clipped = (~has_eos).float().mean().item()
1033
+ all_pad_or_zero = (lengths == 0).float().mean().item()
1034
+ at_max_len = (lengths >= max_completion_length - 1).float().mean().item()
1035
+
1036
+ fields: dict[str, Any] = {
1037
+ "batch_size": completion_ids.size(0),
1038
+ "num_generations": num_generations,
1039
+ "completion_max_len": completion_ids.size(1),
1040
+ "effective_tokens_mean": float(lengths.mean().item()),
1041
+ "effective_tokens_min": float(lengths.min().item()),
1042
+ "effective_tokens_max": float(lengths.max().item()),
1043
+ "eos_terminated_rate": eos_rate,
1044
+ "clipped_no_eos_rate": clipped,
1045
+ "zero_length_rate": all_pad_or_zero,
1046
+ "at_max_length_rate": at_max_len,
1047
+ }
1048
+ opsd_debug.log_detail("generation", "completion mask summary", **fields)
1049
+
1050
+ n = min(sample_count, len(completions))
1051
+ for i in range(n):
1052
+ gen_group = i // num_generations if num_generations else 0
1053
+ opsd_debug.log_detail(
1054
+ "generation",
1055
+ f"sample[{i}]",
1056
+ group=gen_group,
1057
+ effective_tokens=int(lengths[i].item()),
1058
+ has_eos=bool(has_eos[i].item()),
1059
+ text=_preview_text(completions[i]),
1060
+ raw_token_head=completion_ids[i, :12].tolist(),
1061
+ )
1062
+
1063
+
1064
+ def log_reward_diagnostics(
1065
+ *,
1066
+ global_step: int,
1067
+ format_rewards: torch.Tensor,
1068
+ acc_rewards: torch.Tensor,
1069
+ context_rewards: torch.Tensor,
1070
+ all_rewards: torch.Tensor,
1071
+ advantages: torch.Tensor,
1072
+ reward_weights: torch.Tensor,
1073
+ num_generations: int,
1074
+ answers: Optional[list[str]] = None,
1075
+ completions: Optional[list[str]] = None,
1076
+ sample_count: int = 4,
1077
+ ) -> None:
1078
+ if not opsd_debug.should_log_detail(global_step):
1079
+ return
1080
+
1081
+ opsd_debug.log_detail_banner(global_step, "REWARD & ADVANTAGE")
1082
+
1083
+ w = reward_weights.detach().float()
1084
+ weighted = (
1085
+ format_rewards * w[0] + context_rewards * w[1] + acc_rewards * w[2]
1086
+ )
1087
+
1088
+ fields: dict[str, Any] = {
1089
+ "reward_weights": w.tolist(),
1090
+ "format_sum": float(format_rewards.sum().item()),
1091
+ "acc_sum": float(acc_rewards.sum().item()),
1092
+ "context_sum": float(context_rewards.sum().item()),
1093
+ "total_sum": float(all_rewards.sum().item()),
1094
+ "format_zero_rate": float((format_rewards == 0).float().mean().item()),
1095
+ "acc_zero_rate": float((acc_rewards == 0).float().mean().item()),
1096
+ "context_zero_rate": float((context_rewards == 0).float().mean().item()),
1097
+ "weighted_mean": float(weighted.mean().item()),
1098
+ "weighted_std": float(weighted.std(unbiased=False).item()) if weighted.numel() > 1 else 0.0,
1099
+ }
1100
+ adv_flat = advantages.reshape(-1)
1101
+ fields.update(
1102
+ {
1103
+ "advantage_mean": float(adv_flat.mean().item()),
1104
+ "advantage_std": float(adv_flat.std(unbiased=False).item()) if adv_flat.numel() > 1 else 0.0,
1105
+ "advantage_zero_rate": float((adv_flat.abs() < 1e-8).float().mean().item()),
1106
+ "advantage_min": float(adv_flat.min().item()),
1107
+ "advantage_max": float(adv_flat.max().item()),
1108
+ }
1109
+ )
1110
+ opsd_debug.log_detail("reward", "aggregate reward stats", **fields)
1111
+
1112
+ n = min(sample_count, format_rewards.numel())
1113
+ for i in range(n):
1114
+ g = i // num_generations if num_generations else 0
1115
+ extra: dict[str, Any] = {
1116
+ "group": g,
1117
+ "format": float(format_rewards.view(-1)[i].item()),
1118
+ "acc": float(acc_rewards.view(-1)[i].item()),
1119
+ "context": float(context_rewards.view(-1)[i].item()),
1120
+ "weighted": float(weighted.view(-1)[i].item()),
1121
+ "advantage": float(adv_flat.view(-1)[i].item()) if i < adv_flat.numel() else None,
1122
+ }
1123
+ if answers and i < len(answers):
1124
+ extra["gold_answer"] = _preview_text(str(answers[i // num_generations]), 80)
1125
+ if completions and i < len(completions):
1126
+ extra["completion"] = _preview_text(completions[i], 160)
1127
+ opsd_debug.log_detail("reward", f"per_sample[{i}]", **extra)
1128
+
1129
+
1130
+ def log_routing_diagnostics(
1131
+ *,
1132
+ global_step: int,
1133
+ opsd_active: bool,
1134
+ opsd_mask_list: list[bool],
1135
+ has_correct: torch.Tensor,
1136
+ completion_modes: Optional[list[int]] = None,
1137
+ recoverable_flags: Optional[list[bool]] = None,
1138
+ completion_advantages: Optional[torch.Tensor] = None,
1139
+ completion_mask: Optional[torch.Tensor] = None,
1140
+ ) -> None:
1141
+ if not opsd_debug.should_log_detail(global_step):
1142
+ return
1143
+
1144
+ opsd_debug.log_detail_banner(global_step, "OPSD ROUTING & MASK")
1145
+
1146
+ opsd_true = sum(opsd_mask_list)
1147
+ fields: dict[str, Any] = {
1148
+ "opsd_active": opsd_active,
1149
+ "opsd_mask_true": opsd_true,
1150
+ "opsd_mask_false": len(opsd_mask_list) - opsd_true,
1151
+ "opsd_mask_ratio": opsd_true / max(len(opsd_mask_list), 1),
1152
+ "has_correct": has_correct.tolist() if hasattr(has_correct, "tolist") else has_correct,
1153
+ }
1154
+ if recoverable_flags is not None:
1155
+ fields["recoverable_flags"] = recoverable_flags
1156
+ if completion_modes is not None:
1157
+ from opsd_utils.debug_log import MODE_NAMES
1158
+
1159
+ counts = {MODE_NAMES.get(c, str(c)): completion_modes.count(c) for c in set(completion_modes)}
1160
+ fields["completion_mode_counts"] = counts
1161
+
1162
+ if completion_advantages is not None and completion_mask is not None:
1163
+ with torch.no_grad():
1164
+ pos = ((completion_advantages > 0) & (completion_mask > 0)).float().sum(dim=1)
1165
+ neg = ((completion_advantages < 0) & (completion_mask > 0)).float().sum(dim=1)
1166
+ zero_adv = ((completion_advantages.abs() < 1e-8) & (completion_mask > 0)).float().sum(dim=1)
1167
+ fields["adv_pos_tokens_mean"] = float(pos.mean().item())
1168
+ fields["adv_neg_tokens_mean"] = float(neg.mean().item())
1169
+ fields["adv_zero_tokens_mean"] = float(zero_adv.mean().item())
1170
+
1171
+ opsd_debug.log_detail("routing", "mode routing summary", **fields)
1172
+
1173
+
1174
+ def log_loss_diagnostics(
1175
+ *,
1176
+ global_step: int,
1177
+ grpo_loss: torch.Tensor,
1178
+ per_token_logps: torch.Tensor,
1179
+ old_per_token_logps: torch.Tensor,
1180
+ completion_mask: torch.Tensor,
1181
+ advantages: torch.Tensor,
1182
+ coef_1: torch.Tensor,
1183
+ per_token_loss: torch.Tensor,
1184
+ opsd_loss: Optional[torch.Tensor] = None,
1185
+ combined_loss: Optional[torch.Tensor] = None,
1186
+ opsd_mask: Optional[torch.Tensor] = None,
1187
+ epsilon_low: float = 0.2,
1188
+ epsilon_high: float = 0.2,
1189
+ ) -> None:
1190
+ if not opsd_debug.should_log_detail(global_step):
1191
+ return
1192
+
1193
+ opsd_debug.log_detail_banner(global_step, "LOSS & GRADIENT SIGNAL")
1194
+
1195
+ with torch.no_grad():
1196
+ m = completion_mask.float()
1197
+ valid_per_sample = m.sum(dim=1).clamp(min=1.0)
1198
+ sample_grpo = (per_token_loss * m).sum(dim=1) / valid_per_sample
1199
+
1200
+ fields: dict[str, Any] = {
1201
+ "grpo_loss_scalar": float(grpo_loss.detach().item()),
1202
+ "grpo_per_sample_mean": float(sample_grpo.mean().item()),
1203
+ "grpo_per_sample_max": float(sample_grpo.max().item()),
1204
+ "completion_mask_tokens_mean": float(valid_per_sample.mean().item()),
1205
+ }
1206
+ fields.update(_tensor_stats(advantages, "advantages"))
1207
+ fields.update(_tensor_stats(per_token_logps, "per_token_logps"))
1208
+ fields.update(_tensor_stats(old_per_token_logps, "old_per_token_logps"))
1209
+ fields.update(_tensor_stats((per_token_logps - old_per_token_logps) * m, "logps_delta_masked"))
1210
+ fields.update(_tensor_stats(coef_1 * m, "coef_1_masked"))
1211
+
1212
+ low_clip = ((coef_1 < 1 - epsilon_low) & (advantages.unsqueeze(1) < 0) & (m > 0)).float().sum()
1213
+ high_clip = ((coef_1 > 1 + epsilon_high) & (advantages.unsqueeze(1) > 0) & (m > 0)).float().sum()
1214
+ fields["clipped_low_tokens"] = int(low_clip.item())
1215
+ fields["clipped_high_tokens"] = int(high_clip.item())
1216
+ fields["grpo_zero_loss_rate"] = float((sample_grpo.abs() < 1e-12).float().mean().item())
1217
+
1218
+ if opsd_loss is not None:
1219
+ fields["opsd_loss_scalar"] = float(opsd_loss.detach().item())
1220
+ if combined_loss is not None:
1221
+ fields["combined_loss_scalar"] = float(combined_loss.detach().item())
1222
+ if opsd_mask is not None:
1223
+ fields["opsd_batch_count"] = int(opsd_mask.sum().item())
1224
+
1225
+ # Weak-signal hints
1226
+ hints: list[str] = []
1227
+ if fields.get("advantages/abs_mean", 1.0) < 1e-6:
1228
+ hints.append("advantages≈0 → GRPO per-token loss≈0")
1229
+ if fields.get("grpo_zero_loss_rate", 0) > 0.9:
1230
+ hints.append("most samples have ~0 GRPO loss")
1231
+ if fields.get("completion_mask_tokens_mean", 0) < 2:
1232
+ hints.append("very few effective completion tokens in mask")
1233
+ if opsd_loss is not None and abs(fields.get("opsd_loss_scalar", 0)) < 1e-8:
1234
+ hints.append("OPSD JSD≈0 → student/teacher nearly identical on completion")
1235
+ fields["weak_signal_hints"] = hints or ["none"]
1236
+
1237
+ opsd_debug.log_detail("loss", "GRPO / OPSD loss breakdown", **fields)
1238
+
1239
+
1240
+ def summarize_loss_health(
1241
+ *,
1242
+ grpo_loss: torch.Tensor,
1243
+ per_token_logps: torch.Tensor,
1244
+ completion_mask: torch.Tensor,
1245
+ advantages: torch.Tensor,
1246
+ per_token_loss: torch.Tensor,
1247
+ opsd_loss: Optional[torch.Tensor] = None,
1248
+ combined_loss: Optional[torch.Tensor] = None,
1249
+ ) -> dict[str, float]:
1250
+ """Lightweight loss/signal summary for every-step health monitoring."""
1251
+ with torch.no_grad():
1252
+ m = completion_mask.float()
1253
+ valid_per_sample = m.sum(dim=1).clamp(min=1.0)
1254
+ sample_grpo = (per_token_loss * m).sum(dim=1) / valid_per_sample
1255
+ adv_flat = advantages.detach().float().reshape(-1)
1256
+ fields: dict[str, float] = {
1257
+ "grpo_loss_scalar": float(grpo_loss.detach().item()),
1258
+ "grpo_zero_loss_rate": float((sample_grpo.abs() < 1e-12).float().mean().item()),
1259
+ "advantages_abs_mean": float(adv_flat.abs().mean().item()) if adv_flat.numel() else 0.0,
1260
+ "completion_mask_tokens_mean": float(valid_per_sample.mean().item()),
1261
+ }
1262
+ if opsd_loss is not None:
1263
+ fields["opsd_loss_scalar"] = float(opsd_loss.detach().item())
1264
+ if combined_loss is not None:
1265
+ fields["combined_loss_scalar"] = float(combined_loss.detach().item())
1266
+ logps_delta = (per_token_logps * m).sum() / m.sum().clamp(min=1.0)
1267
+ fields["logps_delta_mean"] = float(logps_delta.item()) if m.sum() > 0 else 0.0
1268
+ return fields
1269
+
1270
+
1271
+ def summarize_batch_data_health(
1272
+ samples: list[dict[str, Any]],
1273
+ *,
1274
+ prompt_mask: Optional[torch.Tensor] = None,
1275
+ pixel_values: Optional[torch.Tensor] = None,
1276
+ ) -> dict[str, Any]:
1277
+ """Batch-level data I/O sanity for health monitoring."""
1278
+ n = max(len(samples), 1)
1279
+ vf_empty = 0
1280
+ prompt_lens: list[int] = []
1281
+ for sample in samples:
1282
+ vf = (
1283
+ sample.get("visual_fact_hint")
1284
+ or sample.get("visual_fact")
1285
+ or sample.get("visual_facts")
1286
+ or ""
1287
+ )
1288
+ if not str(vf).strip():
1289
+ vf_empty += 1
1290
+ if sample.get("prompt"):
1291
+ prompt_lens.append(len(str(sample["prompt"])))
1292
+
1293
+ out: dict[str, Any] = {
1294
+ "visual_fact_empty_rate": vf_empty / n,
1295
+ "batch_size": len(samples),
1296
+ "prompt_len_mean": float(sum(prompt_lens) / len(prompt_lens)) if prompt_lens else 0.0,
1297
+ }
1298
+
1299
+ if prompt_mask is not None:
1300
+ with torch.no_grad():
1301
+ lengths = prompt_mask.sum(dim=1).float()
1302
+ out["prompt_tokens_mean"] = float(lengths.mean().item())
1303
+ out["prompt_tokens_max"] = float(lengths.max().item())
1304
+
1305
+ if pixel_values is not None and isinstance(pixel_values, torch.Tensor) and pixel_values.numel() > 0:
1306
+ with torch.no_grad():
1307
+ flat = pixel_values.detach().float().reshape(-1)
1308
+ out["pixel_mean"] = float(flat.mean().item())
1309
+ out["pixel_has_nan"] = bool(torch.isnan(flat).any().item())
1310
+ out["pixel_has_inf"] = bool(torch.isinf(flat).any().item())
1311
+
1312
+ return out
1313
+
1314
+
1315
+ def log_opsd_jsd_diagnostics(*, global_step: int) -> None:
1316
+ """Emit cached JSD stats recorded during OPSD loss (zero extra model forwards)."""
1317
+ if not opsd_debug.should_log_detail(global_step):
1318
+ return
1319
+
1320
+ capture = _OPSD_JSD_DETAIL_CAPTURE
1321
+ if capture.get("global_step") != global_step:
1322
+ return
1323
+
1324
+ opsd_debug.log_detail_banner(global_step, "OPSD JSD DECOMPOSITION")
1325
+
1326
+ if capture.get("skipped_memory"):
1327
+ opsd_debug.log_detail(
1328
+ "opsd_jsd",
1329
+ "JSD detail skipped (CUDA memory guard)",
1330
+ global_step=global_step,
1331
+ reason=capture.get("skip_reason", ""),
1332
+ min_free_gib=_detail_min_free_gib(),
1333
+ cuda_free_gib=cuda_free_gib(),
1334
+ )
1335
+ _OPSD_JSD_DETAIL_CAPTURE["active"] = False
1336
+ return
1337
+
1338
+ entries = capture.get("entries") or []
1339
+ if not entries:
1340
+ opsd_debug.log_detail(
1341
+ "opsd_jsd",
1342
+ "no JSD detail captured (no OPSD samples on this rank or capture disabled)",
1343
+ global_step=global_step,
1344
+ )
1345
+ _OPSD_JSD_DETAIL_CAPTURE["active"] = False
1346
+ return
1347
+
1348
+ for k, stats in enumerate(entries):
1349
+ opsd_debug.log_detail("opsd_jsd", f"sample[{k}] token-level JSD", global_step=global_step, **stats)
1350
+
1351
+ _OPSD_JSD_DETAIL_CAPTURE["active"] = False
opsd_utils/leakage.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Detect OPSD / completion patterns that indicate privileged-information leakage."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import Any
6
+
7
+ _LEAKAGE_PHRASES = (
8
+ "reference answer",
9
+ "reference reasoning",
10
+ "according to the reference",
11
+ "according to the answer",
12
+ "参考答案",
13
+ )
14
+
15
+ _ANSWER_IN_COMPLETION = re.compile(r"(?i)answer:\s*.+")
16
+
17
+
18
+ def completion_has_leakage_pattern(
19
+ text: str,
20
+ gold_answer: str | None = None,
21
+ *,
22
+ min_gold_substring_len: int = 4,
23
+ ) -> bool:
24
+ """Return True if completion text suggests privileged-info cheating."""
25
+ if not text or not text.strip():
26
+ return False
27
+ lower = text.lower()
28
+ for phrase in _LEAKAGE_PHRASES:
29
+ if phrase in lower:
30
+ return True
31
+ if gold_answer:
32
+ gold = gold_answer.strip()
33
+ if len(gold) >= min_gold_substring_len and gold.lower() in lower:
34
+ # Short numeric answers may false-positive; require Answer: prefix context
35
+ if _ANSWER_IN_COMPLETION.search(text):
36
+ return False
37
+ if len(gold) >= 8:
38
+ return True
39
+ return False
40
+
41
+
42
+ def privileged_suffix_has_gold(suffix: str, sample: dict[str, Any]) -> bool:
43
+ if not suffix.strip():
44
+ return False
45
+ if "[Reference Answer]" in suffix or "[Reference Reasoning]" in suffix:
46
+ return True
47
+ answer = (sample.get("answer") or "").strip()
48
+ hint = (sample.get("hint") or "").strip()
49
+ if answer and answer in suffix:
50
+ return True
51
+ if hint and len(hint) >= 8 and hint in suffix:
52
+ return True
53
+ return False
opsd_utils/opsd_loss.py ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn.functional as F
3
+
4
+ from opsd_utils import debug_log as opsd_debug
5
+ from opsd_utils import diagnostics as opsd_diagnostics
6
+ from opsd_utils.deepspeed_utils import deepspeed_requires_single_student_forward
7
+ from opsd_utils.teacher_batching import (
8
+ align_teacher_prompt_image_tokens,
9
+ as_batch_num_images_tensor,
10
+ get_teacher_vision_for_sample,
11
+ model_inference_device,
12
+ move_batch_num_images_to_model_device,
13
+ move_pixel_values_to_model_device,
14
+ student_batch_num_images_tensor,
15
+ )
16
+ from opsd_utils.vocab_align import align_cross_model_logits
17
+
18
+
19
+ def _slice_image_sizes(image_sizes, index: int):
20
+ """Slice per-sample image_sizes for student path (one image per batch row)."""
21
+ if image_sizes is None:
22
+ return None
23
+ if isinstance(image_sizes, torch.Tensor):
24
+ if image_sizes.dim() == 0:
25
+ return image_sizes
26
+ return image_sizes[index : index + 1]
27
+ if isinstance(image_sizes, (list, tuple)):
28
+ return image_sizes[index]
29
+ return image_sizes
30
+
31
+
32
+ def _slice_image_sizes_batch(image_sizes, start: int, end: int):
33
+ """Slice image_sizes for a micro-batch row range [start, end)."""
34
+ if image_sizes is None:
35
+ return None
36
+ if isinstance(image_sizes, torch.Tensor):
37
+ if image_sizes.dim() == 0:
38
+ return image_sizes
39
+ if image_sizes.shape[0] >= end:
40
+ return image_sizes[start:end]
41
+ return image_sizes
42
+ if isinstance(image_sizes, (list, tuple)):
43
+ return image_sizes[start:end] if len(image_sizes) >= end else image_sizes
44
+ return image_sizes
45
+
46
+
47
+ def _teacher_image_counts(inputs: dict, batch_size: int) -> list[int]:
48
+ """Number of teacher images per batch sample (LLaVA-OV stacks images on dim 0)."""
49
+ counts = inputs.get("teacher_num_images")
50
+ if counts is None:
51
+ return [1] * batch_size
52
+ if isinstance(counts, torch.Tensor):
53
+ return [int(max(1, c)) for c in counts.detach().cpu().tolist()]
54
+ return [int(max(1, c)) for c in counts]
55
+
56
+
57
+ def slice_teacher_vision_inputs(
58
+ teacher_pixel_values,
59
+ teacher_image_sizes,
60
+ local: int,
61
+ num_images_per_sample: list[int],
62
+ ):
63
+ """
64
+ Slice teacher pixel_values / image_sizes for one batch sample.
65
+ LLaVA-OneVision uses dim-0 = total images across batch (not batch size).
66
+ """
67
+ if teacher_pixel_values is None:
68
+ return None, None
69
+ start = sum(num_images_per_sample[:local])
70
+ end = start + num_images_per_sample[local]
71
+ t_pixel = teacher_pixel_values[start:end]
72
+ t_sizes = None
73
+ if teacher_image_sizes is not None and isinstance(teacher_image_sizes, torch.Tensor):
74
+ t_sizes = teacher_image_sizes[start:end]
75
+ return t_pixel, t_sizes
76
+
77
+
78
+ def generalized_jsd_loss(student_logits, teacher_logits, mask, beta=0.5):
79
+ """Token-level generalized JSD on completion positions."""
80
+ # Cross-model OPD: teacher logits already live on the teacher GPU; avoid
81
+ # copying them onto the student GPU (vocab × seq is multi-hundred MiB per sample).
82
+ jsd_device = teacher_logits.device
83
+ if student_logits.device != jsd_device:
84
+ student_logits = student_logits.to(jsd_device, non_blocking=True)
85
+ mask = mask.to(device=jsd_device, non_blocking=True)
86
+
87
+ comp_dtype = student_logits.dtype
88
+ if comp_dtype == torch.float32:
89
+ comp_dtype = torch.bfloat16
90
+ if student_logits.dtype != comp_dtype:
91
+ student_logits = student_logits.to(comp_dtype)
92
+ if teacher_logits.dtype != comp_dtype:
93
+ teacher_logits = teacher_logits.to(comp_dtype)
94
+
95
+ student_logits, teacher_logits = align_cross_model_logits(student_logits, teacher_logits)
96
+ student_log_probs = F.log_softmax(student_logits, dim=-1)
97
+ teacher_log_probs = F.log_softmax(teacher_logits, dim=-1)
98
+ opsd_debug.log(
99
+ "vocab_align",
100
+ "generalized_jsd_loss log_softmax on aligned vocab",
101
+ student_log_prob_shape=tuple(student_log_probs.shape),
102
+ teacher_log_prob_shape=tuple(teacher_log_probs.shape),
103
+ student_exp_sum=float(torch.exp(student_log_probs[0, 0]).sum().item()) if student_log_probs.numel() else None,
104
+ teacher_exp_sum=float(torch.exp(teacher_log_probs[0, 0]).sum().item()) if teacher_log_probs.numel() else None,
105
+ )
106
+
107
+ if beta == 0:
108
+ jsd = F.kl_div(student_log_probs, teacher_log_probs, reduction="none", log_target=True)
109
+ elif beta == 1:
110
+ jsd = F.kl_div(teacher_log_probs, student_log_probs, reduction="none", log_target=True)
111
+ else:
112
+ beta_t = torch.tensor(beta, dtype=student_log_probs.dtype, device=student_log_probs.device)
113
+ mixture_log_probs = torch.logsumexp(
114
+ torch.stack([student_log_probs + torch.log1p(-beta_t), teacher_log_probs + torch.log(beta_t)]),
115
+ dim=0,
116
+ )
117
+ kl_teacher = F.kl_div(mixture_log_probs, teacher_log_probs, reduction="none", log_target=True)
118
+ kl_student = F.kl_div(mixture_log_probs, student_log_probs, reduction="none", log_target=True)
119
+ jsd = beta_t * kl_teacher + (1 - beta_t) * kl_student
120
+
121
+ jsd = jsd.sum(dim=-1)
122
+ jsd = jsd * mask
123
+ denom = mask.sum().clamp(min=1.0)
124
+ return jsd.sum() / denom
125
+
126
+
127
+ def _teacher_logits_with_oom_retry(
128
+ model,
129
+ processor,
130
+ teacher_prompt_ids,
131
+ teacher_prompt_mask,
132
+ completion_ids,
133
+ completion_mask,
134
+ t_pixel,
135
+ t_sizes,
136
+ logits_to_keep: int,
137
+ teacher_batch_num_images=None,
138
+ ):
139
+ """Teacher forward with OOM micro-batch halving (decision E). Batch dim is already 1 in OPSD loop."""
140
+ if processor is not None:
141
+ teacher_prompt_ids, teacher_prompt_mask = align_teacher_prompt_image_tokens(
142
+ model,
143
+ processor,
144
+ teacher_prompt_ids,
145
+ teacher_prompt_mask,
146
+ t_pixel,
147
+ t_sizes,
148
+ batch_num_images=teacher_batch_num_images,
149
+ )
150
+ teacher_device = model_inference_device(model)
151
+ teacher_prompt_ids = teacher_prompt_ids.to(teacher_device)
152
+ teacher_prompt_mask = teacher_prompt_mask.to(teacher_device)
153
+ completion_ids = completion_ids.to(teacher_device)
154
+ completion_mask = completion_mask.to(teacher_device)
155
+ t_pixel = move_pixel_values_to_model_device(model, t_pixel)
156
+ teacher_batch_num_images = move_batch_num_images_to_model_device(model, teacher_batch_num_images)
157
+ teacher_input = torch.cat([teacher_prompt_ids, completion_ids], dim=1)
158
+ teacher_attn = torch.cat([teacher_prompt_mask, completion_mask], dim=1)
159
+ oom_retries = 0
160
+ while True:
161
+ try:
162
+ with torch.no_grad():
163
+ return model(
164
+ input_ids=teacher_input,
165
+ attention_mask=teacher_attn,
166
+ pixel_values=t_pixel,
167
+ image_sizes=t_sizes,
168
+ batch_num_images=teacher_batch_num_images,
169
+ ).logits[:, -logits_to_keep - 1 : -1, :]
170
+ except RuntimeError as exc:
171
+ if "out of memory" not in str(exc).lower():
172
+ raise
173
+ oom_retries += 1
174
+ opsd_debug.log(
175
+ "teacher_forward_oom",
176
+ "teacher OPSD forward OOM, clearing cache and retrying",
177
+ micro_batch_size=teacher_input.shape[0],
178
+ oom_retries=oom_retries,
179
+ )
180
+ if torch.cuda.is_available():
181
+ torch.cuda.empty_cache()
182
+ if oom_retries >= 3:
183
+ raise
184
+
185
+
186
+ def slice_student_completion_logits(full_logits: torch.Tensor, logits_to_keep: int) -> torch.Tensor:
187
+ """Completion-token logits aligned with ``_get_per_token_logps`` / OPSD JSD."""
188
+ logits = full_logits[:, -logits_to_keep - 1 :, :]
189
+ logits = logits[:, :-1, :]
190
+ return logits[:, -logits_to_keep:, :]
191
+
192
+
193
+ def compute_vlm_opsd_loss(
194
+ model,
195
+ student_prompt_ids,
196
+ student_prompt_mask,
197
+ student_pixel_values,
198
+ student_image_sizes,
199
+ teacher_prompt_ids,
200
+ teacher_prompt_mask,
201
+ teacher_pixel_values,
202
+ completion_ids,
203
+ completion_mask,
204
+ beta=0.5,
205
+ teacher_image_sizes=None,
206
+ processor=None,
207
+ teacher_batch_num_images=None,
208
+ teacher_model=None,
209
+ global_idx: int | None = None,
210
+ capture_jsd_detail: bool = False,
211
+ tokenizer=None,
212
+ student_logits=None,
213
+ ) -> torch.Tensor:
214
+ """
215
+ OPSD / OPD: student vs teacher prompt, shared student completion.
216
+ When teacher_model is set, cross-model OPD (e.g. frozen 7B teacher); else self-OPSD.
217
+ """
218
+ teacher_model = teacher_model if teacher_model is not None else model
219
+ opsd_debug.log(
220
+ "opsd_loss",
221
+ "compute_vlm_opsd_loss enter",
222
+ beta=beta,
223
+ student_prompt_shape=tuple(student_prompt_ids.shape),
224
+ teacher_prompt_shape=tuple(teacher_prompt_ids.shape),
225
+ completion_shape=tuple(completion_ids.shape),
226
+ has_teacher_pixel_values=teacher_pixel_values is not None,
227
+ teacher_pixel_values_shape=(
228
+ tuple(teacher_pixel_values.shape) if teacher_pixel_values is not None else None
229
+ ),
230
+ )
231
+ student_batch_num_images = student_batch_num_images_tensor(
232
+ student_pixel_values, student_prompt_ids.shape[0]
233
+ )
234
+ if processor is not None and student_pixel_values is not None:
235
+ student_prompt_ids, student_prompt_mask = align_teacher_prompt_image_tokens(
236
+ model,
237
+ processor,
238
+ student_prompt_ids,
239
+ student_prompt_mask,
240
+ student_pixel_values,
241
+ student_image_sizes,
242
+ batch_num_images=student_batch_num_images,
243
+ )
244
+
245
+ student_input = torch.cat([student_prompt_ids, completion_ids], dim=1)
246
+ student_attn = torch.cat([student_prompt_mask, completion_mask], dim=1)
247
+
248
+ logits_to_keep = completion_ids.size(1)
249
+
250
+ if student_logits is None:
251
+ with opsd_debug.timed("opsd_loss", "student forward (grad)"):
252
+ student_logits = model(
253
+ input_ids=student_input,
254
+ attention_mask=student_attn,
255
+ pixel_values=student_pixel_values,
256
+ image_sizes=student_image_sizes,
257
+ batch_num_images=student_batch_num_images,
258
+ ).logits[:, -logits_to_keep - 1 : -1, :]
259
+ else:
260
+ opsd_debug.log(
261
+ "opsd_loss",
262
+ "reuse GRPO student completion logits (DeepSpeed single-forward)",
263
+ student_logits_shape=tuple(student_logits.shape),
264
+ )
265
+
266
+ t_pixel = teacher_pixel_values if teacher_pixel_values is not None else student_pixel_values
267
+ t_sizes = teacher_image_sizes if teacher_image_sizes is not None else student_image_sizes
268
+ with opsd_debug.timed("opsd_loss", "teacher forward (no grad)"):
269
+ teacher_logits = _teacher_logits_with_oom_retry(
270
+ teacher_model,
271
+ processor,
272
+ teacher_prompt_ids,
273
+ teacher_prompt_mask,
274
+ completion_ids,
275
+ completion_mask,
276
+ t_pixel,
277
+ t_sizes,
278
+ logits_to_keep,
279
+ teacher_batch_num_images=teacher_batch_num_images,
280
+ )
281
+
282
+ cross_model = teacher_model is not model
283
+ if cross_model:
284
+ opsd_debug.log(
285
+ "opsd_loss",
286
+ "cross-model OPD logits",
287
+ student_vocab=student_logits.size(-1),
288
+ teacher_vocab=teacher_logits.size(-1),
289
+ )
290
+
291
+ loss = generalized_jsd_loss(student_logits, teacher_logits, completion_mask.float(), beta=beta)
292
+
293
+ if capture_jsd_detail and global_idx is not None:
294
+ opsd_diagnostics.maybe_capture_opsd_jsd_detail(
295
+ global_idx=global_idx,
296
+ student_logits=student_logits,
297
+ teacher_logits=teacher_logits,
298
+ completion_mask=completion_mask,
299
+ completion_ids=completion_ids,
300
+ beta=beta,
301
+ tokenizer=tokenizer,
302
+ student_prompt_len=int(student_prompt_mask.sum().item()),
303
+ teacher_prompt_len=int(teacher_prompt_mask.sum().item()),
304
+ )
305
+
306
+ del teacher_logits
307
+ if cross_model and torch.cuda.is_available():
308
+ torch.cuda.empty_cache()
309
+
310
+ opsd_debug.log("opsd_loss", "compute_vlm_opsd_loss done", loss=float(loss.detach().item()))
311
+ return loss
312
+
313
+
314
+ def compute_vlm_opsd_loss_masked_batch(
315
+ model,
316
+ opsd_indices: list[int],
317
+ all_indices: list[int],
318
+ inputs: dict,
319
+ beta: float = 0.5,
320
+ processor=None,
321
+ teacher_model=None,
322
+ acc_gate: bool = True,
323
+ pad_to_count: int | None = None,
324
+ global_step: int | None = None,
325
+ tokenizer=None,
326
+ detail_max_samples: int = 2,
327
+ student_completion_logits=None,
328
+ ) -> torch.Tensor:
329
+ """Compute mean OPSD loss over opsd_indices within a batch.
330
+
331
+ Under DDP every rank must run the *same* number of student/teacher
332
+ forwards, otherwise the per-forward buffer broadcast (and gradient
333
+ reduction) collectives desync across ranks and NCCL eventually times out.
334
+ ``pad_to_count`` is the global-max OPSD sample count across ranks; ranks
335
+ with fewer (or zero) real samples run extra zero-weighted "dummy" forwards
336
+ on a valid local row so the collective sequence stays aligned.
337
+ """
338
+ real_count = len(opsd_indices)
339
+ target_count = pad_to_count if pad_to_count is not None else real_count
340
+ if target_count <= 0:
341
+ opsd_debug.log("opsd_loss", "compute_vlm_opsd_loss_masked_batch skipped (no OPSD samples)")
342
+ return torch.tensor(0.0, device=inputs["prompt_ids"].device, requires_grad=True)
343
+
344
+ opsd_debug.log(
345
+ "opsd_loss",
346
+ "compute_vlm_opsd_loss_masked_batch enter",
347
+ opsd_indices=opsd_indices,
348
+ all_indices=all_indices,
349
+ beta=beta,
350
+ real_count=real_count,
351
+ target_count=target_count,
352
+ )
353
+ capture_jsd_detail = (
354
+ global_step is not None and opsd_debug.should_log_detail(global_step)
355
+ )
356
+ if capture_jsd_detail:
357
+ opsd_diagnostics.begin_opsd_jsd_detail_capture(
358
+ global_step,
359
+ opsd_indices,
360
+ max_samples=detail_max_samples,
361
+ )
362
+ losses = []
363
+ idx_map = {g: i for i, g in enumerate(all_indices)}
364
+ batch_size = inputs["prompt_ids"].shape[0]
365
+ teacher_img_counts = _teacher_image_counts(inputs, batch_size)
366
+
367
+ for step_idx in range(target_count):
368
+ is_real = step_idx < real_count
369
+ # Dummy iterations reuse the first available row so shapes stay valid;
370
+ # their contribution is zeroed out below.
371
+ global_idx = opsd_indices[step_idx] if is_real else all_indices[0]
372
+ local = idx_map[global_idx]
373
+ student_sizes = _slice_image_sizes(inputs.get("img_sizes"), local)
374
+ t_pixel, teacher_sizes = get_teacher_vision_for_sample(
375
+ inputs, local, teacher_img_counts
376
+ )
377
+ if t_pixel is None:
378
+ t_pixel = inputs["pixel_values"][local : local + 1]
379
+ teacher_sizes = student_sizes
380
+ opsd_debug.log(
381
+ "opsd_loss",
382
+ "compute sample OPSD loss",
383
+ global_idx=global_idx,
384
+ local_idx=local,
385
+ teacher_num_images=teacher_img_counts[local],
386
+ student_image_sizes=student_sizes,
387
+ teacher_image_sizes=teacher_sizes,
388
+ teacher_pixel_values_shape=tuple(t_pixel.shape) if t_pixel is not None else None,
389
+ )
390
+ n_img = teacher_img_counts[local]
391
+ teacher_batch_num_images = as_batch_num_images_tensor(n_img, t_pixel)
392
+ if not is_real and deepspeed_requires_single_student_forward():
393
+ # ZeRO-1/2: avoid extra student forwards (even loss*0 still backprops).
394
+ losses.append(torch.zeros((), device=inputs["prompt_ids"].device, requires_grad=True))
395
+ continue
396
+ precomputed_student_logits = None
397
+ if student_completion_logits is not None:
398
+ precomputed_student_logits = student_completion_logits[local : local + 1]
399
+ with opsd_debug.timed("opsd_loss", f"sample_opsd_loss idx={global_idx}"):
400
+ loss = compute_vlm_opsd_loss(
401
+ model,
402
+ inputs["prompt_ids"][local : local + 1],
403
+ inputs["prompt_mask"][local : local + 1],
404
+ inputs["pixel_values"][local : local + 1],
405
+ student_sizes,
406
+ inputs["teacher_prompt_ids"][local : local + 1],
407
+ inputs["teacher_prompt_mask"][local : local + 1],
408
+ t_pixel,
409
+ inputs["completion_ids"][local : local + 1],
410
+ inputs["completion_mask"][local : local + 1],
411
+ beta=beta,
412
+ teacher_image_sizes=teacher_sizes,
413
+ processor=processor,
414
+ teacher_batch_num_images=teacher_batch_num_images,
415
+ teacher_model=teacher_model,
416
+ global_idx=global_idx if is_real else None,
417
+ capture_jsd_detail=capture_jsd_detail and is_real,
418
+ tokenizer=tokenizer,
419
+ student_logits=precomputed_student_logits,
420
+ )
421
+ if not is_real:
422
+ # Keep the autograd graph / DDP collective alive but contribute nothing.
423
+ loss = loss * 0.0
424
+ elif acc_gate and "acc_rewards" in inputs:
425
+ acc_val = float(inputs["acc_rewards"][global_idx].item())
426
+ loss = loss * max(0.0, 1.0 - acc_val)
427
+ losses.append(loss)
428
+
429
+ # Mean over real samples only; dummy (zero-weighted) forwards keep the
430
+ # collective sequence aligned across ranks without skewing the loss scale.
431
+ mean_loss = torch.stack(losses).sum() / max(real_count, 1)
432
+ opsd_debug.log(
433
+ "opsd_loss",
434
+ "compute_vlm_opsd_loss_masked_batch done",
435
+ mean_loss=float(mean_loss.detach().item()),
436
+ real_count=real_count,
437
+ target_count=target_count,
438
+ )
439
+ return mean_loss
opsd_utils/privileged/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (707 Bytes). View file
 
opsd_utils/privileged/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (436 Bytes). View file
 
opsd_utils/privileged/__pycache__/base.cpython-310.pyc ADDED
Binary file (1.08 kB). View file
 
opsd_utils/privileged/__pycache__/base.cpython-312.pyc ADDED
Binary file (1.18 kB). View file
 
opsd_utils/privileged/__pycache__/image_utils.cpython-310.pyc ADDED
Binary file (4.03 kB). View file
 
opsd_utils/privileged/__pycache__/providers.cpython-312.pyc ADDED
Binary file (4.56 kB). View file
 
opsd_utils/privileged/__pycache__/registry.cpython-310.pyc ADDED
Binary file (3.52 kB). View file
 
opsd_utils/privileged/__pycache__/registry.cpython-312.pyc ADDED
Binary file (2.18 kB). View file
 
opsd_utils/privileged/debug_artifacts.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Save privileged teacher images to disk on detail steps."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from typing import Any, Optional
7
+
8
+ from PIL import Image
9
+
10
+ from opsd_utils import debug_log as opsd_debug
11
+
12
+ _saved_counts: dict[int, int] = {}
13
+ _output_dir: Optional[str] = None
14
+ _cfg: dict[str, Any] = {}
15
+
16
+
17
+ def configure(output_dir: Optional[str] = None, privileged_debug_cfg: Optional[dict[str, Any]] = None) -> None:
18
+ global _output_dir, _cfg
19
+ _output_dir = output_dir
20
+ _cfg = dict(privileged_debug_cfg or {})
21
+ _saved_counts.clear()
22
+
23
+
24
+ def _image_subdir() -> str:
25
+ return _cfg.get("image_subdir", "logs/images")
26
+
27
+
28
+ def maybe_save_privileged_images(
29
+ global_step: Optional[int],
30
+ sample_idx: int,
31
+ full_img: Optional[Image.Image],
32
+ crop_img: Optional[Image.Image],
33
+ meta: Optional[dict[str, Any]] = None,
34
+ output_dir: Optional[str] = None,
35
+ privileged_debug_cfg: Optional[dict[str, Any]] = None,
36
+ ) -> Optional[str]:
37
+ """
38
+ Save teacher privileged images when should_log_detail(global_step) is true.
39
+ Returns base path prefix if saved, else None.
40
+ """
41
+ if global_step is None:
42
+ return None
43
+ if not opsd_debug.should_log_detail(global_step):
44
+ return None
45
+
46
+ cfg = privileged_debug_cfg if privileged_debug_cfg is not None else _cfg
47
+ if not cfg.get("save_images", True):
48
+ return None
49
+
50
+ max_samples = int(cfg.get("max_samples_per_detail", 2))
51
+ count = _saved_counts.get(global_step, 0)
52
+ if count >= max_samples:
53
+ return None
54
+
55
+ base_out = output_dir or _output_dir
56
+ if not base_out:
57
+ opsd_debug.log(
58
+ "privileged_debug",
59
+ "skip image save (no output_dir)",
60
+ global_step=global_step,
61
+ sample_idx=sample_idx,
62
+ )
63
+ return None
64
+
65
+ subdir = os.path.join(base_out, _image_subdir() if cfg is _cfg else cfg.get("image_subdir", "logs/images"))
66
+ os.makedirs(subdir, exist_ok=True)
67
+
68
+ prefix = os.path.join(subdir, f"step_{int(global_step):06d}_idx_{sample_idx}")
69
+ saved_paths: list[str] = []
70
+
71
+ if full_img is not None:
72
+ full_path = f"{prefix}_full.png"
73
+ full_img.save(full_path)
74
+ saved_paths.append(full_path)
75
+
76
+ if crop_img is not None:
77
+ crop_path = f"{prefix}_crop.png"
78
+ crop_img.save(crop_path)
79
+ saved_paths.append(crop_path)
80
+
81
+ meta_path = f"{prefix}_meta.json"
82
+ meta_payload = dict(meta or {})
83
+ meta_payload.update(
84
+ {
85
+ "global_step": global_step,
86
+ "sample_idx": sample_idx,
87
+ "saved_paths": saved_paths,
88
+ }
89
+ )
90
+ with open(meta_path, "w", encoding="utf-8") as f:
91
+ json.dump(meta_payload, f, ensure_ascii=False, indent=2)
92
+
93
+ _saved_counts[global_step] = count + 1
94
+ opsd_debug.log_detail(
95
+ "privileged_debug",
96
+ "privileged images saved",
97
+ global_step=global_step,
98
+ sample_idx=sample_idx,
99
+ prefix=prefix,
100
+ saved_paths=saved_paths,
101
+ meta_path=meta_path,
102
+ **{k: v for k, v in (meta or {}).items() if k not in ("full_size", "crop_size")},
103
+ )
104
+ return prefix
opsd_utils/privileged/providers.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ from PIL import Image
4
+
5
+ from data_utils.chart.deplot_pipeline import format_deplot_for_teacher, is_deplot_placeholder
6
+ from data_utils.privileged_schema import parse_visual_fact
7
+ from opsd_utils.privileged.base import PrivilegedContextProvider
8
+ from opsd_utils.privileged.image_utils import heuristic_crop_from_visual_fact, load_rgb
9
+
10
+
11
+ DEFAULT_FORMAT_ONLY_HINT = (
12
+ "Use the following structure in your response:\n"
13
+ "Goal: ...\nObservation: ...\nReasoning: ...\nAnswer: ..."
14
+ )
15
+
16
+
17
+ class FormatOnlyProvider(PrivilegedContextProvider):
18
+ """Structure hint only — no gold answer or reference reasoning (anti-leakage)."""
19
+
20
+ def __init__(self, hint_text: str | None = None):
21
+ self._hint_text = (hint_text or DEFAULT_FORMAT_ONLY_HINT).strip()
22
+
23
+ def build_teacher_suffix(self, sample: dict[str, Any]) -> str:
24
+ return self._hint_text
25
+
26
+
27
+ class TextProvider(PrivilegedContextProvider):
28
+ def __init__(self, include_gold: bool = True):
29
+ self.include_gold = include_gold
30
+
31
+ def build_teacher_suffix(self, sample: dict[str, Any]) -> str:
32
+ if not self.include_gold:
33
+ return ""
34
+ parts = []
35
+ hint = (sample.get("hint") or "").strip()
36
+ answer = (sample.get("answer") or "").strip()
37
+ if hint:
38
+ parts.append(f"[Reference Reasoning]\n{hint}")
39
+ if answer:
40
+ parts.append(f"[Reference Answer]\n{answer}")
41
+ return "\n\n".join(parts)
42
+
43
+
44
+ class VisualFactsProvider(PrivilegedContextProvider):
45
+ """B1: raw JSON visual facts; F1+F2 merge hint and deplot sources."""
46
+
47
+ def _collect_visual_fact_parts(self, sample: dict[str, Any]) -> list[str]:
48
+ parts: list[str] = []
49
+ hint_vf = sample.get("visual_fact_hint")
50
+ if hint_vf:
51
+ text = parse_visual_fact(hint_vf)
52
+ if text:
53
+ parts.append(f"[Visual Facts - Hint]\n{text}")
54
+
55
+ deplot_vf = sample.get("visual_fact_deplot")
56
+ if deplot_vf and not is_deplot_placeholder(deplot_vf):
57
+ text = format_deplot_for_teacher(deplot_vf)
58
+ if text:
59
+ parts.append(f"[Visual Facts - DePlot]\n{text}")
60
+
61
+ primary = sample.get("visual_fact") or sample.get("visual_facts")
62
+ if primary and not (hint_vf or deplot_vf):
63
+ text = parse_visual_fact(primary)
64
+ if text:
65
+ parts.append(f"[Visual Facts]\n{text}")
66
+ elif primary and (hint_vf or deplot_vf):
67
+ text = parse_visual_fact(primary)
68
+ if text:
69
+ parts.append(f"[Visual Facts - Combined]\n{text}")
70
+
71
+ return parts
72
+
73
+ def build_teacher_suffix(self, sample: dict[str, Any]) -> str:
74
+ parts = self._collect_visual_fact_parts(sample)
75
+ return "\n\n".join(parts)
76
+
77
+
78
+ class CropProvider(PrivilegedContextProvider):
79
+ """Returns evidence crop as second teacher image (dual-image path uses image_utils)."""
80
+
81
+ def build_teacher_suffix(self, sample: dict[str, Any]) -> str:
82
+ return ""
83
+
84
+ def build_teacher_images(self, sample: dict[str, Any], crop_cfg: dict[str, Any] | None = None) -> list[Image.Image]:
85
+ image = sample.get("image")
86
+ if image is None:
87
+ return []
88
+ full = load_rgb(image)
89
+ if full is None:
90
+ return []
91
+ crop, _, _ = heuristic_crop_from_visual_fact(full, sample, crop_cfg)
92
+ return [crop]
93
+
94
+
95
+ class HybridProvider(PrivilegedContextProvider):
96
+ def __init__(
97
+ self,
98
+ provider_names: list[str],
99
+ crop_cfg: dict[str, Any] | None = None,
100
+ *,
101
+ text_include_gold: bool = True,
102
+ format_only_hint: str | None = None,
103
+ ):
104
+ self._providers: list[PrivilegedContextProvider] = []
105
+ self._crop_cfg = crop_cfg or {}
106
+ for name in provider_names:
107
+ if name == "text":
108
+ self._providers.append(TextProvider(include_gold=text_include_gold))
109
+ elif name == "format_only":
110
+ self._providers.append(FormatOnlyProvider(format_only_hint))
111
+ elif name == "visual_facts":
112
+ self._providers.append(VisualFactsProvider())
113
+ elif name == "crop":
114
+ self._providers.append(CropProvider())
115
+
116
+ def build_teacher_suffix(self, sample: dict[str, Any]) -> str:
117
+ chunks = [p.build_teacher_suffix(sample) for p in self._providers]
118
+ chunks = [c for c in chunks if c.strip()]
119
+ return "\n\n".join(chunks)
120
+
121
+ def build_teacher_images(self, sample: dict[str, Any]) -> list[Image.Image]:
122
+ for p in self._providers:
123
+ if isinstance(p, CropProvider):
124
+ imgs = p.build_teacher_images(sample, self._crop_cfg)
125
+ if imgs:
126
+ return imgs
127
+ return []