Buckets:
| """Visual-OPSD Phase 0: KL Diagnostic — GO/NO-GO gate for Visual-OPSD | |
| training. | |
| Measures the distributional gap between teacher (with VT images) and | |
| student (without VT images) using frozen ThinkMorph-7B weights. | |
| For each sample: | |
| Teacher: [input_image_ViT, question, thought₁, VT_image_ViT, thought₂, answer] | |
| Student: [input_image_ViT, question, full_text_thought, answer] | |
| Computes per-token KL divergence at text positions. | |
| GO condition: answer_span_kl > 0.05 nats. | |
| Usage: | |
| torchrun --nproc_per_node=4 scripts/visual_opsd/kl_diagnostic.py \ | |
| --model_path models/ThinkMorph-7B \ | |
| --num_samples 1000 \ | |
| --output_dir results/visual_opsd_kl_diagnostic | |
| """ | |
| import argparse | |
| import io | |
| import json | |
| import os | |
| import sys | |
| import numpy as np | |
| import pyarrow.parquet as pq | |
| import torch | |
| import torch.distributed as dist | |
| import torch.nn.functional as F | |
| from PIL import Image | |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) | |
| from data.data_utils import add_special_tokens, patchify, pil_img2rgb | |
| from data.data_utils import get_flattened_position_ids_extrapolate, prepare_attention_mask_per_sample | |
| from data.transforms import ImageTransform | |
| from modeling.bagel import ( | |
| BagelConfig, Bagel, Qwen2Config, Qwen2ForCausalLM, | |
| SiglipVisionConfig, SiglipVisionModel, | |
| ) | |
| from modeling.qwen2 import Qwen2Tokenizer | |
| def load_model(model_path, device): | |
| """Load ThinkMorph-7B model.""" | |
| llm_config = Qwen2Config.from_json_file(os.path.join(model_path, "llm_config.json")) | |
| llm_config.layer_module = "Qwen2MoTDecoderLayer" | |
| llm_config.qk_norm = True | |
| llm_config.tie_word_embeddings = False | |
| llm_config.freeze_und = False | |
| language_model = Qwen2ForCausalLM(llm_config) | |
| vit_config = SiglipVisionConfig.from_json_file(os.path.join(model_path, "vit_config.json")) | |
| vit_config.num_hidden_layers = vit_config.num_hidden_layers + 1 + (-2) | |
| vit_config.rope = False | |
| vit_model = SiglipVisionModel(vit_config) | |
| config = BagelConfig( | |
| visual_gen=False, | |
| visual_und=True, | |
| llm_config=llm_config, | |
| vit_config=vit_config, | |
| vae_config=None, | |
| latent_patch_size=2, | |
| max_latent_size=64, | |
| vit_max_num_patch_per_side=70, | |
| connector_act="gelu_pytorch_tanh", | |
| interpolate_pos=False, | |
| timestep_shift=1.0, | |
| ) | |
| model = Bagel(language_model, vit_model, config) | |
| model.vit_model.vision_model.embeddings.convert_conv2d_to_linear(vit_config) | |
| tokenizer = Qwen2Tokenizer.from_pretrained(model_path) | |
| tokenizer, new_token_ids, num_new_tokens = add_special_tokens(tokenizer) | |
| if num_new_tokens > 0: | |
| model.language_model.resize_token_embeddings(len(tokenizer)) | |
| model.config.llm_config.vocab_size = len(tokenizer) | |
| model.language_model.config.vocab_size = len(tokenizer) | |
| from safetensors.torch import load_file | |
| ckpt_files = sorted([f for f in os.listdir(model_path) if f.endswith(".safetensors") and f != "ae.safetensors"]) | |
| state_dict = {} | |
| for f in ckpt_files: | |
| state_dict.update(load_file(os.path.join(model_path, f))) | |
| model.load_state_dict(state_dict, strict=False) | |
| model = model.to(device).to(torch.bfloat16).eval() | |
| return model, tokenizer, new_token_ids | |
| def load_diagnostic_samples(data_root, num_samples, seed=42): | |
| """Load held-out diagnostic samples (deterministic by pid hash). | |
| Uses a hash-based split to create a reproducible held-out set that | |
| won't overlap with training data. | |
| """ | |
| import hashlib | |
| datasets = ["Visual_Search", "Spatial_Navigation", "Jigsaw_Assembly", "Chart_Refocus"] | |
| candidates = [] | |
| for ds_name in datasets: | |
| data_dir = os.path.join(data_root, ds_name, "data") | |
| if not os.path.exists(data_dir): | |
| continue | |
| for f in sorted(os.listdir(data_dir)): | |
| if not (f.endswith(".parquet") and "train" in f): | |
| continue | |
| table = pq.read_table(os.path.join(data_dir, f)) | |
| for i in range(len(table)): | |
| pid = str(table.column("pid")[i].as_py()) | |
| h = int(hashlib.md5(pid.encode()).hexdigest(), 16) % 100 | |
| if h < 5: # 5% held-out partition | |
| candidates.append((ds_name, os.path.join(data_dir, f), i)) | |
| rng = np.random.RandomState(seed) | |
| rng.shuffle(candidates) | |
| candidates = candidates[:num_samples] | |
| samples = [] | |
| for ds_name, fpath, idx in candidates: | |
| table = pq.read_table(fpath) | |
| row = {col: table.column(col)[idx].as_py() for col in table.column_names} | |
| row["_dataset"] = ds_name | |
| samples.append(row) | |
| return samples | |
| def build_sequence( | |
| model, tokenizer, new_token_ids, vit_transform, | |
| problem_image, question, thoughts, vt_images, answer, | |
| include_vt=True, full_text_thought=None, | |
| ): | |
| """Build a packed sequence for a single sample. | |
| Args: | |
| include_vt: If True, build teacher sequence (with VT images). | |
| If False, build student sequence (no VT, but keep input image). | |
| full_text_thought: Curated text-only thought for student path. | |
| Returns: | |
| dict with packed tensors ready for model.forward(), plus answer_ce_len. | |
| """ | |
| bos_id = new_token_ids["bos_token_id"] | |
| eos_id = new_token_ids["eos_token_id"] | |
| start_of_image = new_token_ids["start_of_image"] | |
| end_of_image = new_token_ids["end_of_image"] | |
| vit_patch_size = 14 | |
| max_patches_per_side = 70 | |
| curr = 0 | |
| packed_text_ids = [] | |
| packed_text_indexes = [] | |
| packed_position_ids = [] | |
| packed_label_ids = [] | |
| ce_loss_indexes = [] | |
| packed_vit_tokens_list = [] | |
| packed_vit_position_ids_list = [] | |
| packed_vit_token_indexes = [] | |
| vit_token_seqlens = [] | |
| split_lens = [] | |
| attn_modes = [] | |
| curr_rope_id = 0 | |
| def add_text_segment(text_ids, has_loss=False): | |
| nonlocal curr, curr_rope_id | |
| shifted = [bos_id] + text_ids | |
| packed_text_ids.extend(shifted) | |
| packed_text_indexes.extend(range(curr, curr + len(shifted))) | |
| if has_loss: | |
| ce_loss_indexes.extend(range(curr, curr + len(shifted))) | |
| packed_label_ids.extend(text_ids + [eos_id]) | |
| curr += len(shifted) | |
| packed_text_ids.append(eos_id) | |
| packed_text_indexes.append(curr) | |
| curr += 1 | |
| seg_len = len(shifted) + 1 | |
| split_lens.append(seg_len) | |
| attn_modes.append("causal") | |
| packed_position_ids.extend(range(curr_rope_id, curr_rope_id + seg_len)) | |
| curr_rope_id += seg_len | |
| def add_vit_image(image_pil): | |
| nonlocal curr, curr_rope_id | |
| image_tensor = vit_transform(pil_img2rgb(image_pil)) | |
| vit_tokens = patchify(image_tensor, vit_patch_size) | |
| num_tokens = vit_tokens.shape[0] | |
| packed_text_ids.append(start_of_image) | |
| packed_text_indexes.append(curr) | |
| curr += 1 | |
| packed_vit_token_indexes.extend(range(curr, curr + num_tokens)) | |
| packed_vit_tokens_list.append(vit_tokens) | |
| vit_token_seqlens.append(num_tokens) | |
| packed_vit_position_ids_list.append( | |
| get_flattened_position_ids_extrapolate( | |
| image_tensor.size(1), image_tensor.size(2), | |
| vit_patch_size, max_num_patches_per_side=max_patches_per_side | |
| ) | |
| ) | |
| curr += num_tokens | |
| packed_text_ids.append(end_of_image) | |
| packed_text_indexes.append(curr) | |
| curr += 1 | |
| seg_len = num_tokens + 2 | |
| split_lens.append(seg_len) | |
| attn_modes.append("full") | |
| packed_position_ids.extend([curr_rope_id] * seg_len) | |
| curr_rope_id += 1 | |
| # 1) Input image (always included) | |
| add_vit_image(problem_image) | |
| # 2) Question (no loss) | |
| q_ids = tokenizer.encode(question) | |
| add_text_segment(q_ids, has_loss=False) | |
| if include_vt: | |
| # Teacher path: interleaved text + VT images | |
| for i, thought in enumerate(thoughts): | |
| t_ids = tokenizer.encode(thought) | |
| add_text_segment(t_ids, has_loss=True) | |
| if i < len(vt_images) and vt_images[i] is not None: | |
| add_vit_image(vt_images[i]) | |
| else: | |
| # Student path: no VT images, input image preserved | |
| full_text = full_text_thought if full_text_thought else " ".join(thoughts) | |
| t_ids = tokenizer.encode(full_text) | |
| add_text_segment(t_ids, has_loss=True) | |
| # 3) Answer | |
| a_ids = tokenizer.encode(answer) | |
| answer_ce_start = len(ce_loss_indexes) | |
| add_text_segment(a_ids, has_loss=True) | |
| answer_ce_len = len(ce_loss_indexes) - answer_ce_start | |
| sequence_length = curr | |
| device = next(model.parameters()).device | |
| attn_mask = prepare_attention_mask_per_sample(split_lens, attn_modes, device=device) | |
| data = { | |
| "sequence_length": sequence_length, | |
| "sample_lens": [sequence_length], | |
| "packed_text_ids": torch.tensor(packed_text_ids, dtype=torch.long, device=device), | |
| "packed_text_indexes": torch.tensor(packed_text_indexes, dtype=torch.long, device=device), | |
| "packed_position_ids": torch.tensor(packed_position_ids, dtype=torch.long, device=device), | |
| "nested_attention_masks": [attn_mask], | |
| "packed_label_ids": torch.tensor(packed_label_ids, dtype=torch.long, device=device), | |
| "ce_loss_indexes": torch.tensor(ce_loss_indexes, dtype=torch.long, device=device), | |
| } | |
| if packed_vit_tokens_list: | |
| data["packed_vit_tokens"] = torch.cat(packed_vit_tokens_list, dim=0).to(device) | |
| data["packed_vit_position_ids"] = torch.cat(packed_vit_position_ids_list, dim=0).to(device) | |
| data["packed_vit_token_indexes"] = torch.tensor(packed_vit_token_indexes, dtype=torch.long, device=device) | |
| data["vit_token_seqlens"] = torch.tensor(vit_token_seqlens, dtype=torch.int, device=device) | |
| data["_answer_ce_len"] = answer_ce_len | |
| return data | |
| def compute_kl_for_sample(model, tokenizer, new_token_ids, vit_transform, row): | |
| """Compute per-token KL between teacher and student for one sample.""" | |
| problem_image = Image.open(io.BytesIO(row["problem_image_0"]["bytes"])).convert("RGB") | |
| thoughts = [] | |
| vt_images = [] | |
| if "resoning_thought_0" in row and row["resoning_thought_0"]: | |
| thoughts.append(row["resoning_thought_0"]) | |
| if "reasoning_image_0" in row and row["reasoning_image_0"]: | |
| vt_images.append(Image.open(io.BytesIO(row["reasoning_image_0"]["bytes"])).convert("RGB")) | |
| if "resoning_thought_1" in row and row["resoning_thought_1"]: | |
| thoughts.append(row["resoning_thought_1"]) | |
| answer = row.get("answer", "") | |
| question = row.get("question", "") | |
| full_text_thought = row.get("full_text_only_thought", None) | |
| if not thoughts or not answer: | |
| return None | |
| try: | |
| teacher_data = build_sequence( | |
| model, tokenizer, new_token_ids, vit_transform, | |
| problem_image, question, thoughts, vt_images, answer, | |
| include_vt=True, | |
| ) | |
| student_data = build_sequence( | |
| model, tokenizer, new_token_ids, vit_transform, | |
| problem_image, question, thoughts, vt_images, answer, | |
| include_vt=False, full_text_thought=full_text_thought, | |
| ) | |
| except Exception as e: | |
| print(f"Error building sequence for {row.get('pid', '?')}: {e}") | |
| return None | |
| answer_ce_len = min(teacher_data.pop("_answer_ce_len"), student_data.pop("_answer_ce_len")) | |
| model.train() | |
| with torch.no_grad(), torch.amp.autocast("cuda", dtype=torch.bfloat16): | |
| teacher_out = model(**teacher_data, return_logits=True) | |
| student_out = model(**student_data, return_logits=True) | |
| torch.cuda.synchronize() | |
| model.eval() | |
| if teacher_out["logits"] is None or student_out["logits"] is None: | |
| return None | |
| teacher_logits = teacher_out["logits"].float() | |
| student_logits = student_out["logits"].float() | |
| min_len = min(teacher_logits.shape[0], student_logits.shape[0]) | |
| if min_len == 0: | |
| return None | |
| # Align from the END (answer tokens match at the tail) | |
| t_logits = teacher_logits[-min_len:] | |
| s_logits = student_logits[-min_len:] | |
| t_probs = F.softmax(t_logits, dim=-1) | |
| s_log_probs = F.log_softmax(s_logits, dim=-1) | |
| per_token_kl = F.kl_div(s_log_probs, t_probs, reduction="none").sum(dim=-1) | |
| # Answer span KL using exact CE-position metadata | |
| answer_kl = per_token_kl[-answer_ce_len:].mean().item() if answer_ce_len <= min_len else per_token_kl.mean().item() | |
| return { | |
| "pid": row.get("pid", ""), | |
| "task": row.get("_dataset", ""), | |
| "avg_kl": per_token_kl.mean().item(), | |
| "max_kl": per_token_kl.max().item(), | |
| "answer_span_kl": answer_kl, | |
| "num_text_tokens": min_len, | |
| "answer_ce_len": answer_ce_len, | |
| "teacher_ce_tokens": teacher_logits.shape[0], | |
| "student_ce_tokens": student_logits.shape[0], | |
| "per_token_kl": per_token_kl.cpu().numpy().tolist(), | |
| } | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Visual-OPSD KL Diagnostic") | |
| parser.add_argument("--model_path", type=str, default="models/ThinkMorph-7B") | |
| parser.add_argument("--data_root", type=str, default="datasets") | |
| parser.add_argument("--num_samples", type=int, default=1000) | |
| parser.add_argument("--output_dir", type=str, default="results/visual_opsd_kl_diagnostic") | |
| parser.add_argument("--seed", type=int, default=42) | |
| args = parser.parse_args() | |
| local_rank = int(os.environ.get("LOCAL_RANK", 0)) | |
| world_size = int(os.environ.get("WORLD_SIZE", 1)) | |
| if world_size > 1: | |
| dist.init_process_group("nccl") | |
| device = local_rank % torch.cuda.device_count() | |
| torch.cuda.set_device(device) | |
| else: | |
| device = 0 | |
| torch.cuda.set_device(device) | |
| os.makedirs(args.output_dir, exist_ok=True) | |
| if world_size > 1: | |
| dist.barrier() | |
| if local_rank == 0: | |
| print(f"Loading model from {args.model_path}...") | |
| model, tokenizer, new_token_ids = load_model(args.model_path, device) | |
| vit_transform = ImageTransform(image_stride=14, max_image_size=518, min_image_size=224) | |
| if local_rank == 0: | |
| print(f"Loading {args.num_samples} held-out diagnostic samples from {args.data_root}...") | |
| samples = load_diagnostic_samples(args.data_root, args.num_samples, seed=args.seed) | |
| # Distribute samples across ranks | |
| per_rank = len(samples) // max(world_size, 1) | |
| start_idx = local_rank * per_rank | |
| end_idx = start_idx + per_rank if local_rank < world_size - 1 else len(samples) | |
| my_samples = samples[start_idx:end_idx] | |
| results = [] | |
| for i, row in enumerate(my_samples): | |
| if local_rank == 0 and i % 10 == 0: | |
| print(f"[Rank {local_rank}] Processing {i}/{len(my_samples)}...") | |
| try: | |
| result = compute_kl_for_sample(model, tokenizer, new_token_ids, vit_transform, row) | |
| if result is not None: | |
| results.append(result) | |
| except torch.cuda.OutOfMemoryError: | |
| torch.cuda.empty_cache() | |
| print(f"[Rank {local_rank}] OOM on sample {row.get('pid', '?')}, skipping") | |
| continue | |
| except Exception as e: | |
| print(f"[Rank {local_rank}] Error on sample {row.get('pid', '?')}: {e}") | |
| continue | |
| # Save per-rank results | |
| rank_file = os.path.join(args.output_dir, f"results_rank{local_rank}.json") | |
| with open(rank_file, "w") as f: | |
| json.dump(results, f, indent=2) | |
| if world_size > 1: | |
| dist.barrier() | |
| # Aggregate on rank 0 | |
| if local_rank == 0: | |
| all_results = [] | |
| for r in range(world_size): | |
| rf = os.path.join(args.output_dir, f"results_rank{r}.json") | |
| if os.path.exists(rf): | |
| with open(rf) as f: | |
| all_results.extend(json.load(f)) | |
| if not all_results: | |
| print("NO VALID RESULTS. Cannot proceed.") | |
| return | |
| avg_kls = [r["avg_kl"] for r in all_results] | |
| answer_kls = [r["answer_span_kl"] for r in all_results] | |
| max_kls = [r["max_kl"] for r in all_results] | |
| # Per-task breakdown | |
| tasks = {} | |
| for r in all_results: | |
| t = r["task"] | |
| if t not in tasks: | |
| tasks[t] = {"avg_kl": [], "answer_span_kl": []} | |
| tasks[t]["avg_kl"].append(r["avg_kl"]) | |
| tasks[t]["answer_span_kl"].append(r["answer_span_kl"]) | |
| summary = { | |
| "num_samples": len(all_results), | |
| "overall_avg_kl": float(np.mean(avg_kls)), | |
| "overall_median_kl": float(np.median(avg_kls)), | |
| "overall_std_kl": float(np.std(avg_kls)), | |
| "overall_max_kl": float(np.max(max_kls)), | |
| "answer_span_avg_kl": float(np.mean(answer_kls)), | |
| "answer_span_median_kl": float(np.median(answer_kls)), | |
| "per_task": { | |
| t: { | |
| "n": len(v["avg_kl"]), | |
| "avg_kl": float(np.mean(v["avg_kl"])), | |
| "answer_span_kl": float(np.mean(v["answer_span_kl"])), | |
| } | |
| for t, v in tasks.items() | |
| }, | |
| } | |
| # GO/NO-GO decision | |
| go = summary["answer_span_avg_kl"] > 0.05 | |
| summary["go_nogo"] = "GO" if go else "NO-GO" | |
| summary["threshold"] = 0.05 | |
| with open(os.path.join(args.output_dir, "summary.json"), "w") as f: | |
| json.dump(summary, f, indent=2) | |
| print("\n" + "=" * 60) | |
| print("Visual-OPSD KL DIAGNOSTIC RESULTS") | |
| print("=" * 60) | |
| print(f"Samples processed: {summary['num_samples']}") | |
| print(f"Overall avg KL: {summary['overall_avg_kl']:.4f} nats") | |
| print(f"Answer span KL: {summary['answer_span_avg_kl']:.4f} nats") | |
| print(f"Threshold: {summary['threshold']} nats") | |
| print( | |
| f"Decision: " | |
| f"{'GO — Proceed with Visual-OPSD training' if go else 'NO-GO — VT context does not change predictions'}" | |
| ) | |
| print() | |
| print("Per-task breakdown:") | |
| for t, v in summary["per_task"].items(): | |
| print(f" {t}: n={v['n']}, avg_kl={v['avg_kl']:.4f}, answer_kl={v['answer_span_kl']:.4f}") | |
| print("=" * 60) | |
| if world_size > 1: | |
| dist.destroy_process_group() | |
| if __name__ == "__main__": | |
| main() | |
Xet Storage Details
- Size:
- 18.3 kB
- Xet hash:
- 3164daa9868ac652a75e49e0a71b2f4a629876f280d8c5224b839b9a70a21182
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.