Skip to content

Commit c6a0f7c

Browse files
committed
update dataset
1 parent 12736b1 commit c6a0f7c

6 files changed

Lines changed: 2381 additions & 1046 deletions

File tree

examples/vision_food_reasoning_dataset/data/vision_food_reasoning_full.jsonl

Lines changed: 1000 additions & 1000 deletions
Large diffs are not rendered by default.

examples/vision_food_reasoning_dataset/data/vision_food_reasoning_raw_full.jsonl

Lines changed: 1000 additions & 0 deletions
Large diffs are not rendered by default.

examples/vision_food_reasoning_dataset/data/vision_food_reasoning_sample.jsonl

Lines changed: 120 additions & 8 deletions
Large diffs are not rendered by default.

examples/vision_food_reasoning_dataset/requirements.txt

Whitespace-only changes.
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Utility script to convert the raw fireworks vision-food-reasoning dataset into native
4+
Eval Protocol EvaluationRow JSONL files so that the default dataset adapter can be used.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import argparse
10+
import json
11+
import re
12+
from pathlib import Path
13+
from typing import Any, Iterable
14+
15+
DATASET_SOURCE_ID = "fireworks-ai/vision-food-reasoning-dataset"
16+
17+
_BOLD_LABEL_PATTERN = re.compile(r"\*\*(?P<label>[^*]+)\*\*")
18+
_APPEARS_PATTERN = re.compile(r"appears to be\s+(?P<label>[A-Za-z0-9_\- ]+)", re.IGNORECASE)
19+
_IS_PATTERN = re.compile(r"is\s+(?:a|an|the)?\s*(?P<label>[A-Za-z0-9_\- ]+)", re.IGNORECASE)
20+
_SECTION_HEADINGS = {
21+
"visual characteristics",
22+
"texture and shape",
23+
"texture",
24+
"shape",
25+
"cooking method or preparation style",
26+
"cooking method",
27+
"preparation style",
28+
"cultural context or typical presentation",
29+
"cultural context",
30+
"presentation",
31+
"distinguishing features",
32+
"ingredients",
33+
"aroma",
34+
"flavor profile",
35+
}
36+
37+
38+
def _normalize_label(label: str | None) -> str:
39+
if not label:
40+
return ""
41+
cleaned = re.sub(r"[^a-z0-9]+", "_", label.lower())
42+
cleaned = re.sub(r"_+", "_", cleaned).strip("_")
43+
return cleaned
44+
45+
46+
def _content_to_text(content: Any) -> str:
47+
if content is None:
48+
return ""
49+
if isinstance(content, str):
50+
return content
51+
if isinstance(content, Iterable):
52+
parts: list[str] = []
53+
for part in content:
54+
if isinstance(part, dict) and part.get("type") == "text":
55+
text_val = part.get("text")
56+
if isinstance(text_val, str):
57+
parts.append(text_val)
58+
return "\n".join(parts)
59+
return ""
60+
61+
62+
def _extract_label_from_text(text: str) -> str | None:
63+
if not text:
64+
return None
65+
bold_matches = _BOLD_LABEL_PATTERN.findall(text)
66+
if bold_matches:
67+
for candidate in reversed(bold_matches):
68+
normalized = candidate.strip().lower()
69+
if normalized not in _SECTION_HEADINGS:
70+
return candidate.strip()
71+
for pattern in (_APPEARS_PATTERN, _IS_PATTERN):
72+
match = pattern.search(text)
73+
if match:
74+
label = match.group("label").strip()
75+
if len(label.split()) <= 5:
76+
return label
77+
sentences = [segment.strip() for segment in re.split(r"[.!?\n]+", text) if segment.strip()]
78+
if sentences:
79+
tail = sentences[-1]
80+
tokens = re.findall(r"[A-Za-z][A-Za-z0-9_\- ]+", tail)
81+
if tokens:
82+
return tokens[-1].strip()
83+
return None
84+
85+
86+
def convert_dataset(input_path: Path, output_path: Path) -> None:
87+
rows: list[dict[str, Any]] = []
88+
with input_path.open() as infile:
89+
for line in infile:
90+
line = line.strip()
91+
if not line:
92+
continue
93+
rows.append(json.loads(line))
94+
95+
converted_rows: list[dict[str, Any]] = []
96+
skipped = 0
97+
98+
for idx, raw in enumerate(rows):
99+
messages_payload = raw.get("messages")
100+
if not isinstance(messages_payload, list) or len(messages_payload) < 2:
101+
skipped += 1
102+
continue
103+
104+
assistant_reference = messages_payload[-1]
105+
prompt_messages = [
106+
message
107+
for message in messages_payload[:-1]
108+
if isinstance(message, dict) and message.get("role") in {"system", "user"}
109+
]
110+
if not prompt_messages:
111+
skipped += 1
112+
continue
113+
114+
reference_text = _content_to_text(assistant_reference.get("content"))
115+
raw_label = _extract_label_from_text(reference_text)
116+
normalized_label = _normalize_label(raw_label)
117+
if not normalized_label:
118+
skipped += 1
119+
continue
120+
121+
row_id = str(raw.get("id") or f"vision_food_reasoning_{idx}")
122+
converted_rows.append(
123+
{
124+
"messages": prompt_messages,
125+
"ground_truth": {
126+
"label": normalized_label,
127+
"raw_label": raw_label or "",
128+
"reference_answer": reference_text,
129+
},
130+
"input_metadata": {
131+
"row_id": row_id,
132+
"dataset_info": {
133+
"source": DATASET_SOURCE_ID,
134+
"normalized_label": normalized_label,
135+
},
136+
},
137+
}
138+
)
139+
140+
with output_path.open("w") as outfile:
141+
for row in converted_rows:
142+
outfile.write(json.dumps(row, ensure_ascii=False) + "\n")
143+
144+
print(f"Converted {len(converted_rows)} rows (skipped {skipped}) from {input_path} -> {output_path}")
145+
146+
147+
def main() -> None:
148+
parser = argparse.ArgumentParser(description="Convert raw vision food reasoning dataset.")
149+
parser.add_argument("--input", required=True, type=Path, help="Path to the raw JSONL dataset.")
150+
parser.add_argument(
151+
"--output",
152+
required=True,
153+
type=Path,
154+
help="Destination JSONL path for the converted EvaluationRow dataset.",
155+
)
156+
args = parser.parse_args()
157+
convert_dataset(args.input, args.output)
158+
159+
160+
if __name__ == "__main__":
161+
main()

