Skip to content

Commit c916183

Browse files
feat(metrics): paper-faithful OneIG alignment scoring
Split 2x2 grids, one VLM question per call, Qwen2.5-VL default, and strict list[dict] aux validation. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2605047 commit c916183

3 files changed

Lines changed: 120 additions & 66 deletions

File tree

src/pruna/evaluation/metrics/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from pruna.evaluation.metrics.metric_memory import DiskMemoryMetric, InferenceMemoryMetric, TrainingMemoryMetric
2424
from pruna.evaluation.metrics.metric_model_architecture import TotalMACsMetric, TotalParamsMetric
2525
from pruna.evaluation.metrics.metric_pairwise_clip import PairwiseClipScore
26+
from pruna.evaluation.metrics.metric_oneig_alignment import OneIGAlignmentMetric
2627
from pruna.evaluation.metrics.metric_qa_accuracy import QAAccuracyMetric
2728
from pruna.evaluation.metrics.metric_rapiddata import RapidataMetric as RapidataMetric
2829
from pruna.evaluation.metrics.metric_sharpness import SharpnessMetric
@@ -54,6 +55,7 @@
5455
"SharpnessMetric",
5556
"AestheticLAION",
5657
"LMEvalMetric",
58+
"OneIGAlignmentMetric",
5759
"QAAccuracyMetric",
5860
"RapidataMetric",
5961
"BaseVLM",

src/pruna/evaluation/metrics/metric_oneig_alignment.py

Lines changed: 110 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,17 @@
1616

1717
from __future__ import annotations
1818

19-
from typing import Any, Mapping
19+
from typing import Any, Literal, Mapping
2020

2121
import torch
22+
from PIL import Image
2223

2324
from pruna.evaluation.metrics.metric_qa_accuracy import QAAccuracyMetric
2425
from pruna.evaluation.metrics.registry import MetricRegistry
2526
from pruna.evaluation.metrics.utils import metric_data_processor
26-
from pruna.evaluation.metrics.vlm_utils import _process_images
27+
from pruna.evaluation.metrics.vlm_utils import _process_images, split_mxn_grid
28+
29+
_DEFAULT_ONEIG_ALIGNMENT_VLM = "Qwen/Qwen2.5-VL-7B-Instruct"
2730

2831

