Buckets:
| """Visual-OPSD (offline) — Trace Collection. | |
| Caches teacher top-K logprobs and VT-sensitivity weights so that the | |
| offline trainer (``train_visual_opsd_offline.py``) can use cheap KD | |
| without re-running the teacher every step. | |
| For each training sample, runs ThinkMorph-7B in two modes: | |
| Teacher: [input_image_ViT, question, thought₁, VT_image_ViT, thought₂, answer] | |
| No-VT: [input_image_ViT, question, full_text_thought, answer] | |
| Caches per-sample: | |
| - Top-256 teacher logprobs (float16) [num_ce_tokens, 256] | |
| - Top-256 token indices (int32) [num_ce_tokens, 256] | |
| - VT-sensitivity weights (float16) [num_ce_tokens] | |
| - Answer CE span length int | |
| Usage: | |
| torchrun --nproc_per_node=4 scripts/visual_opsd/collect_traces.py \ | |
| --model_path models/ThinkMorph-7B \ | |
| --output_dir traces/visual_opsd_teacher_cache \ | |
| --top_k 256 | |
| """ | |
| import argparse | |
| import io | |
| 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): | |
| 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 build_sequence( | |
| model, tokenizer, new_token_ids, vit_transform, | |
| problem_image, question, thoughts, vt_images, answer, | |
| include_vt=True, full_text_thought=None, | |
| ): | |
| 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"] | |
| 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(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_pil): | |
| nonlocal curr, curr_rope_id | |
| image_tensor = vit_transform(pil_img2rgb(image_pil)) | |
| vit_tokens = patchify(image_tensor, 14) | |
| n = 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 + n)) | |
| packed_vit_tokens_list.append(vit_tokens) | |
| vit_token_seqlens.append(n) | |
| packed_vit_position_ids_list.append( | |
| get_flattened_position_ids_extrapolate( | |
| image_tensor.size(1), image_tensor.size(2), 14, max_num_patches_per_side=70 | |
| ) | |
| ) | |
| curr += n | |
| packed_text_ids.append(end_of_image) | |
| packed_text_indexes.append(curr) | |
| curr += 1 | |
| seg_len = n + 2 | |
| split_lens.append(seg_len) | |
| attn_modes.append("full") | |
| packed_position_ids.extend([curr_rope_id] * seg_len) | |
| curr_rope_id += 1 | |
| add_vit(problem_image) | |
| add_text(tokenizer.encode(question), has_loss=False) | |
| if include_vt: | |
| for i, thought in enumerate(thoughts): | |
| add_text(tokenizer.encode(thought), has_loss=True) | |
| if i < len(vt_images) and vt_images[i] is not None: | |
| add_vit(vt_images[i]) | |
| else: | |
| txt = full_text_thought if full_text_thought else " ".join(thoughts) | |
| add_text(tokenizer.encode(txt), has_loss=True) | |
| answer_ce_start = len(ce_loss_indexes) | |
| add_text(tokenizer.encode(answer), has_loss=True) | |
| answer_ce_len = len(ce_loss_indexes) - answer_ce_start | |
| device = next(model.parameters()).device | |
| attn_mask = prepare_attention_mask_per_sample(split_lens, attn_modes, device=device) | |
| data = { | |
| "sequence_length": curr, | |
| "sample_lens": [curr], | |
| "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 process_sample(model, tokenizer, new_token_ids, vit_transform, row, top_k=256): | |
| """Process one sample: get teacher logprobs and VT-sensitivity weights.""" | |
| problem_image = Image.open(io.BytesIO(row["problem_image_0"]["bytes"])).convert("RGB") | |
| thoughts = [] | |
| vt_images = [] | |
| if row.get("resoning_thought_0"): | |
| thoughts.append(row["resoning_thought_0"]) | |
| if row.get("reasoning_image_0"): | |
| vt_images.append(Image.open(io.BytesIO(row["reasoning_image_0"]["bytes"])).convert("RGB")) | |
| if row.get("resoning_thought_1"): | |
| thoughts.append(row["resoning_thought_1"]) | |
| answer = row.get("answer", "") | |
| question = row.get("question", "") | |
| full_text = row.get("full_text_only_thought", None) | |
| if not thoughts or not answer: | |
| return None | |
| teacher_data = build_sequence( | |
| model, tokenizer, new_token_ids, vit_transform, | |
| problem_image, question, thoughts, vt_images, answer, | |
| include_vt=True, | |
| ) | |
| novt_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, | |
| ) | |
| answer_ce_len_t = teacher_data.pop("_answer_ce_len") | |
| answer_ce_len_s = novt_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) | |
| novt_out = model(**novt_data, return_logits=True) | |
| torch.cuda.synchronize() | |
| model.eval() | |
| if teacher_out["logits"] is None or novt_out["logits"] is None: | |
| return None | |
| # teacher_logits和novt_logits的形状通常都是 [T, V],其中T是序列长度(token数),V是词表大小(vocab size)。 | |
| # 也就是说,每一行对应一个token位置,每一列是词表中对应词的logits。 | |
| teacher_logits = teacher_out["logits"].float() | |
| novt_logits = novt_out["logits"].float() | |
| # Align from end (answer tokens match at tail) | |
| min_len = min(teacher_logits.shape[0], novt_logits.shape[0]) | |
| if min_len == 0: | |
| return None | |
| t_logits = teacher_logits[-min_len:] | |
| n_logits = novt_logits[-min_len:] | |
| # Top-K teacher logprobs | |
| t_log_probs = F.log_softmax(t_logits, dim=-1) | |
| topk_logprobs, topk_indices = torch.topk(t_log_probs, k=top_k, dim=-1) | |
| # VT-sensitivity weights: per-token KL(teacher_with_VT || no_VT) | |
| t_probs = F.softmax(t_logits, dim=-1) | |
| n_log_probs = F.log_softmax(n_logits, dim=-1) | |
| vt_weights = F.kl_div(n_log_probs, t_probs, reduction="none").sum(dim=-1) | |
| return { | |
| "teacher_top_logprobs": topk_logprobs.half().cpu(), # [min_len, top_k] | |
| "teacher_top_indices": topk_indices.int().cpu(), # [min_len, top_k] | |
| "vt_weights": vt_weights.half().cpu(), # [min_len] | |
| "num_aligned_tokens": min_len, | |
| "answer_ce_len": min(answer_ce_len_t, answer_ce_len_s), | |
| "teacher_ce_tokens": teacher_logits.shape[0], | |
| "student_ce_tokens": novt_logits.shape[0], | |
| } | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Visual-OPSD Trace Collection") | |
| parser.add_argument("--model_path", type=str, | |
| default="models/ThinkMorph-7B") | |
| parser.add_argument("--data_root", type=str, default="datasets") | |
| parser.add_argument("--output_dir", type=str, default="traces/visual_opsd_teacher_cache") | |
| parser.add_argument("--top_k", type=int, default=256) | |
| parser.add_argument("--batch_save_size", type=int, default=100, | |
| help="Save traces in batches of this size") | |
| parser.add_argument("--no_resume", action="store_true", | |
| help="Disable resume — reprocess all samples from scratch") | |
| 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) | |
| # Load all training data paths | |
| datasets = ["Visual_Search", "Spatial_Navigation", "Jigsaw_Assembly", "Chart_Refocus"] | |
| all_samples = [] | |
| for ds_name in datasets: | |
| data_dir = os.path.join(args.data_root, ds_name, "data") | |
| if not os.path.exists(data_dir): | |
| continue | |
| for f in sorted(os.listdir(data_dir)): | |
| if f.endswith(".parquet") and "train" in f: | |
| all_samples.append((ds_name, os.path.join(data_dir, f))) | |
| # Flatten: list of (ds_name, file_path, row_idx) | |
| sample_list = [] | |
| for ds_name, fpath in all_samples: | |
| table = pq.read_table(fpath) | |
| for i in range(len(table)): | |
| sample_list.append((ds_name, fpath, i)) | |
| print(f"Total samples: {len(sample_list)}") | |
| # --- Resume: scan THIS rank's existing traces to find already-collected keys --- | |
| # Keys are "{ds_name}/{pid}" (composite) to avoid cross-dataset PID collision. | |
| # For backward compat, old plain-PID keys are also loaded as-is. | |
| done_keys = set() | |
| next_batch_num = 1 | |
| if not args.no_resume: | |
| my_prefix = f"rank{local_rank}_" | |
| for fname in sorted(os.listdir(args.output_dir)): | |
| if not fname.endswith(".pt") or fname == "index.pt": | |
| continue | |
| if not fname.startswith(my_prefix): | |
| continue | |
| try: | |
| data = torch.load(os.path.join(args.output_dir, fname), | |
| map_location="cpu", weights_only=False) | |
| done_keys.update(data.keys()) | |
| except Exception as e: | |
| print(f" [Rank {local_rank}] Warning: corrupt cache file {fname}: {e}") | |
| continue | |
| if "_batch" in fname: | |
| try: | |
| b = int(fname.replace(".pt", "").split("_batch")[1]) | |
| next_batch_num = max(next_batch_num, b + 1) | |
| except ValueError: | |
| pass | |
| print(f"[Rank {local_rank}] Resume: {len(done_keys)} keys cached, " | |
| f"next batch = {next_batch_num}") | |
| # Distribute across ranks | |
| per_rank = len(sample_list) // max(world_size, 1) | |
| start = local_rank * per_rank | |
| end = start + per_rank if local_rank < world_size - 1 else len(sample_list) | |
| my_samples = sample_list[start:end] | |
| print(f"[Rank {local_rank}] Total samples: {len(sample_list)}, " | |
| f"this rank: {len(my_samples)}") | |
| # Process and save in batches | |
| batch_traces = {} | |
| saved_count = 0 | |
| skipped = 0 | |
| resumed = 0 | |
| file_cache = {} | |
| batch_num = next_batch_num | |
| for idx, (ds_name, fpath, row_idx) in enumerate(my_samples): | |
| if idx % 50 == 0: | |
| print(f"[Rank {local_rank}] {idx}/{len(my_samples)} " | |
| f"(saved={saved_count}, skipped={skipped}, resumed={resumed})") | |
| if fpath not in file_cache: | |
| file_cache[fpath] = pq.read_table(fpath) | |
| table = file_cache[fpath] | |
| row = {col: table.column(col)[row_idx].as_py() for col in table.column_names} | |
| pid = str(row.get("pid", f"{ds_name}_{row_idx}")) | |
| key = f"{ds_name}/{pid}" | |
| if key in done_keys or pid in done_keys: | |
| resumed += 1 | |
| continue | |
| try: | |
| result = process_sample(model, tokenizer, new_token_ids, vit_transform, row, args.top_k) | |
| except torch.cuda.OutOfMemoryError: | |
| torch.cuda.empty_cache() | |
| skipped += 1 | |
| continue | |
| except Exception as e: | |
| if idx < 10: | |
| print(f" [Rank {local_rank}] Error on {key}: {e}") | |
| skipped += 1 | |
| continue | |
| if result is None: | |
| skipped += 1 | |
| continue | |
| batch_traces[key] = result | |
| saved_count += 1 | |
| if len(batch_traces) >= args.batch_save_size: | |
| batch_file = os.path.join( | |
| args.output_dir, f"rank{local_rank}_batch{batch_num}.pt" | |
| ) | |
| torch.save(batch_traces, batch_file) | |
| batch_traces = {} | |
| batch_num += 1 | |
| # Save remaining | |
| if batch_traces: | |
| batch_file = os.path.join(args.output_dir, f"rank{local_rank}_resume_final.pt") | |
| torch.save(batch_traces, batch_file) | |
| print(f"[Rank {local_rank}] Trace collection complete: " | |
| f"saved={saved_count}, skipped={skipped}, resumed={resumed}") | |
| # Build index on rank 0 | |
| if world_size > 1: | |
| dist.barrier() | |
| if local_rank == 0: | |
| index = {} | |
| for f in sorted(os.listdir(args.output_dir)): | |
| if not f.endswith(".pt") or f == "index.pt": | |
| continue | |
| data = torch.load(os.path.join(args.output_dir, f), weights_only=False) | |
| for key in data: | |
| index[key] = f | |
| torch.save(index, os.path.join(args.output_dir, "index.pt")) | |
| print(f"Index built: {len(index)} samples across {len(set(index.values()))} files") | |
| total_bytes = sum( | |
| os.path.getsize(os.path.join(args.output_dir, f)) | |
| for f in os.listdir(args.output_dir) if f.endswith(".pt") | |
| ) | |
| print(f"Total cache size: {total_bytes / 1e9:.2f} GB") | |
| if world_size > 1: | |
| dist.destroy_process_group() | |
| if __name__ == "__main__": | |
| main() | |
Xet Storage Details
- Size:
- 17.4 kB
- Xet hash:
- 6f393bf62425d5ae3eb6b83256c281cd651d2106a483ab12d445fff7c013a2cc
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.