examples/vision_food_reasoning_dataset/tests/test_vision_food_reasoning.py

Lines changed: 100 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
1+
import json
2+
import os
13
import re
24
from pathlib import Path
35
from typing import Any
46

7+
import litellm
8+
59
from eval_protocol.models import (
610
EvaluateResult,
711
EvaluationRow,
812
Message,
913
MetricResult,
1014
ChatCompletionContentPartTextParam,
1115
)
12-
from eval_protocol.pytest.default_single_turn_rollout_process import (
13-
SingleTurnRolloutProcessor,
14-
)
16+
from eval_protocol.pytest.default_single_turn_rollout_process import SingleTurnRolloutProcessor
1517
from eval_protocol.pytest.evaluation_test import evaluation_test
1618

1719
DATASET_PATH = Path(__file__).resolve().parents[1] / "data" / "vision_food_reasoning_sample.jsonl"
18-
DATASET_SOURCE_ID = "fireworks-ai/vision-food-reasoning-dataset"
1920

2021
_BOLD_LABEL_PATTERN = re.compile(r"\*\*(?P<label>[^*]+)\*\*")
2122
_APPEARS_PATTERN = re.compile(r"appears to be\s+(?P<label>[A-Za-z0-9_\- ]+)", re.IGNORECASE)
@@ -84,35 +85,6 @@ def _extract_label_from_text(text: str) -> str | None:
8485
return None
8586

