Skip to content

Commit c10e5bc

Browse files
jiafatomCopilot
andauthored
Align vision VQA eval: dynamic choice detection, configurable max_length, robust error handling (microsoft#2499)
## Description Aligns the Olive JSON-based vision evaluation (`vision_vqa_pre_process`) with the standalone `eval.py` scripts in olive-recipes, and adds robustness improvements. ### Problem The JSON eval was reporting ~66% accuracy on AI2D while eval.py reported ~76% on the same CUDA model. The gap was caused by: 1. **0-based vs 1-based option numbering** — options were presented as `0. opt, 1. opt...` but VLMs prefer 1-based (`1. opt, 2. opt...`) 2. **Overly strict output parsing** — `re.match(r"^(\\d+)", pred)` only matched a leading digit, missing valid responses like "The answer is 2" ### Changes - **`olive/data/component/pre_process_data.py`**: - Use 1-based numbering for options and convert 0-based ground truth index to 1-based - Pass `num_choices` (actual count of options) instead of a boolean flag - Add configurable `max_length` parameter (default 4096), passable from JSON data config - **`olive/evaluator/olive_evaluator.py`**: - Build regex dynamically as `r"\\b([1-{num_choices}])\\b"` instead of hardcoded `[1-4]` - Only enable digit extraction when `num_choices` is 1-9 (single-digit range) - Read `max_length` from data config, falling back to 4096 default - Wrap entire per-image block in try/except so corrupt images log a warning with sample index instead of aborting the run ### Testing Validated on AI2D (3088 samples) with Qwen2.5-VL-3B-Instruct CUDA model — results now align with eval.py (~76% accuracy). --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d4582d9 commit c10e5bc

2 files changed

Lines changed: 98 additions & 57 deletions

File tree

olive/data/component/pre_process_data.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ def vision_vqa_pre_process(
389389
answer_col: str = "answer",
390390
options_col: str = "",
391391
system_prompt: str = "",
392+
max_length: int = 4096,
392393
max_samples: Optional[int] = None,
393394
limit: Optional[float] = None,
394395
seed: int = 42,
@@ -414,6 +415,8 @@ def vision_vqa_pre_process(
414415
options are formatted as numbered choices and appended to the question. Defaults to "".
415416
system_prompt: System prompt to guide model responses (e.g., "Reply with only the
416417
option number"). Passed through to the evaluator. Defaults to "".
418+
max_length: Maximum generation length (input + output tokens) for the VLM. Vision prompts
419+
with large images can exceed 3000 tokens due to vision patches. Defaults to 4096.
417420
max_samples: Maximum number of samples (deprecated, use limit). Defaults to None.
418421
limit: Sampling limit following Olive convention:
419422
If >= 1: use first N samples.
@@ -444,13 +447,23 @@ class VisionVQADataset:
444447
Note: Use batch_size=1 in dataloader config as images have variable sizes.
445448
"""
446449

447-
def __init__(self, hf_dataset, image_column, question_column, answer_column, options_column="", sys_prompt=""):
450+
def __init__(
451+
self,
452+
hf_dataset,
453+
image_column,
454+
question_column,
455+
answer_column,
456+
options_column="",
457+
sys_prompt="",
458+
max_length=4096,
459+
):
448460
self.dataset = hf_dataset
449461
self.image_column = image_column
450462
self.question_column = question_column
451463
self.answer_column = answer_column
452464
self.options_column = options_column
453465
self.system_prompt = sys_prompt
466+
self.max_length = max_length
454467

455468
def __len__(self):
456469
return len(self.dataset)
@@ -462,23 +475,35 @@ def __getitem__(self, idx):
462475
answer = item[self.answer_column]
463476

464477
# Format options into the question if options_col is specified
465-
has_options = False
478+
# Use 1-based numbering (1, 2, 3, 4) which aligns with how VLMs are
479+
# typically prompted and avoids confusion with diagram region labels.
480+
num_choices = 0
466481
if self.options_column and self.options_column in item:
467482
options = item[self.options_column]
468-
if isinstance(options, (list, tuple)):
469-
options_text = "\n".join(f"{i}. {opt}" for i, opt in enumerate(options))
483+
if isinstance(options, (list, tuple)) and len(options) > 0:
484+
num_choices = len(options)
485+
options_text = "\n".join(f"{i + 1}. {opt}" for i, opt in enumerate(options))
470486
question = f"{question}\n{options_text}"
471-
has_options = True
472487

473488
# Handle list/tuple answers (some datasets have multiple valid answers)
474489
# Join with | separator so metrics can match against any valid answer
475490
if isinstance(answer, (list, tuple)):
476491
answer = "|".join(str(a) for a in answer) if answer else ""
492+
493+
# Convert 0-based answer index to 1-based to match the option numbering
494+
if num_choices > 0:
495+
try:
496+
idx = int(answer)
497+
answer = str(idx + 1)
498+
except (ValueError, TypeError):
499+
pass # answer is already a non-numeric string (e.g., text label)
500+
477501
input_dict = {
478502
"image": image,
479503
"question": question,
480504
"system_prompt": self.system_prompt,
481-
"extract_number": has_options,
505+
"num_choices": num_choices,
506+
"max_length": self.max_length,
482507
}
483508
return input_dict, str(answer)
484509

@@ -496,4 +521,4 @@ def collate_fn(batch):
496521
answers = [item[1] for item in batch]
497522
return (inputs, answers)
498523

499-
return VisionVQADataset(dataset, image_col, question_col, answer_col, options_col, system_prompt)
524+
return VisionVQADataset(dataset, image_col, question_col, answer_col, options_col, system_prompt, max_length)

olive/evaluator/olive_evaluator.py

Lines changed: 66 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -847,11 +847,8 @@ def _inference_vision_genai(
847847

848848
model_dir = str(Path(model.model_path).parent)
849849

850-
# max_length in genai is total sequence length (input + output).
851-
# Default to 1028 which accommodates image/prompt tokens (~200-500) plus answer tokens.
852-
# Note: genai_config.json's search.max_length is typically the full context window
853-
# (e.g., 262144) which is too large — the model will stop at EOS well before this cap.
854-
max_length = 1028
850+
# Default max_length; can be overridden per-sample from the data config.
851+
default_max_length = 4096
855852

856853
# Build og.Model with appropriate execution provider
857854
# Note: onnxruntime-genai uses CPU by default when no provider is appended.
@@ -872,6 +869,7 @@ def _inference_vision_genai(
872869
with tempfile.TemporaryDirectory() as tmp_dir:
873870
tmp_img_path = Path(tmp_dir) / "input.png"
874871

872+
sample_idx = 0
875873
for batch in dataloader:
876874
input_data, labels = OliveEvaluator.unpack_batch_for_accuracy(batch)
877875

@@ -883,59 +881,77 @@ def _inference_vision_genai(
883881
pil_image = item.get("image")
884882
question = item.get("question", "")
885883
sys_prompt = item.get("system_prompt", "")
886-
extract_number = item.get("extract_number", False)
884+
num_choices = item.get("num_choices", 0)
885+
max_length = item.get("max_length", default_max_length)
887886

888887
if pil_image is None:
889888
# Append empty pred to maintain alignment with targets
890889
all_preds.append("")
890+
sample_idx += 1
891891
continue
892892

893-
# Ensure PIL Image
894-
if not isinstance(pil_image, Image.Image):
895-
with Image.open(pil_image) as img:
896-
pil_image = img.convert("RGB")
897-
898-
# Build chat messages for the vision-language model
899-
messages = []
900-
if sys_prompt:
901-
messages.append({"role": "system", "content": sys_prompt})
902-
messages.append(
903-
{
904-
"role": "user",
905-
"content": [
906-
{"type": "image"},
907-
{"type": "text", "text": question},
908-
],
909-
}
910-
)
911-
messages_json = json.dumps(messages)
912-
913-
# Save image to temp file for og.Images (reuse same path to minimize I/O)
914-
pil_image.save(str(tmp_img_path), format="PNG")
915-
images = og.Images.open(str(tmp_img_path))
916-
917-
prompt = tokenizer.apply_chat_template(messages_json, add_generation_prompt=True)
918-
inputs = processor(prompt, images=images)
919-
920-
params = og.GeneratorParams(og_model)
921-
params.set_search_options(max_length=max_length, do_sample=False)
922-
923-
generator = og.Generator(og_model, params)
924-
generator.set_inputs(inputs)
925-
926-
tokens = []
927-
while not generator.is_done():
928-
generator.generate_next_token()
929-
tokens.append(generator.get_next_tokens()[0])
930-
del generator
931-
932-
pred = tokenizer.decode(tokens).strip()
933-
# For multiple-choice tasks, extract leading number from responses
934-
# like "1. D" or "0. krill" to match the expected answer format
935-
if extract_number:
936-
num_match = re.match(r"^(\d+)", pred)
893+
try:
894+
# Ensure PIL Image
895+
if not isinstance(pil_image, Image.Image):
896+
with Image.open(pil_image) as img:
897+
pil_image = img.convert("RGB")
898+
899+
# Build chat messages for the vision-language model
900+
messages = []
901+
if sys_prompt:
902+
messages.append({"role": "system", "content": sys_prompt})
903+
messages.append(
904+
{
905+
"role": "user",
906+
"content": [
907+
{"type": "image"},
908+
{"type": "text", "text": question},
909+
],
910+
}
911+
)
912+
messages_json = json.dumps(messages)
913+
914+
# Save image to temp file for og.Images (reuse same path to minimize I/O)
915+
pil_image.save(str(tmp_img_path), format="PNG")
916+
images = og.Images.open(str(tmp_img_path))
917+
918+
prompt = tokenizer.apply_chat_template(messages_json, add_generation_prompt=True)
919+
inputs = processor(prompt, images=images)
920+
921+
params = og.GeneratorParams(og_model)
922+
params.set_search_options(max_length=max_length, do_sample=False)
923+
924+
generator = og.Generator(og_model, params)
925+
generator.set_inputs(inputs)
926+
927+
tokens = []
928+
while not generator.is_done():
929+
generator.generate_next_token()
930+
tokens.append(generator.get_next_tokens()[0])
931+
del generator
932+
933+
pred = tokenizer.decode(tokens).strip()
934+
except Exception as e:
935+
logger.warning("Skipping sample %d due to error: %s", sample_idx, e)
936+
pred = ""
937+
938+
sample_idx += 1
939+
940+
# For multiple-choice tasks, extract the answer digit from responses
941+
# like "2", "The answer is 3", or "1. D" to match the expected answer format.
942+
# Only enabled when num_choices is between 1 and 9 (single-digit options).
943+
if 1 <= num_choices <= 9 and pred:
944+
pattern = rf"\b([1-{num_choices}])\b"
945+
num_match = re.search(pattern, pred)
937946
if num_match:
938947
pred = num_match.group(1)
948+
else:
949+
# Fallback: find any single digit in the valid range
950+
valid_digits = {str(d) for d in range(1, num_choices + 1)}
951+
for ch in pred:
952+
if ch in valid_digits:
953+
pred = ch
954+
break
939955
all_preds.append(pred)
940956

941957
# Collect reference texts (aligned with preds including empty ones for None images)

0 commit comments

Comments
 (0)