| |
| """Run deterministic open-weight LVLM baselines for the RAVEL revision. |
| |
| The script writes hard-label LVLM results only. It does not fabricate |
| calibration metrics when normalized label-token probabilities are unavailable. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import csv |
| import json |
| import random |
| import re |
| import sys |
| import time |
| from dataclasses import asdict, dataclass |
| from pathlib import Path |
| from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple |
|
|
| import numpy as np |
| import torch |
| from PIL import Image |
| from sklearn.metrics import accuracy_score, f1_score |
| from transformers import AutoModelForImageTextToText, AutoProcessor |
|
|
| PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| if str(PROJECT_ROOT) not in sys.path: |
| sys.path.insert(0, str(PROJECT_ROOT)) |
|
|
|
|
| ROOT = Path("ravel_revision_results") |
| OUT = ROOT / "lvlm_results" |
| AGG = ROOT / "aggregate_results" |
|
|
| MODEL_ALIASES = { |
| "lvlm_small": "Qwen/Qwen2.5-VL-3B-Instruct", |
| "lvlm_large": "Qwen/Qwen2.5-VL-7B-Instruct", |
| } |
|
|
| MVSA_LABELS = {0: "Positive", 1: "Neutral", 2: "Negative"} |
| HFM_LABELS = {0: "Non-hateful", 1: "Hateful"} |
|
|
|
|
| @dataclass |
| class EvalSample: |
| sample_id: str |
| image_path: str |
| text: str |
| label: int |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description="Run LVLM baselines for RAVEL revision.") |
| parser.add_argument("--models", nargs="+", default=["lvlm_small", "lvlm_large"]) |
| parser.add_argument("--datasets", nargs="+", default=["mvsa_multiple", "hfm_deleak"]) |
| parser.add_argument("--settings", nargs="+", default=["zero_shot", "four_shot"]) |
| parser.add_argument("--split-seed", type=int, default=1) |
| parser.add_argument("--max-new-tokens", type=int, default=16) |
| parser.add_argument("--dtype", choices=["bf16", "fp16", "fp32"], default="bf16") |
| parser.add_argument("--device-map", default="auto") |
| parser.add_argument("--limit-test-samples", type=int, default=None) |
| parser.add_argument("--overwrite", action="store_true") |
| parser.add_argument("--dry-run", action="store_true") |
| parser.add_argument("--output-root", default=str(ROOT)) |
| parser.add_argument( |
| "--hfm-deleak-manifest", |
| default="ravel_revision_results/data_audit/hfm_split_manifest_deleaked.csv", |
| ) |
| return parser.parse_args() |
|
|
|
|
| def write_csv(path: Path, rows: Iterable[Dict[str, Any]], columns: Sequence[str]) -> None: |
| path.parent.mkdir(parents=True, exist_ok=True) |
| with path.open("w", newline="", encoding="utf-8") as f: |
| writer = csv.DictWriter(f, fieldnames=list(columns), extrasaction="ignore") |
| writer.writeheader() |
| for row in rows: |
| writer.writerow(row) |
|
|
|
|
| def load_samples(dataset: str, split_seed: int, manifest: str) -> Tuple[List[EvalSample], List[EvalSample]]: |
| if dataset == "mvsa_multiple": |
| from src.mvsa_multiple_pipeline import DEFAULT_MVSA_MULTIPLE_CONFIG, LABEL_NAME_TO_ID, MVSALoader |
|
|
| cfg = dict(DEFAULT_MVSA_MULTIPLE_CONFIG) |
| loader = MVSALoader(cfg["text_dir"], cfg["label_file"]) |
| loader.load( |
| preprocessing_mode=str(cfg.get("preprocessing_mode", "paper")), |
| require_unanimous=bool(cfg.get("require_unanimous", False)), |
| require_cross_agree=bool(cfg.get("require_cross_agree", False)), |
| paper_exact_counts=True, |
| ) |
| train, _val, test = loader.split( |
| train_ratio=float(cfg["train_ratio"]), |
| val_ratio=float(cfg["val_ratio"]), |
| seed=split_seed, |
| paper_811=True, |
| ) |
|
|
| def convert(sample: Any) -> EvalSample: |
| return EvalSample( |
| sample_id=str(sample.image_id), |
| image_path=str(sample.image_path), |
| text=str(sample.text), |
| label=int(LABEL_NAME_TO_ID[sample.combined_majority]), |
| ) |
|
|
| return [convert(s) for s in train], [convert(s) for s in test] |
|
|
| if dataset == "hfm_deleak": |
| from src.hfm_pipeline import HFMLoader, DEFAULT_HFM_CONFIG |
|
|
| cfg = dict(DEFAULT_HFM_CONFIG) |
| loader = HFMLoader(cfg["text_dir"], cfg["image_root"]) |
| loader.load_from_manifest(manifest) |
|
|
| def convert(sample: Any) -> EvalSample: |
| return EvalSample( |
| sample_id=str(sample.image_id), |
| image_path=str(sample.image_path), |
| text=str(sample.text), |
| label=int(sample.label), |
| ) |
|
|
| return [convert(s) for s in loader.get_split("train")], [convert(s) for s in loader.get_split("test")] |
|
|
| raise ValueError(f"Unsupported LVLM dataset: {dataset}") |
|
|
|
|
| def label_names(dataset: str) -> Dict[int, str]: |
| return MVSA_LABELS if dataset.startswith("mvsa") else HFM_LABELS |
|
|
|
|
| def task_prompt(dataset: str, text: str) -> str: |
| if dataset.startswith("mvsa"): |
| return ( |
| "You are given one image and one accompanying text.\n" |
| "Classify the overall sentiment.\n\n" |
| f"Text: {text}\n\n" |
| "Return exactly one label:\n" |
| "Negative\n" |
| "Neutral\n" |
| "Positive" |
| ) |
| return ( |
| "You are given one meme image and its accompanying text.\n" |
| "Determine whether the combined image-text content is hateful.\n\n" |
| f"Text: {text}\n\n" |
| "Return exactly one label:\n" |
| "Non-hateful\n" |
| "Hateful" |
| ) |
|
|
|
|
| def choose_few_shot(train: List[EvalSample], dataset: str, k: int, seed: int) -> List[EvalSample]: |
| if k <= 0: |
| return [] |
| rng = random.Random(seed) |
| grouped: Dict[int, List[EvalSample]] = {} |
| for sample in train: |
| grouped.setdefault(sample.label, []).append(sample) |
| for values in grouped.values(): |
| rng.shuffle(values) |
| labels = sorted(grouped) |
| selected: List[EvalSample] = [] |
| while len(selected) < k and labels: |
| progressed = False |
| for label in labels: |
| bucket = grouped.get(label, []) |
| if bucket and len(selected) < k: |
| selected.append(bucket.pop(0)) |
| progressed = True |
| if not progressed: |
| break |
| return selected[:k] |
|
|
|
|
| def build_messages(dataset: str, sample: EvalSample, demos: List[EvalSample]) -> Tuple[List[Dict[str, Any]], List[Image.Image]]: |
| images: List[Image.Image] = [] |
| messages: List[Dict[str, Any]] = [] |
| labels = label_names(dataset) |
| for demo in demos: |
| images.append(Image.open(demo.image_path).convert("RGB")) |
| messages.append( |
| { |
| "role": "user", |
| "content": [ |
| {"type": "image"}, |
| {"type": "text", "text": task_prompt(dataset, demo.text)}, |
| ], |
| } |
| ) |
| messages.append({"role": "assistant", "content": labels[demo.label]}) |
| images.append(Image.open(sample.image_path).convert("RGB")) |
| messages.append( |
| { |
| "role": "user", |
| "content": [ |
| {"type": "image"}, |
| {"type": "text", "text": task_prompt(dataset, sample.text)}, |
| ], |
| } |
| ) |
| return messages, images |
|
|
|
|
| def parse_label(dataset: str, raw_output: str) -> Tuple[Optional[int], bool]: |
| text = raw_output.strip().lower() |
| text = re.sub(r"[^a-z\\-\\s]", " ", text) |
| text = re.sub(r"\\s+", " ", text).strip() |
| if dataset.startswith("mvsa"): |
| if re.search(r"\\bnegative\\b", text): |
| return 2, True |
| if re.search(r"\\bneutral\\b", text): |
| return 1, True |
| if re.search(r"\\bpositive\\b", text): |
| return 0, True |
| return None, False |
| if "non-hateful" in text or "non hateful" in text or "not hateful" in text: |
| return 0, True |
| if re.search(r"\\bhateful\\b", text): |
| return 1, True |
| return None, False |
|
|
|
|
| def tensor_device(model: torch.nn.Module) -> torch.device: |
| try: |
| return next(model.parameters()).device |
| except StopIteration: |
| return torch.device("cuda" if torch.cuda.is_available() else "cpu") |
|
|
|
|
| def move_inputs(inputs: Dict[str, Any], device: torch.device) -> Dict[str, Any]: |
| return {key: value.to(device) if torch.is_tensor(value) else value for key, value in inputs.items()} |
|
|
|
|
| def load_model(model_id: str, dtype: str, device_map: str) -> Tuple[Any, Any, int]: |
| torch_dtype = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32}[dtype] |
| processor = AutoProcessor.from_pretrained(model_id) |
| model = AutoModelForImageTextToText.from_pretrained( |
| model_id, |
| torch_dtype=torch_dtype, |
| device_map=device_map, |
| low_cpu_mem_usage=True, |
| ) |
| model.eval() |
| total_params = int(sum(parameter.numel() for parameter in model.parameters())) |
| return processor, model, total_params |
|
|
|
|
| @torch.no_grad() |
| def run_suite( |
| processor: Any, |
| model: Any, |
| total_params: int, |
| model_alias: str, |
| model_id: str, |
| dataset: str, |
| setting: str, |
| args: argparse.Namespace, |
| ) -> Dict[str, Any]: |
| train, test = load_samples(dataset, args.split_seed, args.hfm_deleak_manifest) |
| if args.limit_test_samples and args.limit_test_samples > 0: |
| test = test[: args.limit_test_samples] |
| demos = choose_few_shot(train, dataset, 4 if setting == "four_shot" else 0, args.split_seed) |
|
|
| suite_dir = Path(args.output_root) / "lvlm_results" / model_alias / dataset / setting |
| pred_path = suite_dir / "predictions.csv" |
| if pred_path.exists() and not args.overwrite: |
| return aggregate_suite(model_alias, model_id, dataset, setting, suite_dir, total_params, args.dtype) |
|
|
| suite_dir.mkdir(parents=True, exist_ok=True) |
| prompt_record = { |
| "dataset": dataset, |
| "setting": setting, |
| "base_prompt": task_prompt(dataset, "{sample_text}"), |
| "decoding": {"temperature": 0, "do_sample": False, "max_new_tokens": args.max_new_tokens}, |
| "probability_source": "hard_label_only", |
| } |
| (suite_dir / "prompt.txt").write_text(json.dumps(prompt_record, indent=2), encoding="utf-8") |
| (suite_dir / "few_shot_examples.json").write_text( |
| json.dumps([asdict(sample) for sample in demos], indent=2), |
| encoding="utf-8", |
| ) |
|
|
| device = tensor_device(model) |
| rows: List[Dict[str, Any]] = [] |
| if torch.cuda.is_available(): |
| torch.cuda.reset_peak_memory_stats() |
| for sample in test: |
| try: |
| messages, images = build_messages(dataset, sample, demos) |
| text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) |
| inputs = processor(text=[text], images=images, return_tensors="pt") |
| prompt_tokens = int(inputs["input_ids"].shape[-1]) if "input_ids" in inputs else 0 |
| inputs = move_inputs(inputs, device) |
| if torch.cuda.is_available(): |
| torch.cuda.synchronize() |
| start = time.perf_counter() |
| generated = model.generate( |
| **inputs, |
| do_sample=False, |
| max_new_tokens=args.max_new_tokens, |
| ) |
| if torch.cuda.is_available(): |
| torch.cuda.synchronize() |
| latency_ms = (time.perf_counter() - start) * 1000.0 |
| input_len = int(inputs["input_ids"].shape[-1]) |
| generated_ids = generated[:, input_len:] |
| raw_output = processor.batch_decode( |
| generated_ids, |
| skip_special_tokens=True, |
| clean_up_tokenization_spaces=False, |
| )[0].strip() |
| generated_tokens = int(generated_ids.shape[-1]) |
| parsed, ok = parse_label(dataset, raw_output) |
| except Exception as exc: |
| latency_ms = float("nan") |
| prompt_tokens = 0 |
| generated_tokens = 0 |
| raw_output = f"ERROR: {type(exc).__name__}: {exc}" |
| parsed, ok = None, False |
| rows.append( |
| { |
| "sample_id": sample.sample_id, |
| "true_label": sample.label, |
| "raw_output": raw_output, |
| "parsed_label": "" if parsed is None else int(parsed), |
| "parse_success": bool(ok), |
| "latency_ms": latency_ms, |
| "prompt_tokens": prompt_tokens, |
| "generated_tokens": generated_tokens, |
| } |
| ) |
|
|
| write_csv( |
| pred_path, |
| rows, |
| [ |
| "sample_id", |
| "true_label", |
| "raw_output", |
| "parsed_label", |
| "parse_success", |
| "latency_ms", |
| "prompt_tokens", |
| "generated_tokens", |
| ], |
| ) |
| return aggregate_suite(model_alias, model_id, dataset, setting, suite_dir, total_params, args.dtype) |
|
|
|
|
| def aggregate_suite( |
| model_alias: str, |
| model_id: str, |
| dataset: str, |
| setting: str, |
| suite_dir: Path, |
| total_params: int, |
| dtype: str, |
| ) -> Dict[str, Any]: |
| pred_path = suite_dir / "predictions.csv" |
| rows: List[Dict[str, str]] = [] |
| with pred_path.open("r", encoding="utf-8", newline="") as f: |
| reader = csv.DictReader(f) |
| rows = list(reader) |
| true: List[int] = [] |
| pred: List[int] = [] |
| latencies: List[float] = [] |
| failures = 0 |
| for row in rows: |
| ok = str(row.get("parse_success", "")).lower() == "true" |
| if not ok or row.get("parsed_label", "") == "": |
| failures += 1 |
| continue |
| true.append(int(row["true_label"])) |
| pred.append(int(row["parsed_label"])) |
| try: |
| latencies.append(float(row["latency_ms"])) |
| except Exception: |
| pass |
| if true: |
| accuracy = float(accuracy_score(true, pred)) |
| macro_f1 = float(f1_score(true, pred, average="macro", zero_division=0)) |
| weighted_f1 = float(f1_score(true, pred, average="weighted", zero_division=0)) |
| else: |
| accuracy = macro_f1 = weighted_f1 = float("nan") |
| lat = np.asarray(latencies, dtype=float) |
| peak_vram = torch.cuda.max_memory_reserved() / (1024**2) if torch.cuda.is_available() else float("nan") |
| efficiency = { |
| "total_parameters": total_params, |
| "precision": dtype, |
| "peak_vram_mb": float(peak_vram), |
| "mean_latency_ms": float(np.nanmean(lat)) if lat.size else float("nan"), |
| "p50_latency_ms": float(np.nanpercentile(lat, 50)) if lat.size else float("nan"), |
| "p95_latency_ms": float(np.nanpercentile(lat, 95)) if lat.size else float("nan"), |
| } |
| (suite_dir / "efficiency.json").write_text(json.dumps(efficiency, indent=2), encoding="utf-8") |
| return { |
| "model": model_alias, |
| "model_id": model_id, |
| "dataset": dataset, |
| "setting": setting, |
| "total_params": total_params, |
| "trainable_params": 0, |
| "accuracy": accuracy, |
| "macro_f1": macro_f1, |
| "weighted_f1": weighted_f1, |
| "f1": weighted_f1 if dataset.startswith("mvsa") else macro_f1, |
| "auroc": "N/A", |
| "raw_ece": "N/A", |
| "nll": "N/A", |
| "brier": "N/A", |
| "latency_per_sample_ms": efficiency["mean_latency_ms"], |
| "peak_vram_mb": efficiency["peak_vram_mb"], |
| "parse_failure_rate": failures / max(1, len(rows)), |
| "probability_source": "hard_label_only", |
| "status": "complete" if rows else "missing", |
| } |
|
|
|
|
| def write_aggregate(rows: List[Dict[str, Any]]) -> None: |
| AGG.mkdir(parents=True, exist_ok=True) |
| write_csv( |
| AGG / "lvlm_results.csv", |
| rows, |
| [ |
| "model", |
| "model_id", |
| "dataset", |
| "setting", |
| "total_params", |
| "trainable_params", |
| "accuracy", |
| "macro_f1", |
| "weighted_f1", |
| "f1", |
| "auroc", |
| "raw_ece", |
| "nll", |
| "brier", |
| "latency_per_sample_ms", |
| "peak_vram_mb", |
| "parse_failure_rate", |
| "probability_source", |
| "status", |
| ], |
| ) |
|
|
|
|
| def main() -> None: |
| args = parse_args() |
| global ROOT, OUT, AGG |
| ROOT = Path(args.output_root) |
| OUT = ROOT / "lvlm_results" |
| AGG = ROOT / "aggregate_results" |
| if args.dry_run: |
| for model_alias in args.models: |
| model_id = MODEL_ALIASES.get(model_alias, model_alias) |
| for dataset in args.datasets: |
| train, test = load_samples(dataset, args.split_seed, args.hfm_deleak_manifest) |
| if args.limit_test_samples and args.limit_test_samples > 0: |
| test = test[: args.limit_test_samples] |
| for setting in args.settings: |
| demos = choose_few_shot(train, dataset, 4 if setting == "four_shot" else 0, args.split_seed) |
| print( |
| f"DRY-RUN model={model_alias} ({model_id}) dataset={dataset} " |
| f"setting={setting} train={len(train)} test={len(test)} demos={[d.sample_id for d in demos]}", |
| flush=True, |
| ) |
| return |
| all_rows: List[Dict[str, Any]] = [] |
| for model_alias in args.models: |
| model_id = MODEL_ALIASES.get(model_alias, model_alias) |
| processor, model, total_params = load_model(model_id, args.dtype, args.device_map) |
| for dataset in args.datasets: |
| for setting in args.settings: |
| row = run_suite(processor, model, total_params, model_alias, model_id, dataset, setting, args) |
| all_rows.append(row) |
| write_aggregate(all_rows) |
| del model |
| if torch.cuda.is_available(): |
| torch.cuda.empty_cache() |
| write_aggregate(all_rows) |
| print(f"Wrote {AGG / 'lvlm_results.csv'}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|