8687

87-
def vision_food_reasoning_dataset_adapter(rows: list[dict[str, Any]]) -> list[EvaluationRow]:
88-
adapted: list[EvaluationRow] = []
89-
for idx, raw in enumerate(rows):
90-
messages_payload = raw.get("messages")
91-
if not isinstance(messages_payload, list) or len(messages_payload) < 2:
92-
continue
93-
try:
94-
user_message = Message.model_validate(messages_payload[0])
95-
assistant_reference = Message.model_validate(messages_payload[-1])
96-
except Exception:
97-
continue
98-
reference_text = _content_to_text(assistant_reference.content)
99-
raw_label = _extract_label_from_text(reference_text)
100-
normalized_label = _normalize_label(raw_label)
101-
if not normalized_label:
102-
continue
103-
row = EvaluationRow(messages=[user_message])
104-
row.input_metadata.row_id = str(raw.get("id") or f"vision_food_reasoning_{idx}")
105-
row.input_metadata.dataset_info = {
106-
"source": DATASET_SOURCE_ID,
107-
"normalized_label": normalized_label,
108-
}
109-
row.ground_truth = {"label": normalized_label, "raw_label": raw_label or ""}
110-
adapted.append(row)
111-
if not adapted:
112-
raise RuntimeError("Vision food reasoning adapter returned no usable rows.")
113-
return adapted
114-
115-
11688
def _extract_prediction(row: EvaluationRow) -> tuple[str, str]:
11789
assistant_messages = [m for m in row.messages if m.role == "assistant"]
11890
if not assistant_messages:
@@ -122,6 +94,80 @@ def _extract_prediction(row: EvaluationRow) -> tuple[str, str]:
12294
return label, text
12395

12496

97+
def _llm_equivalence_check(
98+
ground_truth_label: str,
99+
prediction_text: str,
100+
*,
101+
judge_model: str | None = None,
102+
) -> tuple[bool, str]:
103+
model_name = (
104+
judge_model
105+
or os.getenv("VISION_FOOD_REASONING_JUDGE_MODEL")
106+
or "fireworks_ai/accounts/fireworks/models/gpt-oss-120b"
107+
)
108+
prediction_text = prediction_text.strip()
109+
if not prediction_text:
110+
return False, "LLM judge skipped: prediction text is empty."
111+
112+
system_prompt = (
113+
"You are a strict food classification judge. "
114+
"Given the ground-truth dish label and a model response, decide whether the response "
115+
"unambiguously identifies the same dish. "
116+
"Only consider the final answer portion; ignore speculation or unrelated commentary. "
117+
'Respond with compact JSON like {"equivalent": true, "reason": "..."}.'
118+
)
119+
user_prompt = (
120+
"GROUND TRUTH LABEL: {label}\n"
121+
'MODEL RESPONSE:\n"""\n{response}\n"""\n\n'
122+
"If the model clearly identifies the same dish, set equivalent=true, otherwise false. "
123+
"Explain the decision in the reason."
124+
).format(label=ground_truth_label, response=prediction_text)
125+
126+
try:
127+
completion = litellm.completion(
128+
model=model_name,
129+
temperature=0,
130+
messages=[
131+
{"role": "system", "content": system_prompt},
132+
{"role": "user", "content": user_prompt},
133+
],
134+
)
135+
raw_content = completion["choices"][0]["message"]["content"]
136+
except Exception as exc: # pragma: no cover - depends on external service
137+
return False, f"LLM judge failed: {exc}"
138+
139+
if not raw_content:
140+
return False, "LLM judge returned empty response."
141+
142+
parsed = _parse_json_blob(raw_content)
143+
if not isinstance(parsed, dict):
144+
return False, f"LLM judge did not return JSON: {raw_content}"
145+
146+
decision = parsed.get("equivalent")
147+
reason = parsed.get("reason") or "LLM judge provided no reason."
148+
if isinstance(decision, str):
149+
decision = decision.strip().lower() in {"true", "yes", "1"}
150+
elif not isinstance(decision, bool):
151+
decision = False
152+
reason = f"LLM judge missing boolean decision. Raw: {raw_content}"
153+
return bool(decision), reason
154+
155+
156+
def _parse_json_blob(blob: str) -> Any:
157+
try:
158+
return json.loads(blob)
159+
except json.JSONDecodeError:
160+
start = blob.find("{")
161+
end = blob.rfind("}")
162+
if start != -1 and end != -1 and start < end:
163+
snippet = blob[start : end + 1]
164+
try:
165+
return json.loads(snippet)
166+
except json.JSONDecodeError:
167+
return None
168+
return None
169+
170+
125171
def _ground_truth_label(row: EvaluationRow) -> str:
126172
if isinstance(row.ground_truth, dict):
127173
return _normalize_label(row.ground_truth.get("label"))
@@ -132,10 +178,9 @@ def _ground_truth_label(row: EvaluationRow) -> str:
132178