2932
def _int_dict_keys(mapping: Mapping[Any, Any]) -> dict[int, Any]:
@@ -122,73 +125,146 @@ def aggregate_oneig_alignment_per_cell(filtered_scores: Mapping[int, float], que
122125
return s / float(len(question_ids))
123126

124127

128+
def _aux_list_from_gt(aux_slot: Any, batch_size: int) -> list[dict[str, Any]]:
129+
if isinstance(aux_slot, torch.Tensor):
130+
raise ValueError(
131+
"oneig_alignment expects gt as list[dict] with 'questions' and optional 'dependencies'. "
132+
f"Got tensor with shape {tuple(aux_slot.shape)}."
133+
)
134+
if not isinstance(aux_slot, (list, tuple)):
135+
return [{} for _ in range(batch_size)]
136+
out: list[dict[str, Any]] = []
137+
for i in range(batch_size):
138+
row = aux_slot[i] if i < len(aux_slot) else {}
139+
if not isinstance(row, dict):
140+
raise ValueError(f"oneig_alignment requires aux[{i}] to be a dict. Got: {type(row)!r}.")
141+
out.append(row)
142+
return out
143+
144+
125145
@MetricRegistry.register("oneig_alignment")
126146
class OneIGAlignmentMetric(QAAccuracyMetric):
127147
"""
128148
OneIG alignment with dependency-aware aggregation.
129149
130-
Reuses :class:`QAAccuracyMetric` VLM Yes/No scoring but aggregates like
131-
``OneIG-Benchmark`` ``alignment_score.py`` for a **single** grid cell (no
132-
``split_mxn_grid``): question ids are sorted numerically, raw scores are
133-
masked when any non-root parent is ``No``, then the mean over all questions
134-
is stored per image. Entries with null or blank question text (HF ``datasets``
135-
schema padding) are omitted from scoring.
150+
Matches ``OneIG-Benchmark`` ``alignment_score.py``: split an ``m x n`` output grid
151+
(default ``2 x 2``), score **one question per VLM call** across all cells, apply
152+
dependency masking per cell, then average cell scores.
136153
137-
Numerical parity with upstream also depends on the VLM (e.g. ``openai/gpt-4o`` via
138-
litellm vs reference Qwen2.5-VL).
154+
Scoring semantics
155+
-----------------
156+
OneIG Q_D probes are phrased so **Yes = aligned**. Each call requests
157+
:meth:`~pruna.evaluation.metrics.vlm_base.BaseVLM.score` with expected answer
158+
``"Yes"`` (probability of Yes). Low scores act as semantic **No** for dependency
159+
masking.
139160
140161
Parameters
141162
----------
142-
*args : Any
143-
Additional positional arguments for :class:`QAAccuracyMetric`.
163+
grid_size : tuple[int, int], optional
164+
``(columns, rows)`` for :func:`~pruna.evaluation.metrics.vlm_utils.split_mxn_grid`.
165+
Default ``(2, 2)`` per OneIG. Use ``(1, 1)`` to score the full image without splitting.
144166
vlm : BaseVLM | None, optional
145167
Custom VLM instance. If provided, ``vlm_type`` and ``model_name`` are ignored.
146168
vlm_type : {"litellm", "transformers"}, optional
147-
VLM backend. Default is ``"litellm"``.
169+
VLM backend. Default is ``"transformers"`` (paper-faithful Qwen2.5-VL).
148170
model_name : str | None, optional
149-
Litellm model id or HuggingFace checkpoint id. **Required** when ``vlm`` is not
150-
provided (e.g. ``openai/gpt-4o``).
171+
HuggingFace or litellm model id. Default ``Qwen/Qwen2.5-VL-7B-Instruct``.
151172
vlm_kwargs : dict, optional
152-
Forwarded by ``get_vlm`` to ``LitellmVLM`` or ``TransformersVLM``. For local models,
153-
set ``model_load_kwargs`` for ``from_pretrained``; for litellm, pass extra API options.
173+
Forwarded by ``get_vlm``.
154174
structured_output : bool, optional
155-
Use structured generation (litellm pydantic; transformers outlines when applicable).
156-
Default is True.
175+
Use structured generation when applicable.
157176
device : str | torch.device | None, optional
158177
Device for transformers VLM.
159178
api_key : str | None, optional
160179
API key for litellm.
161180
call_type : str, optional
162181
Call type for the metric.
182+
aggregation : str, optional
183+
Unused; kept for registry compatibility with :class:`QAAccuracyMetric`.
163184
**kwargs : Any
164185
Additional keyword arguments for :class:`QAAccuracyMetric`.
165186
166187
Examples
167188
--------
168-
Same ``hosted`` / ``local`` pattern as ``QAAccuracyMetric`` and
169-
:func:`~pruna.evaluation.metrics.vlm_base.get_vlm`:
170-
171189
.. code-block:: python
172190
173-
import torch
174-
175191
from pruna.evaluation.metrics import OneIGAlignmentMetric
176192
177-
hosted = OneIGAlignmentMetric(vlm_type="litellm", model_name="openai/gpt-4o")
178-
local = OneIGAlignmentMetric(
179-
vlm_type="transformers",
180-
model_name="HuggingFaceTB/SmolVLM-256M-Instruct",
181-
device="cpu",
182-
vlm_kwargs={"model_load_kwargs": {"torch_dtype": torch.float32}},
183-
)
193+
paper = OneIGAlignmentMetric(device="cuda")
194+
api = OneIGAlignmentMetric(vlm_type="litellm", model_name="openai/gpt-4o")
184195
"""
185196

186197
metric_name: str = "oneig_alignment"
187198
metric_units: str = "alignment"
188199

200+
def __init__(
201+
self,
202+
*args: Any,
203+
grid_size: tuple[int, int] = (2, 2),
204+
vlm: Any | None = None,
205+
vlm_type: Literal["litellm", "transformers"] = "transformers",
206+
model_name: str | None = _DEFAULT_ONEIG_ALIGNMENT_VLM,
207+
vlm_kwargs: dict | None = None,
208+
structured_output: bool = True,
209+
device: str | torch.device | None = None,
210+
api_key: str | None = None,
211+
call_type: str | None = None,
212+
**kwargs: Any,
213+
) -> None:
214+
super().__init__(
215+
*args,
216+
vlm=vlm,
217+
vlm_type=vlm_type,
218+
model_name=model_name,
219+
vlm_kwargs=vlm_kwargs,
220+
structured_output=structured_output,
221+
device=device,
222+
api_key=api_key,
223+
call_type=call_type if call_type is not None else "y_gt",
224+
**kwargs,
225+
)
226+
self.grid_size = (int(grid_size[0]), int(grid_size[1]))
227+
228+
def _score_sample(self, image: Any, aux: dict[str, Any]) -> float:
229+
if not isinstance(image, Image.Image):
230+
if isinstance(image, torch.Tensor):
231+
from pruna.evaluation.metrics.vlm_utils import _tensor_to_pil
232+
233+
image = _tensor_to_pil(image)
234+
else:
235+
image = Image.fromarray(image).convert("RGB")
236+
cells = split_mxn_grid(image, self.grid_size)
237+
qs = aux.get("questions")
238+
if not isinstance(qs, dict) or not qs:
239+
raise ValueError(
240+
f"oneig_alignment requires 'questions' as a non-empty dict on aux. Got keys: {list(aux.keys())}."
241+
)
242+
qmap = _int_dict_keys(qs)
243+
qids = _active_oneig_question_ids(qmap)
244+
if not qids:
245+
return 0.0
246+
deps = _normalize_dependencies(aux.get("dependencies", {}))
247+
per_question_cell_scores: dict[int, list[float]] = {}
248+
n_cells = len(cells)
249+
for qid in qids:
250+
qtext = str(qmap[qid])
251+
raw_scores_list = self.vlm.score(
252+
cells,
253+
[qtext] * n_cells,
254+
["Yes"] * n_cells,
255+
response_format=self.response_format,
256+
)
257+
per_question_cell_scores[qid] = [float(s) for s in raw_scores_list]
258+
cell_means: list[float] = []
259+
for cell_i in range(n_cells):
260+
raw_map = {qid: per_question_cell_scores[qid][cell_i] for qid in qids}
261+
filtered = apply_oneig_dependency_mask(raw_map, deps)
262+
cell_means.append(aggregate_oneig_alignment_per_cell(filtered, qids))
263+
return float(sum(cell_means) / len(cell_means))
264+
189265
def update(self, x: list[Any] | torch.Tensor, gt: torch.Tensor, outputs: torch.Tensor) -> None:
190266
"""
191-
Score each question with the VLM, apply dependency masking, append per-cell mean.
267+
Score each prompt image with OneIG alignment (grid split + per-question VLM calls).
192268
193269
Parameters
194270
----------
@@ -202,33 +278,6 @@ def update(self, x: list[Any] | torch.Tensor, gt: torch.Tensor, outputs: torch.T
202278
"""
203279
inputs = metric_data_processor(x, gt, outputs, self.call_type)
204280
images = _process_images(inputs[0])
205-
aux_list = inputs[1] if len(inputs) > 1 else []
206-
if isinstance(aux_list, torch.Tensor):
207-
aux_list = aux_list.tolist()
281+
aux_list = _aux_list_from_gt(inputs[1] if len(inputs) > 1 else [], len(images))
208282
for i, image in enumerate(images):
209-
aux = aux_list[i] if i < len(aux_list) else {}
210-
if not isinstance(aux, dict):
211-
raise ValueError(
212-
"oneig_alignment requires aux[{}] to be a dict with 'questions'. Got: {!r}.".format(i, type(aux))
213-
)
214-
qs = aux.get("questions")
215-
if not isinstance(qs, dict) or not qs:
216-
raise ValueError(
217-
f"oneig_alignment requires 'questions' as a non-empty dict on aux. Got keys: {list(aux.keys())}."
218-
)
219-
qmap = _int_dict_keys(qs)
220-
qids = _active_oneig_question_ids(qmap)
221-
if not qids:
222-
self.scores.append(0.0)
223-
continue
224-
question_texts = [str(qmap[qi]) for qi in qids]
225-
deps = _normalize_dependencies(aux.get("dependencies", {}))
226-
raw_scores_list = self.vlm.score(
227-
[image] * len(question_texts),
228-
question_texts,
229-
["Yes"] * len(question_texts),
230-
response_format=self.response_format,
231-
)
232-
raw_map = {qid: float(raw_scores_list[j]) for j, qid in enumerate(qids)}
233-
filtered = apply_oneig_dependency_mask(raw_map, deps)
234-
self.scores.append(aggregate_oneig_alignment_per_cell(filtered, qids))
283+
self.scores.append(self._score_sample(image, aux_list[i]))

tests/evaluation/test_text_metrics.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ def test_oneig_alignment_metric_respects_question_id_order() -> None:
7171
mock_vlm = MagicMock(spec=BaseVLM)
7272
mock_vlm.score.return_value = [0.0, 1.0]
7373

74-
metric = OneIGAlignmentMetric(vlm=mock_vlm, vlm_type="litellm", device="cpu")
74+
metric = OneIGAlignmentMetric(vlm=mock_vlm, vlm_type="litellm", device="cpu", grid_size=(1, 1))
75+
mock_vlm.score.side_effect = [[0.0], [1.0]]
7576
images = torch.rand(1, 3, 64, 64)
7677
aux = {
7778
"questions": {"2": "second", "1": "first"},
@@ -83,8 +84,9 @@ def test_oneig_alignment_metric_respects_question_id_order() -> None:
8384
assert result.higher_is_better is True
8485
assert result.metric_units == "alignment"
8586
assert result.result == 0.0
86-
call = mock_vlm.score.call_args
87-
assert call[0][1] == ["first", "second"]
87+
assert mock_vlm.score.call_count == 2
88+
assert mock_vlm.score.call_args_list[0][0][1] == ["first"]
89+
assert mock_vlm.score.call_args_list[1][0][1] == ["second"]
8890

8991

9092
@pytest.mark.cpu
@@ -93,7 +95,8 @@ def test_oneig_alignment_skips_none_question_texts() -> None:
9395
mock_vlm = MagicMock(spec=BaseVLM)
9496
mock_vlm.score.return_value = [1.0]
9597

96-
metric = OneIGAlignmentMetric(vlm=mock_vlm, vlm_type="litellm", device="cpu")
98+
metric = OneIGAlignmentMetric(vlm=mock_vlm, vlm_type="litellm", device="cpu", grid_size=(1, 1))
99+
mock_vlm.score.return_value = [1.0]
97100
images = torch.rand(1, 3, 64, 64)
98101
aux = {
99102
"questions": {"1": "first", "21": None},
@@ -111,7 +114,7 @@ def test_oneig_alignment_skips_none_question_texts() -> None:
111114
def test_oneig_alignment_all_padding_questions_yields_zero_without_vlm() -> None:
112115
"""When every slot is padding, score is 0.0 and the VLM is not called."""
113116
mock_vlm = MagicMock(spec=BaseVLM)
114-
metric = OneIGAlignmentMetric(vlm=mock_vlm, vlm_type="litellm", device="cpu")
117+
metric = OneIGAlignmentMetric(vlm=mock_vlm, vlm_type="litellm", device="cpu", grid_size=(1, 1))
115118
aux = {"questions": {"1": None, "2": None}, "dependencies": {}}
116119
metric.update(["p"], [aux], torch.rand(1, 3, 64, 64))
117120
assert metric.compute().result == 0.0

0 commit comments

Comments
 (0)