Spaces:
Running on L40S
Running on L40S
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |
| # SPDX-License-Identifier: OpenMDW-1.1 | |
| """Inference + accuracy/Pearson metrics for a VideoPhy-2-SFT'd Qwen3-VL ckpt. | |
| Two modes share one CLI: | |
| 1. **Run + eval** — pass ``--hf_ckpt`` and ``--val_root``. The script loads the | |
| HF safetensors export, iterates the prepared val manifest, runs batched | |
| generations, writes one ``<sample_id>.json`` per sample to ``--results_dir``, | |
| then walks the directory and writes ``summary.json``. | |
| 2. **Eval only** — pass only ``--results_dir`` (point at a dir already filled | |
| by a prior run). Re-reads each JSON, recomputes ``summary.json``. Useful | |
| when iterating on the score parser without re-running inference. | |
| Multi-GPU is opt-in via ``torchrun`` — every rank loads the model onto its | |
| ``LOCAL_RANK`` GPU and processes ``meta[rank::world_size]``. With no torchrun | |
| env vars set, the script runs single-process on ``cuda:0``. | |
| Single-GPU example:: | |
| python -m cosmos_framework.scripts.vlm.eval_videophy2 \\ | |
| --hf_ckpt $HF_CKPT --val_root $VAL_ROOT --results_dir $OUT | |
| 8-GPU data-parallel example:: | |
| torchrun --nproc_per_node=8 -m cosmos_framework.scripts.vlm.eval_videophy2 \\ | |
| --hf_ckpt $HF_CKPT --val_root $VAL_ROOT --results_dir $OUT --batch_size 2 | |
| The inference path here is intentionally lightweight — it is expected to be | |
| replaced by the upstream ``cosmos_framework.inference`` reasoner path once that | |
| supports video conditioning. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import math | |
| import os | |
| import re | |
| import sys | |
| from pathlib import Path | |
| _SCORE_RE = re.compile(r"\b([1-5])\b") | |
| # --------------------------------------------------------------------------- | |
| # Score parsing + metrics | |
| # --------------------------------------------------------------------------- | |
| def _parse_score(text): | |
| """Return the first standalone 1-5 digit in ``text``, or ``None``.""" | |
| if not isinstance(text, str): | |
| return None | |
| m = _SCORE_RE.search(text) | |
| return int(m.group(1)) if m else None | |
| def _pearson(xs, ys): | |
| """Pearson correlation; returns None if undefined.""" | |
| if len(xs) < 2: | |
| return None | |
| try: | |
| from scipy.stats import pearsonr | |
| r = float(pearsonr(xs, ys).statistic) | |
| except ImportError: | |
| import numpy as np | |
| x = np.asarray(xs, dtype=float) | |
| y = np.asarray(ys, dtype=float) | |
| if x.std() == 0 or y.std() == 0: | |
| return None | |
| r = float(np.corrcoef(x, y)[0, 1]) | |
| return None if math.isnan(r) else r | |
| # --------------------------------------------------------------------------- | |
| # Distributed helpers — opt-in via torchrun env vars | |
| # --------------------------------------------------------------------------- | |
| def _init_distributed(): | |
| """Returns ``(rank, world_size, local_rank)``. Initialises the NCCL process | |
| group when launched under torchrun (``WORLD_SIZE`` env var > 1).""" | |
| world_size = int(os.environ.get("WORLD_SIZE", 1)) | |
| if world_size <= 1: | |
| return 0, 1, 0 | |
| import torch | |
| import torch.distributed as dist | |
| rank = int(os.environ["RANK"]) | |
| local_rank = int(os.environ.get("LOCAL_RANK", rank)) | |
| torch.cuda.set_device(local_rank) | |
| if not dist.is_initialized(): | |
| dist.init_process_group(backend="nccl") | |
| return rank, world_size, local_rank | |
| def _barrier(): | |
| try: | |
| import torch.distributed as dist | |
| if dist.is_available() and dist.is_initialized(): | |
| dist.barrier() | |
| except Exception: | |
| pass | |
| # --------------------------------------------------------------------------- | |
| # Inference | |
| # --------------------------------------------------------------------------- | |
| def _prepare_sample(sample_meta, val_root): | |
| """Load one val sample. Returns ``(sample_id, user_turns, gt_response, video_path)`` | |
| or ``None`` if either the media or conversation file is missing.""" | |
| sample_id = sample_meta.get("id") or sample_meta.get("name") or "sample_unknown" | |
| video_rel = sample_meta.get("media") or sample_meta.get("video") | |
| text_rel = sample_meta.get("text") or sample_meta.get("conversation") | |
| video_path = val_root / video_rel | |
| text_path = val_root / text_rel | |
| if not video_path.exists() or not text_path.exists(): | |
| return None | |
| conversation = json.loads(text_path.read_text())["conversations"] | |
| user_turns = [t for t in conversation if t.get("role") != "assistant"] | |
| gt_entry = next((t for t in conversation if t.get("role") == "assistant"), None) | |
| gt_response = ( | |
| gt_entry["content"][0]["text"] | |
| if gt_entry and isinstance(gt_entry.get("content"), list) | |
| else None | |
| ) | |
| # Resolve the "video_0" placeholder in user content to the actual file path. | |
| for t in user_turns: | |
| content = t.get("content") | |
| if isinstance(content, list): | |
| for c in content: | |
| if isinstance(c, dict) and c.get("type") == "video": | |
| c["video"] = str(video_path) | |
| return sample_id, user_turns, gt_response, video_path | |
| def _run_inference(args, rank, world_size, local_rank): | |
| """Each rank loads the model onto ``cuda:<local_rank>``, processes | |
| ``meta[rank::world_size]`` in batches, and writes one JSON per sample | |
| into ``args.results_dir``.""" | |
| import torch | |
| from transformers import AutoProcessor | |
| from transformers.models.qwen3_vl import Qwen3VLForConditionalGeneration | |
| val_root = Path(args.val_root) | |
| out = Path(args.results_dir) | |
| out.mkdir(parents=True, exist_ok=True) | |
| meta = json.loads((val_root / "meta.json").read_text()) | |
| if args.n is not None: | |
| meta = meta[: args.n] | |
| shard = meta[rank::world_size] | |
| if rank == 0: | |
| print( | |
| f"[infer] {len(meta)} val samples; sharded across {world_size} rank(s); " | |
| f"rank0 owns {len(shard)} samples; batch_size={args.batch_size}", | |
| flush=True, | |
| ) | |
| device = f"cuda:{local_rank}" | |
| if rank == 0: | |
| print(f"[infer] loading model from {args.hf_ckpt} on {device} ...", flush=True) | |
| model = Qwen3VLForConditionalGeneration.from_pretrained( | |
| args.hf_ckpt, torch_dtype=torch.bfloat16, device_map=device, | |
| ) | |
| model.eval() | |
| processor = AutoProcessor.from_pretrained(args.hf_ckpt) | |
| # Left-pad so newly generated tokens land at the actual sequence end. | |
| if hasattr(processor, "tokenizer") and processor.tokenizer is not None: | |
| processor.tokenizer.padding_side = "left" | |
| if rank == 0: | |
| print("[infer] model + processor ready", flush=True) | |
| batch_size = max(1, args.batch_size) | |
| for batch_start in range(0, len(shard), batch_size): | |
| batch_meta = shard[batch_start : batch_start + batch_size] | |
| prepared = [_prepare_sample(sm, val_root) for sm in batch_meta] | |
| valid = [p for p in prepared if p is not None] | |
| for sm, p in zip(batch_meta, prepared): | |
| if p is None: | |
| print(f"[infer] rank{rank} skip {sm.get('id')}: missing file", flush=True) | |
| if not valid: | |
| continue | |
| ids, conversations, gts, video_paths = zip(*valid) | |
| if len(conversations) == 1: | |
| inputs = processor.apply_chat_template( | |
| conversations[0], | |
| add_generation_prompt=True, tokenize=True, | |
| return_tensors="pt", return_dict=True, | |
| ).to(device) | |
| else: | |
| inputs = processor.apply_chat_template( | |
| list(conversations), | |
| add_generation_prompt=True, tokenize=True, | |
| return_tensors="pt", return_dict=True, padding=True, | |
| ).to(device) | |
| with torch.inference_mode(): | |
| output_ids = model.generate( | |
| **inputs, | |
| max_new_tokens=args.max_new_tokens, | |
| do_sample=False, | |
| ) | |
| new_ids = output_ids[:, inputs["input_ids"].shape[-1] :] | |
| responses = processor.batch_decode(new_ids, skip_special_tokens=True) | |
| for sample_id, video_path, response, gt in zip(ids, video_paths, responses, gts): | |
| (out / f"{sample_id}.json").write_text(json.dumps({ | |
| "id": sample_id, | |
| "video": str(video_path), | |
| "model_response": response, | |
| "ground_truth": gt, | |
| }, indent=2)) | |
| if rank == 0: | |
| done = batch_start + len(valid) | |
| preview = responses[0].replace("\n", " ")[:60] | |
| print(f"[infer] rank0 {done}/{len(shard)}: {preview!r}", flush=True) | |
| if rank == 0: | |
| print(f"[infer] rank0 done -> {out}", flush=True) | |
| # --------------------------------------------------------------------------- | |
| # Metrics aggregation | |
| # --------------------------------------------------------------------------- | |
| def _compute_metrics(results_dir, summary_path): | |
| """Walk ``results_dir`` of per-sample JSONs, write ``summary.json``. | |
| Parse-failure policy: a sample with unparseable ground_truth is dropped | |
| entirely (shouldn't happen on the canonical val split). A sample with | |
| parseable GT but unparseable model_response counts as a miss in the | |
| accuracy denominator (it cannot equal the GT). Pearson is computed only | |
| over pairs where both sides parsed. | |
| """ | |
| preds, gts = [], [] | |
| num_pred_parse_failures = 0 | |
| num_gt_parse_failures = 0 | |
| num_correct = 0 | |
| num_gt_parsed = 0 | |
| num_samples = 0 | |
| for json_path in sorted(results_dir.glob("*.json")): | |
| if json_path.resolve() == summary_path.resolve(): | |
| continue | |
| sample = json.loads(json_path.read_text()) | |
| num_samples += 1 | |
| gt = _parse_score(sample.get("ground_truth")) | |
| if gt is None: | |
| num_gt_parse_failures += 1 | |
| print(f"[eval] WARN unparseable ground_truth in {json_path.name}; dropping sample") | |
| continue | |
| num_gt_parsed += 1 | |
| pred = _parse_score(sample.get("model_response")) | |
| if pred is None: | |
| num_pred_parse_failures += 1 | |
| continue | |
| preds.append(pred) | |
| gts.append(gt) | |
| if pred == gt: | |
| num_correct += 1 | |
| accuracy = num_correct / num_gt_parsed if num_gt_parsed else 0.0 | |
| pearson = _pearson(preds, gts) | |
| summary = { | |
| "accuracy": accuracy, | |
| "pearson_correlation": pearson, | |
| "num_samples": num_samples, | |
| "num_pred_parse_failures": num_pred_parse_failures, | |
| "num_gt_parse_failures": num_gt_parse_failures, | |
| } | |
| summary_path.parent.mkdir(parents=True, exist_ok=True) | |
| summary_path.write_text(json.dumps(summary, indent=2) + "\n") | |
| pearson_str = f"{pearson:.3f}" if pearson is not None else "n/a" | |
| print( | |
| f"[eval] acc={accuracy:.3f} ({num_correct}/{num_gt_parsed})" | |
| f" pearson={pearson_str} (n={len(preds)})" | |
| f" pred_parse_fail={num_pred_parse_failures} -> {summary_path}" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # CLI | |
| # --------------------------------------------------------------------------- | |
| def main(): | |
| p = argparse.ArgumentParser() | |
| p.add_argument("--results_dir", required=True, | |
| help="Per-sample JSON dir — output of inference, input to metrics") | |
| p.add_argument("--summary", default=None, | |
| help="summary.json path (default: <results_dir>/summary.json)") | |
| # Inference-mode args. Both required to enable the inference pass. | |
| p.add_argument("--hf_ckpt", default=None, | |
| help="HF safetensors dir (e.g. .../hf_exports/iter_NNN/). " | |
| "If set, run inference first; else just aggregate from --results_dir.") | |
| p.add_argument("--val_root", default=None, | |
| help="VideoPhy-2 val dir with meta.json + media/ + text/. " | |
| "Required when --hf_ckpt is set.") | |
| p.add_argument("--n", type=int, default=None, | |
| help="Limit to first N val samples (default: all)") | |
| p.add_argument("--max_new_tokens", type=int, default=256) | |
| p.add_argument("--batch_size", type=int, default=1, | |
| help="Per-rank generation batch size (default: 1).") | |
| args = p.parse_args() | |
| results_dir = Path(args.results_dir) | |
| summary_path = Path(args.summary) if args.summary else results_dir / "summary.json" | |
| rank, world_size, local_rank = _init_distributed() | |
| if args.hf_ckpt: | |
| if not args.val_root: | |
| sys.exit("--val_root is required when --hf_ckpt is set") | |
| _run_inference(args, rank, world_size, local_rank) | |
| _barrier() | |
| if rank == 0: | |
| if not results_dir.is_dir(): | |
| sys.exit(f"[eval] results_dir not found: {results_dir}") | |
| _compute_metrics(results_dir, summary_path) | |
| if __name__ == "__main__": | |
| main() | |