133179
@evaluation_test(
134180
input_dataset=[str(DATASET_PATH)],
135-
dataset_adapter=vision_food_reasoning_dataset_adapter,
136181
completion_params=[
137182
{
138-
"model": "fireworks_ai/accounts/fireworks/models/qwen3-vl-30b-a3b-instruct",
183+
"model": "fireworks_ai/accounts/fireworks/models/qwen3-vl-235b-a22b-instruct",
139184
# "max_tokens": 512,
140185
# "model": "openrouter/qwen/qwen3-vl-30b-a3b-instruct",
141186
# "model": "gpt-4.1-mini",
@@ -151,8 +196,8 @@ def _ground_truth_label(row: EvaluationRow) -> str:
151196
rollout_processor=SingleTurnRolloutProcessor(),
152197
aggregation_method="mean",
153198
passed_threshold=None,
154-
num_runs=1,
155199
max_dataset_rows=10,
200+
num_runs=1,
156201
mode="pointwise",
157202
)
158203
def test_vision_food_reasoning(row: EvaluationRow) -> EvaluationRow:
@@ -161,11 +206,23 @@ def test_vision_food_reasoning(row: EvaluationRow) -> EvaluationRow:
161206

162207
is_valid = bool(predicted_label)
163208
is_correct = is_valid and predicted_label == ground_truth_label and bool(ground_truth_label)
209+
210+
llm_equivalent = False
211+
llm_reason = "LLM judge not triggered."
212+
if not is_correct and ground_truth_label:
213+
llm_equivalent, llm_reason = _llm_equivalence_check(ground_truth_label, raw_prediction)
214+
if llm_equivalent:
215+
is_correct = True
216+
is_valid = True
217+
164218
score = 1.0 if is_correct else 0.0
219+
reason = "Prediction matches ground truth" if is_correct else "Prediction did not match"
220+
if llm_equivalent:
221+
reason = "LLM judge considered the prediction equivalent to the ground truth."
165222

166223
row.evaluation_result = EvaluateResult(
167224
score=score,
168-
reason="Prediction matches ground truth" if is_correct else "Prediction did not match",
225+
reason=reason,
169226
is_score_valid=is_valid,
170227
metrics={
171228
"exact_match": MetricResult(
@@ -177,7 +234,12 @@ def test_vision_food_reasoning(row: EvaluationRow) -> EvaluationRow:
177234
"ground_truth_label": ground_truth_label,
178235
"raw_prediction": raw_prediction,
179236
},
180-
)
237+
),
238+
"llm_equivalence": MetricResult(
239+
score=1.0 if llm_equivalent else 0.0,
240+
is_score_valid=llm_equivalent,
241+
reason=llm_reason,
242+
),
181243
},
182244
)
183245
return row

0 commit comments

Comments
 (0)