| """ |
| Unified hallucination-removal validation script. |
| |
| Supports any scene→object relation defined in experiment/config/relations.json. |
| Use --relation to select (default: bathroom_toilet). |
| |
| Supported model types (--model_type): |
| lora -> loads base model + LoRA adapter via PeftModel |
| merged -> loads merged HF model via AutoModelForPreTraining |
| delta_w -> loads HookedSAELlavaConditionalGeneration + .pt state dict |
| grace -> loads base model + restores GRACE codebook adapters |
| wise -> loads base model + restores WISE adapter state |
| dualedit -> loads base model + restores DualEdit adapters |
| visedit -> loads editor with trained checkpoint |
| |
| Mention detection (--mention_method): |
| keyword -> fast negation-aware regex (same as old scripts) |
| llm -> local LLM judge only |
| both -> keyword + LLM side-by-side |
| |
| Usage: |
| # LoRA adapter with custom relation |
| python -m experiment.evaluation.validate \ |
| --relation kitchen_microwave \ |
| --model_type lora \ |
| --model_dir step3_lora_v5_outputs/kitchen_microwave/run_xxx/lora_adapter |
| |
| # Default (bathroom_toilet) for backward compat |
| python -m experiment.evaluation.validate \ |
| --model_type lora \ |
| --model_dir step3_lora_outputs/lora_adapter |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import sys |
| import csv |
| import json |
| import math |
| import argparse |
| from pathlib import Path |
|
|
| |
| os.environ["VLLM_USE_V1"] = "0" |
| os.environ.setdefault("NCCL_P2P_DISABLE", "1") |
| os.environ.setdefault("NCCL_IB_DISABLE", "1") |
|
|
| import torch |
|
|
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) |
|
|
| from transformers import AutoProcessor, AutoModelForPreTraining |
| from experiment.config.relation_config import get_relation_config, RelationConfig |
| from experiment.data.datasets import get_split_image_ids |
| from experiment.data.hf_loader import load_hf_dataset |
| from experiment.evaluation.metrics import build_metrics |
| from experiment.evaluation.inference import ( |
| collect_outputs_transformers, |
| collect_outputs_vllm, |
| collect_outputs_visedit, |
| ) |
| from experiment.evaluation.metric import evaluate_collected_outputs, compute_kme_metrics |
| from experiment.evaluation.metrics import TextSimilarityScorer |
| from experiment.evaluation.summary import print_summary |
|
|
| MODEL_NAME = "llava-hf/llava-1.5-7b-hf" |
| dtype = torch.float16 |
|
|
|
|
| def parse_args(): |
| parser = argparse.ArgumentParser(description="Unified hallucination-removal validation") |
|
|
| parser.add_argument("--relation", type=str, default="bathroom_toilet", |
| help="Relation key from relations.json (default: bathroom_toilet)") |
|
|
| parser.add_argument("--val_csv", type=str, default=None, |
| help="(Legacy) Path to CSV. If omitted, loads from HuggingFace.") |
| parser.add_argument("--val_image_dir", type=str, default=None, |
| help="(Legacy) Image directory. If omitted, loads from HuggingFace.") |
| parser.add_argument("--dataset_id", type=str, default=None, |
| help="HuggingFace dataset ID (default: auto from relation config)") |
| parser.add_argument("--num_per_category", type=int, default=50) |
| parser.add_argument("--use_val_split", action="store_true", |
| help="Only use val-split images") |
|
|
| parser.add_argument("--prompts", type=str, nargs="+", |
| default=["Describe this image.", "What do you see in this image?"]) |
| parser.add_argument("--generality_prompts", type=str, nargs="*", |
| default=["Give a detailed description of this image."], |
| help="Unseen prompts for generality evaluation (not used in training)") |
| parser.add_argument("--train_prompts", type=str, nargs="*", |
| default=None, |
| help="Prompts that were used during training (default: from relation config)") |
| parser.add_argument("--max_new_tokens", type=int, default=300) |
|
|
| parser.add_argument("--model_type", type=str, |
| choices=["lora", "merged", "delta_w", "grace", "wise", "dualedit", "visedit"], |
| required=True, |
| help="How to load the finetuned model") |
| parser.add_argument("--base_model_name", type=str, default=MODEL_NAME, |
| help="HuggingFace model name for the base / original model") |
|
|
| group = parser.add_mutually_exclusive_group(required=True) |
| group.add_argument("--model_dir", type=str, |
| help="Path to merged model or LoRA adapter dir (lora / merged)") |
| group.add_argument("--checkpoint", type=str, |
| help="Path to .pt state dict (delta_w)") |
|
|
| parser.add_argument("--mention_method", type=str, |
| choices=["keyword", "llm", "both"], default="both") |
| parser.add_argument("--clip_model", type=str, default="google/siglip-base-patch16-224") |
|
|
| parser.add_argument("--judge_model", type=str, default="Qwen/Qwen3-VL-32B-Instruct") |
| parser.add_argument("--judge_device", type=str, default="cuda", |
| help="Device for judge, e.g. cuda, cuda:1, or cpu") |
| parser.add_argument("--judge_max_tokens", type=int, default=150) |
|
|
| parser.add_argument("--inference_backend", type=str, choices=["transformers", "vllm"], |
| default="transformers") |
| parser.add_argument("--vllm_batch_size", type=int, default=64) |
| parser.add_argument("--vllm_tensor_parallel_size", type=int, default=1) |
| parser.add_argument("--vllm_gpu_memory_utilization", type=float, default=0.9) |
| parser.add_argument("--vllm_max_model_len", type=int, default=4096) |
|
|
| parser.add_argument("--output_dir", type=str, default="./step4_v2_outputs") |
| parser.add_argument("--original_cache_dir", type=str, |
| default="./cached_original_outputs", |
| help="Directory to cache original model outputs for reuse") |
| parser.add_argument("--edit_image_ids", type=str, default=None, |
| help="(deprecated, ignored) Previously pinned BNT eval to specific IDs.") |
| parser.add_argument("--edit_targets", type=str, default=None, |
| help="(visedit only) Path to eval_targets.json {image_id: target_new} " |
| "written by run_visedit.py. Used as correction target for edit signal.") |
| parser.add_argument("--visedit_dir", type=str, default=None, |
| help="(visedit only) Path to VisEdit repo root. " |
| "Defaults to <project_root>/VisEdit.") |
|
|
| return parser.parse_args() |
|
|
|
|
| def load_category_images(relation_config: RelationConfig, |
| num_per_category, use_val_split=False, |
| dataset_id=None, |
| csv_path=None, image_dir=None, |
| edit_image_ids=None): |
| """Load first num_per_category images per category deterministically.""" |
| dataset_id = dataset_id or relation_config.dataset_id |
| scene_col = relation_config.scene_key |
| object_col = relation_config.object_key |
|
|
| categories = {name: [] for name in relation_config.category_names} |
|
|
| if csv_path is not None and image_dir is not None: |
| |
| val_ids = get_split_image_ids(csv_path, "val") if use_val_split else None |
|
|
| with open(csv_path, "r") as f: |
| reader = csv.DictReader(f) |
| for row in reader: |
| if val_ids is not None and row["image_id"] not in val_ids: |
| continue |
| image_path = os.path.join(image_dir, f"{row['image_id']}.jpg") |
| if not os.path.exists(image_path): |
| continue |
| entry = {"path": image_path, "image_id": row["image_id"]} |
| scene_val = int(row.get(scene_col, 0)) |
| object_val = int(row.get(object_col, 0)) |
|
|
| cat = _classify(scene_val, object_val, relation_config) |
| if cat in categories and len(categories[cat]) < num_per_category: |
| categories[cat].append(entry) |
| if all(len(v) >= num_per_category for v in categories.values()): |
| break |
| else: |
| |
| if use_val_split: |
| ds = load_hf_dataset(dataset_id, split="val") |
| else: |
| ds = load_hf_dataset(dataset_id) |
| if hasattr(ds, "keys"): |
| from datasets import concatenate_datasets |
| ds = concatenate_datasets([ds[s] for s in ds]) |
|
|
| for item in ds: |
| scene_val = int(item[scene_col]) |
| object_val = int(item[object_col]) |
| entry = {"image": item["image"], "image_id": item["image_id"]} |
|
|
| cat = _classify(scene_val, object_val, relation_config) |
| if cat in categories and len(categories[cat]) < num_per_category: |
| categories[cat].append(entry) |
| if all(len(v) >= num_per_category for v in categories.values()): |
| break |
|
|
| for cat, imgs in categories.items(): |
| print(f" {cat}: {len(imgs)} images") |
|
|
| return categories |
|
|
|
|
| def _classify(scene_val: int, object_val: int, rc: RelationConfig) -> str: |
| """Classify an image into one of the 4 categories.""" |
| if scene_val == 1 and object_val == 0: |
| return rc.scene_no_object |
| elif scene_val == 1 and object_val == 1: |
| return rc.scene_with_object |
| elif scene_val == 0 and object_val == 1: |
| return rc.non_scene_with_object |
| else: |
| return "unrelated" |
|
|
|
|
| def _is_adapter_dir(path: str) -> bool: |
| |
| if os.path.exists(os.path.join(path, "adapter_config.json")): |
| return True |
| |
| if not os.path.isabs(path) and path.count("/") == 1: |
| return True |
| return False |
|
|
|
|
| def _restore_adapters(model, adapter_states, device): |
| """Reconstruct GRACE/WISE adapter modules from saved state.""" |
| import copy |
|
|
| for module_path, saved in adapter_states.items(): |
| parent_path, attr_name = module_path.rsplit(".", 1) |
| parent = model.get_submodule(parent_path) |
| original_layer = getattr(parent, attr_name) |
| extra = saved["extra"] |
| cfg = extra["config"] |
|
|
| if saved["type"] == "GRACEAdapter": |
| from easyeditor.models.grace.GRACE import GRACEAdapter |
| config = type("Cfg", (), { |
| "eps": cfg["eps"], "dist_fn": cfg["dist_fn"], |
| "replacement": cfg["replacement"], |
| "num_pert": cfg["num_pert"], "dropout": 0.0, |
| "val_init": cfg.get("val_init", "cold"), |
| })() |
| adapter = GRACEAdapter(config, original_layer, transpose=True).to(device) |
| adapter.keys = extra["keys"].to(device) |
| adapter.values = torch.nn.Parameter( |
| saved["state_dict"]["values"].to(device)) |
| adapter.epsilons = extra["epsilons"].to(device) |
| adapter.key_labels = extra["key_labels"] |
| adapter.edit_ids = extra["edit_ids"] |
|
|
| elif saved["type"] == "WISEAdapter": |
| from easyeditor.models.wise.WISE import WISEAdapter |
| config = type("Cfg", (), { |
| "model_name": cfg["model_name"], |
| "retrieve": cfg["retrieve"], |
| "act_ratio": cfg["act_ratio"], |
| "merge_alg": cfg["merge_alg"], |
| "save_freq": cfg.get("save_freq"), |
| "densities": cfg.get("densities"), |
| "weights": cfg.get("weights"), |
| })() |
| adapter = WISEAdapter(config, original_layer, transpose=True).to(device) |
| adapter.new_weight = extra["new_weight"].to(device) |
| adapter.original_layer.load_state_dict(extra["original_layer_state"]) |
| adapter.original_layer = adapter.original_layer.to(device) |
| adapter.memory_weight = [w.to(device) for w in extra["memory_weight"]] |
| adapter.memory_mean_act = extra["memory_mean_act"] |
| adapter.editing_mean_act = extra["editing_mean_act"] |
| |
| adapter.load_state_dict(saved["state_dict"], strict=False) |
| else: |
| raise ValueError(f"Unknown adapter type: {saved['type']}") |
|
|
| setattr(parent, attr_name, adapter) |
|
|
| return model |
|
|
|
|
| def _restore_dualedit(model, state_path: str, device: str): |
| """Reconstruct DualEdit adapters from saved state and hook them to model.""" |
| from experiment.knowledge_editing.dualedit.adapter import VisionEditAdapter, TextEditAdapter |
|
|
| state = torch.load(state_path, map_location=device, weights_only=False) |
| hp = state["hparams"] |
|
|
| |
| vision_adapter = VisionEditAdapter( |
| hidden_size=hp["hidden_size"], |
| mid_dim=hp["adapter_mid_dim"], |
| cross_att_head_n=hp["cross_att_head_n"], |
| img_tok_n=hp["img_tok_n"], |
| ).to(device) |
|
|
| text_adapter = TextEditAdapter( |
| hidden_size=hp["hidden_size"], |
| mid_dim=hp["adapter_mid_dim"], |
| cross_att_head_n=hp["cross_att_head_n"], |
| ).to(device) |
|
|
| |
| vision_adapter.load_state_dict(state["vision_adapter_state"]) |
| text_adapter.load_state_dict(state["text_adapter_state"]) |
|
|
| |
| vision_adapter.set_edit_signal( |
| state["mean_vis_edit_reps"].to(device), |
| state["mean_vis_edit_mask"].to(device), |
| ) |
| text_adapter.set_edit_signal( |
| state["mean_txt_edit_reps"].to(device), |
| state["mean_txt_edit_mask"].to(device), |
| ) |
|
|
| |
| vision_adapter.set_gate(state["gate_prototype"].to(device), state["gate_threshold"]) |
| text_adapter.set_gate(state["gate_prototype"].to(device), state["gate_threshold"]) |
| vision_adapter.open_adapter(True) |
| text_adapter.open_adapter(True) |
| vision_adapter.open_gating = True |
| text_adapter.open_gating = True |
|
|
| |
| vision_layer_name = hp["llm_layer_tmp"].format(hp["vision_adapter_layer"]) |
| text_layer_name = hp["llm_layer_tmp"].format(hp["text_adapter_layer"]) |
|
|
| def _find_module(m, path): |
| for part in path.split("."): |
| m = m[int(part)] if part.isdigit() else getattr(m, part) |
| return m |
|
|
| def make_hook(adapter): |
| def hook(module, args, output): |
| if isinstance(output, tuple): |
| out = list(output) |
| out[0] = adapter(out[0]) |
| return tuple(out) |
| return adapter(output) |
| return hook |
|
|
| vision_layer = _find_module(model, vision_layer_name) |
| text_layer = _find_module(model, text_layer_name) |
| vision_layer.register_forward_hook(make_hook(vision_adapter)) |
| text_layer.register_forward_hook(make_hook(text_adapter)) |
|
|
| |
| image_token_id = model.config.image_token_index |
| img_tok_n = hp["img_tok_n"] |
| _va = vision_adapter |
| _ta = text_adapter |
| _original_generate = model.generate |
|
|
| def _generate_with_adapter_info(*args, **kwargs): |
| input_ids = kwargs.get("input_ids") |
| if input_ids is not None and input_ids.shape[1] > 1: |
| positions = (input_ids[0] == image_token_id).nonzero(as_tuple=True)[0] |
| if len(positions) > 0: |
| vt_begin = int(positions[0]) |
| vt_end = vt_begin + img_tok_n |
| merged_len = input_ids.shape[1] - 1 + img_tok_n |
| print(f" [DualEdit] set_input_info: vt_begin={vt_begin}, vt_end={vt_end}, merged_len={merged_len}, image_token_id={image_token_id}") |
| _va.set_input_info(True, vt_begin, vt_end) |
| _ta.set_input_info(True, vt_begin, vt_end) |
| _ta.prompt_end = torch.tensor([merged_len], device=input_ids.device) |
| else: |
| print(f" [DualEdit] WARNING: image token {image_token_id} not found in input_ids (tokens: {input_ids[0].tolist()[:10]}...)") |
| _va.set_input_info(False, None, None) |
| _ta.set_input_info(False, None, None) |
| else: |
| print(f" [DualEdit] WARNING: input_ids missing or single-token in generate kwargs") |
| return _original_generate(*args, **kwargs) |
|
|
| model.generate = _generate_with_adapter_info |
|
|
| |
| model._dualedit_vision_adapter = vision_adapter |
| model._dualedit_text_adapter = text_adapter |
|
|
| return model |
|
|
|
|
| def load_base_model(base_model_name: str, device: str): |
| model = AutoModelForPreTraining.from_pretrained( |
| base_model_name, torch_dtype=dtype, |
| ).to(device) |
| model.eval() |
| return model |
|
|
|
|
| def load_finetuned_model(args, device: str): |
| if args.model_type == "lora": |
| from peft import PeftModel |
| model_dir = args.model_dir |
| if _is_adapter_dir(model_dir): |
| print(f" Detected LoRA adapter at {model_dir}") |
| base = AutoModelForPreTraining.from_pretrained( |
| args.base_model_name, torch_dtype=dtype, |
| ).to(device) |
| model = PeftModel.from_pretrained(base, model_dir) |
| else: |
| print(" No adapter_config.json found; treating as merged model") |
| model = AutoModelForPreTraining.from_pretrained( |
| model_dir, torch_dtype=dtype, |
| ).to(device) |
| model.eval() |
| return model |
|
|
| if args.model_type == "merged": |
| model = AutoModelForPreTraining.from_pretrained( |
| args.model_dir, torch_dtype=dtype, |
| ).to(device) |
| model.eval() |
| return model |
|
|
| if args.model_type == "delta_w": |
| from model.llava.hooked_llava import HookedSAELlavaConditionalGeneration |
| model = HookedSAELlavaConditionalGeneration.from_pretrained( |
| args.base_model_name, torch_dtype=dtype, |
| ).to(device) |
| state_dict = torch.load(args.checkpoint, map_location=device) |
| model.load_state_dict(state_dict, strict=True) |
| model.eval() |
| return model |
|
|
| if args.model_type in ("grace", "wise"): |
| model = AutoModelForPreTraining.from_pretrained( |
| args.base_model_name, torch_dtype=dtype, |
| ).to(device) |
| adapter_path = os.path.join(args.model_dir, "adapter_state.pt") |
| if os.path.exists(adapter_path): |
| adapter_states = torch.load(adapter_path, map_location=device, weights_only=False) |
| model = _restore_adapters(model, adapter_states, device) |
| print(f" Restored {len(adapter_states)} adapter(s) from {adapter_path}") |
| model.eval() |
| return model |
|
|
| if args.model_type == "dualedit": |
| model = AutoModelForPreTraining.from_pretrained( |
| args.base_model_name, torch_dtype=dtype, |
| ).to(device) |
| dualedit_path = os.path.join(args.model_dir, "dualedit_state.pt") |
| if os.path.exists(dualedit_path): |
| model = _restore_dualedit(model, dualedit_path, device) |
| print(f" Restored DualEdit adapters from {dualedit_path}") |
| model.eval() |
| return model |
|
|
| if args.model_type == "visedit": |
| visedit_dir = args.visedit_dir or str( |
| Path(__file__).resolve().parents[2] / "VisEdit" |
| ) |
| if visedit_dir not in sys.path: |
| sys.path.insert(0, visedit_dir) |
| |
| global_py = Path(visedit_dir) / "utils" / "GLOBAL.py" |
| global_py.write_text( |
| f"ROOT_PATH = {visedit_dir!r}\n" |
| f"model_path_map = {{\n" |
| f" 'llava-v1.5-7b': {args.base_model_name!r},\n" |
| f" 'blip2-opt-2.7b': 'models/blip2-opt-2.7b',\n" |
| f" 'minigpt-4-vicuna-7b': 'models/minigpt-4-vicuna-7b',\n" |
| f"}}\n" |
| ) |
| from utils import load_vllm_editor |
| ckpt_path = args.model_dir |
| editor = load_vllm_editor( |
| "vead", "llava", device, extra_devices=[], |
| editor_ckpt_path=ckpt_path, for_train=False, |
| ) |
| print(f" Loaded VEAD editor from {ckpt_path}") |
| return editor |
|
|
| raise ValueError(f"Unknown model_type: {args.model_type}") |
|
|
|
|
| def _load_processor_from(name_or_path: str): |
| """Load processor, falling back to manual component construction on version mismatches.""" |
| try: |
| return AutoProcessor.from_pretrained(name_or_path) |
| except Exception: |
| pass |
| try: |
| from transformers import AutoTokenizer, CLIPImageProcessor, LlavaProcessor |
| tokenizer = AutoTokenizer.from_pretrained(name_or_path, use_fast=False) |
| image_processor = CLIPImageProcessor.from_pretrained(name_or_path) |
| return LlavaProcessor(tokenizer=tokenizer, image_processor=image_processor) |
| except Exception as e: |
| raise RuntimeError( |
| f"Failed to load processor from {name_or_path!r}. " |
| "Try deleting the HuggingFace cache for this model and re-downloading." |
| ) from e |
|
|
|
|
| def load_processor(args): |
| if args.model_type == "lora" and args.model_dir and _is_adapter_dir(args.model_dir): |
| return _load_processor_from(args.base_model_name) |
| if args.model_type in ("delta_w", "grace", "wise", "dualedit", "visedit"): |
| return _load_processor_from(args.base_model_name) |
| try: |
| source = args.model_dir if args.model_dir else args.base_model_name |
| return _load_processor_from(source) |
| except Exception: |
| return _load_processor_from(args.base_model_name) |
|
|
|
|
| def infer_run_name(args) -> str: |
| path = args.model_dir or args.checkpoint or "unknown" |
| parts = os.path.normpath(path).split(os.sep) |
| for part in reversed(parts): |
| if part.startswith("run_"): |
| return part |
| return os.path.basename(os.path.dirname(path)) or os.path.basename(path) or "run" |
|
|
|
|
| def main(): |
| args = parse_args() |
|
|
| |
| relation_config = get_relation_config(args.relation) |
| dataset_id = args.dataset_id or relation_config.dataset_id |
|
|
| |
| train_prompts = args.train_prompts or relation_config.train_prompts |
|
|
| if args.inference_backend == "vllm": |
| device = "cuda" |
| else: |
| device = "cuda" if torch.cuda.is_available() else "cpu" |
|
|
| run_name = infer_run_name(args) |
| eval_dir = os.path.join(args.output_dir, run_name) |
| os.makedirs(eval_dir, exist_ok=True) |
|
|
| |
| all_prompts = list(args.prompts) |
| if args.generality_prompts: |
| for p in args.generality_prompts: |
| if p not in all_prompts: |
| all_prompts.append(p) |
| args.prompts = all_prompts |
|
|
| print("=" * 70) |
| print("Validate Hallucination Removal") |
| print("=" * 70) |
| print(f" relation: {relation_config}") |
| print(f" model_type: {args.model_type}") |
| print(f" mention_method: {args.mention_method}") |
| print(f" backend: {args.inference_backend}") |
| print(f" device: {device}") |
| print(f" output_dir: {eval_dir}") |
| print(f" train_prompts: {train_prompts}") |
| print(f" all_prompts: {all_prompts}") |
|
|
| if args.edit_image_ids: |
| print(f" NOTE: --edit_image_ids is deprecated and ignored (using first N deterministically)") |
|
|
| print("\nLoading images by category...") |
| categories = load_category_images( |
| relation_config=relation_config, |
| num_per_category=args.num_per_category, |
| use_val_split=args.use_val_split, |
| dataset_id=dataset_id, |
| csv_path=args.val_csv, |
| image_dir=args.val_image_dir, |
| ) |
|
|
| processor = load_processor(args) |
|
|
| |
| cache_dir = args.original_cache_dir |
| os.makedirs(cache_dir, exist_ok=True) |
| |
| all_cache_file = os.path.join(cache_dir, f"original_outputs_{args.relation}_all.json") |
| specific_cache_file = os.path.join( |
| cache_dir, |
| f"original_outputs_{args.relation}_n{args.num_per_category}_p{len(args.prompts)}.json", |
| ) |
|
|
| if os.path.exists(all_cache_file): |
| print(f"\n[1/3] Loading cached original model outputs from {all_cache_file}") |
| with open(all_cache_file, "r") as f: |
| all_cache = json.load(f) |
| needed_ids = { |
| cat: {entry["image_id"] for entry in entries} |
| for cat, entries in categories.items() |
| } |
| needed_prompts = set(args.prompts) |
| original_outputs = {} |
| for cat, entries in all_cache.items(): |
| original_outputs[cat] = [ |
| e for e in entries |
| if e["image_id"] in needed_ids.get(cat, set()) |
| and e["prompt"] in needed_prompts |
| ] |
| elif os.path.exists(specific_cache_file): |
| print(f"\n[1/3] Loading cached original model outputs from {specific_cache_file}") |
| with open(specific_cache_file, "r") as f: |
| original_outputs = json.load(f) |
| else: |
| print("\n[1/3] Inference: loading original model...") |
| original_model = load_base_model(args.base_model_name, device) |
| shared_collect_orig = dict( |
| processor=processor, |
| categories=categories, |
| prompts=args.prompts, |
| max_new_tokens=args.max_new_tokens, |
| device=device, |
| ) |
| print(" Collecting original model outputs...") |
| original_outputs = collect_outputs_transformers( |
| model=original_model, label="original", **shared_collect_orig |
| ) |
| del original_model |
| torch.cuda.empty_cache() |
|
|
| with open(specific_cache_file, "w") as f: |
| json.dump(original_outputs, f, indent=2) |
| print(f" Cached original outputs to {specific_cache_file}") |
|
|
| |
| shared_collect = dict( |
| processor=processor, |
| categories=categories, |
| prompts=args.prompts, |
| max_new_tokens=args.max_new_tokens, |
| device=device, |
| ) |
|
|
| print("\n[1/3] Inference: loading fine-tuned model...") |
| finetuned_model = load_finetuned_model(args, device) |
| print(" Collecting fine-tuned model outputs...") |
| if args.model_type == "visedit": |
| edit_targets = None |
| if args.edit_targets and os.path.exists(args.edit_targets): |
| with open(args.edit_targets) as f: |
| edit_targets = json.load(f) |
| print(f" Loaded {len(edit_targets)} edit targets from {args.edit_targets}") |
| finetuned_outputs = collect_outputs_visedit( |
| editor=finetuned_model, |
| categories=categories, |
| prompts=args.prompts, |
| max_new_tokens=args.max_new_tokens, |
| edit_targets=edit_targets, |
| label="visedit", |
| relation_config=relation_config, |
| ) |
| else: |
| finetuned_outputs = collect_outputs_transformers( |
| model=finetuned_model, label="finetuned", **shared_collect |
| ) |
| del finetuned_model |
| torch.cuda.empty_cache() |
|
|
| print("\nLoading metrics...") |
| keyword_detector, clip_scorer, judge = build_metrics( |
| mention_method=args.mention_method, |
| clip_model=args.clip_model, |
| judge_model=args.judge_model, |
| judge_device=args.judge_device, |
| judge_max_tokens=args.judge_max_tokens, |
| mention_keywords=relation_config.mention_keywords, |
| object_name=relation_config.judge_object_name, |
| ) |
|
|
| print("\n[2/3] Evaluating metrics from collected outputs...") |
| image_lookup = { |
| entry["image_id"]: entry["image"] |
| for cat_entries in categories.values() |
| for entry in cat_entries |
| if "image" in entry |
| } |
| original_results = evaluate_collected_outputs( |
| collected_outputs=original_outputs, |
| keyword_detector=keyword_detector, |
| clip_scorer=clip_scorer, |
| judge=judge, |
| mention_method=args.mention_method, |
| label="original", |
| image_lookup=image_lookup, |
| ) |
| finetuned_results = evaluate_collected_outputs( |
| collected_outputs=finetuned_outputs, |
| keyword_detector=keyword_detector, |
| clip_scorer=clip_scorer, |
| judge=judge, |
| mention_method=args.mention_method, |
| label="finetuned", |
| image_lookup=image_lookup, |
| ) |
|
|
| print("\n Computing KME metrics (locality, generality, consistency)...") |
| similarity_scorer = TextSimilarityScorer() |
| kme_metrics = compute_kme_metrics( |
| original_outputs=original_outputs, |
| finetuned_outputs=finetuned_outputs, |
| keyword_detector=keyword_detector, |
| train_prompts=train_prompts, |
| similarity_scorer=similarity_scorer, |
| efficacy_category=relation_config.efficacy_category, |
| locality_categories=relation_config.locality_categories, |
| ) |
|
|
| print("\n[3/3] Results") |
| print_summary(categories, original_results, finetuned_results, |
| kme_metrics=kme_metrics, |
| relation_config=relation_config) |
|
|
| summary = {} |
| for cat in categories: |
| o = original_results[cat] |
| f = finetuned_results[cat] |
| summary[cat] = { |
| "original": {k: v for k, v in o.items() if k != "details"}, |
| "finetuned": {k: v for k, v in f.items() if k != "details"}, |
| "delta_clip": ( |
| (f["avg_clip_score"] - o["avg_clip_score"]) |
| if not (math.isnan(f["avg_clip_score"]) or math.isnan(o["avg_clip_score"])) |
| else None |
| ), |
| } |
|
|
| config_snapshot = { |
| "relation": args.relation, |
| "model_type": args.model_type, |
| "base_model_name": args.base_model_name, |
| "model_dir": args.model_dir, |
| "checkpoint": args.checkpoint, |
| "val_csv": args.val_csv, |
| "num_per_category": args.num_per_category, |
| "prompts": args.prompts, |
| "train_prompts": train_prompts, |
| "generality_prompts": args.generality_prompts, |
| "mention_method": args.mention_method, |
| "clip_model": args.clip_model, |
| "judge_model": args.judge_model, |
| "judge_device": args.judge_device, |
| "inference_backend": args.inference_backend, |
| "vllm_batch_size": args.vllm_batch_size, |
| "vllm_tensor_parallel_size": args.vllm_tensor_parallel_size, |
| "vllm_gpu_memory_utilization": args.vllm_gpu_memory_utilization, |
| "vllm_max_model_len": args.vllm_max_model_len, |
| } |
|
|
| |
| kme_serializable = { |
| k: (None if isinstance(v, float) and math.isnan(v) else v) |
| for k, v in kme_metrics.items() |
| } |
|
|
| results_path = os.path.join(eval_dir, "validation_results.json") |
| with open(results_path, "w") as f: |
| json.dump({ |
| "summary": summary, |
| "kme_metrics": kme_serializable, |
| "config": config_snapshot, |
| }, f, indent=2) |
|
|
| details_path = os.path.join(eval_dir, "validation_details.json") |
| with open(details_path, "w") as f: |
| json.dump({ |
| "original": {cat: r["details"] for cat, r in original_results.items()}, |
| "finetuned": {cat: r["details"] for cat, r in finetuned_results.items()}, |
| }, f, indent=2, default=lambda x: None if (isinstance(x, float) and math.isnan(x)) else x) |
|
|
| print(f"\nResults saved to: {results_path}") |
| print(f"Details saved to: {details_path}") |
| print(f"\n{'=' * 70}") |
| print("Validation Complete!") |
| print(f"{'=' * 70}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|