Files
wehub-resource-sync 542cfa195c
CI / Frontend build (push) Failing after 9m6s
CI / Plugin validate (push) Failing after 9m27s
CI / Python lint (push) Failing after 16m1s
CI / Tests (push) Successful in 18m0s
Deploy / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:27 +08:00

1275 lines
42 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Data loading and preprocessing for SimpleQA evaluation.
This module handles all data preparation:
- Loading the SimpleQA dataset
- Extracting URLs from metadata
- Capturing screenshots from URLs
- Fetching text content from URLs
"""
import ast
import asyncio
import hashlib
import json
import logging
import os
import re
import urllib.parse
import pandas as pd
import trafilatura
logger = logging.getLogger(__name__)
# ============================================================================
# Data Loading
# ============================================================================
def load_simpleqa_data(num_examples: int | None = None) -> list[dict]:
"""Load SimpleQA dataset.
Args:
num_examples: Optional limit on number of examples to load.
Returns:
List of example dictionaries with 'id', 'problem', 'answer', etc.
"""
logger.info("Loading SimpleQA dataset...")
try:
local_path = "evaluation/simple_qa_eval/data/simple_qa_test_set.csv"
if os.path.exists(local_path):
df = pd.read_csv(local_path)
else:
url = "https://openaipublic.blob.core.windows.net/simple-evals/simple_qa_test_set.csv"
df = pd.read_csv(url)
except Exception as e:
logger.error(f"Failed to load dataset: {e}")
url = "https://openaipublic.blob.core.windows.net/simple-evals/simple_qa_test_set.csv"
df = pd.read_csv(url)
# Ensure stable ordering: reset index to maintain original CSV row order
df = df.reset_index(drop=True)
# Generate unique ID from problem text
df["id"] = df["problem"].apply(
lambda problem: hashlib.md5(problem.encode()).hexdigest()
)
# Convert to list of dicts, maintaining original CSV order
data = [row.to_dict() for _, row in df.iterrows()]
if num_examples:
logger.info(f"Limiting to first {num_examples} examples.")
data = data[:num_examples]
logger.info(f"Loaded {len(data)} examples.")
return data
def load_simpleqa_verified_data(num_examples: int | None = None) -> list[dict]:
"""Load SimpleQA Verified dataset from Hugging Face.
Args:
num_examples: Optional limit on number of examples to load.
Returns:
List of example dictionaries with 'id', 'problem', 'answer', etc.
Compatible format with SimpleQA dataset.
"""
logger.info("Loading SimpleQA Verified dataset...")
try:
# Try using datasets library first (recommended)
try:
from datasets import load_dataset
logger.info("Using Hugging Face datasets library...")
dataset = load_dataset("google/simpleqa-verified", split="eval")
df = dataset.to_pandas()
except ImportError:
logger.warning("datasets library not available, trying alternative methods")
# Fallback: try Hugging Face datasets-server API
try:
import requests
logger.info("Trying Hugging Face datasets-server API...")
api_url = "https://datasets-server.huggingface.co/parquet?dataset=google%2Fsimpleqa-verified&config=simpleqa_verified&split=eval"
response = requests.get(api_url, timeout=60)
if response.status_code == 200:
import io
df = pd.read_parquet(io.BytesIO(response.content))
logger.info("Successfully loaded via datasets-server API")
else:
raise Exception(
f"Failed to download dataset: HTTP {response.status_code}"
)
except Exception as e:
logger.error(f"Failed to load via API: {e}")
# Last resort: try direct file download
try:
logger.info("Trying direct file download...")
# Try parquet file
parquet_url = "https://huggingface.co/datasets/google/simpleqa-verified/resolve/main/data/eval-00000-of-00001.parquet"
df = pd.read_parquet(parquet_url)
logger.info("Successfully loaded via direct file download")
except Exception as e2:
logger.error(f"Failed to load via direct download: {e2}")
raise Exception(
"All methods failed. Please install 'datasets' library: pip install datasets"
)
except Exception as e:
logger.error(f"Failed to load SimpleQA Verified dataset: {e}")
raise
# Ensure stable ordering: reset index to maintain original order
df = df.reset_index(drop=True)
# Convert to compatible format with SimpleQA
# SimpleQA Verified has: original_index, problem, answer, topic, answer_type, multi_step, requires_reasoning, urls
# SimpleQA has: metadata (with urls), problem, answer, id
# Generate unique ID from problem text (same as SimpleQA)
df["id"] = df["problem"].apply(
lambda problem: hashlib.md5(problem.encode()).hexdigest()
)
# Convert urls to list format if it's a string
def normalize_urls(urls):
"""Normalize URLs to list format."""
if isinstance(urls, str):
# Try to parse as list string
try:
import ast
return ast.literal_eval(urls)
except Exception:
# Split by comma if it's a comma-separated string
return [u.strip() for u in urls.split(",") if u.strip()]
elif isinstance(urls, list):
return urls
else:
return []
# Normalize URLs column
if "urls" in df.columns:
df["urls"] = df["urls"].apply(normalize_urls)
else:
df["urls"] = [[]] * len(df)
# Convert to metadata format compatible with SimpleQA
def create_metadata(row):
"""Create metadata dict compatible with SimpleQA format."""
metadata = {
"topic": str(row.get("topic", "")),
"answer_type": str(row.get("answer_type", "")),
"urls": row.get("urls", []),
}
if "multi_step" in row and pd.notna(row["multi_step"]):
metadata["multi_step"] = bool(row["multi_step"])
if "requires_reasoning" in row and pd.notna(row["requires_reasoning"]):
metadata["requires_reasoning"] = bool(row["requires_reasoning"])
if "original_index" in row and pd.notna(row["original_index"]):
metadata["original_index"] = int(row["original_index"])
# Convert to string format similar to SimpleQA (using single quotes for Python dict string)
return str(metadata)
df["metadata"] = df.apply(create_metadata, axis=1)
# Convert to list of dicts, maintaining original order
data = [row.to_dict() for _, row in df.iterrows()]
if num_examples:
logger.info(f"Limiting to first {num_examples} examples.")
data = data[:num_examples]
logger.info(f"Loaded {len(data)} SimpleQA Verified examples.")
return data
def load_text_cache(cache_path: str) -> dict:
"""Load pre-fetched text from JSONL file.
Args:
cache_path: Path to JSONL file with cached text.
Returns:
Dict mapping example ID to cached item.
"""
logger.info(f"Loading text cache from {cache_path}...")
cache = {}
with open(cache_path, "r") as f:
for line in f:
item = json.loads(line)
cache[item["id"]] = item
logger.info(f"Loaded {len(cache)} cached items.")
return cache
# ============================================================================
# URL Extraction
# ============================================================================
def extract_url_from_metadata(example: dict) -> str | None:
"""Extract URL from example metadata.
Args:
example: Example dict with 'metadata' field.
Returns:
Extracted URL or None.
"""
meta = example.get("metadata")
if isinstance(meta, str):
try:
meta = json.loads(meta)
except json.JSONDecodeError:
try:
meta = ast.literal_eval(meta)
except (ValueError, SyntaxError):
pass
target_url = None
if isinstance(meta, dict):
if "url" in meta:
target_url = meta["url"]
elif (
"urls" in meta and isinstance(meta["urls"], list) and len(meta["urls"]) > 0
):
# Flatten URLs: some entries have multiple URLs concatenated in a single string
# (separated by newlines OR directly joined like "https://a.comhttps://b.com")
all_urls = []
for url_entry in meta["urls"]:
if isinstance(url_entry, str):
# Split on "https://" boundaries to handle concatenated URLs
parts = re.split(r"(?=https?://)", url_entry)
for part in parts:
part = part.strip().rstrip(",'\"").strip("- ").strip()
if part and re.match(r"https?://", part):
all_urls.append(part)
# Prefer en.wikipedia.org article URLs (exclude non-English and Category pages)
wikipedia_urls = [
u
for u in all_urls
if "en.wikipedia.org/wiki/" in u
and "/Category:" not in u
and "wikipedia-on-ipfs" not in u.lower()
]
if wikipedia_urls:
target_url = wikipedia_urls[0]
else:
# Secondary: wikimedia.org URLs (e.g., commons.wikimedia.org)
wikimedia_urls = [u for u in all_urls if "wikimedia.org" in u.lower()]
target_url = (
wikimedia_urls[0]
if wikimedia_urls
else (all_urls[0] if all_urls else None)
)
# Extract first valid URL from the string
if target_url:
url_match = re.search(r"https?://[^\s<>\"{}|\\^`\[\]]+", target_url)
target_url = url_match.group(0) if url_match else None
# Note by Yichuan: strip URL fragment (#section) so that URLs differing
# only by anchor are treated as the same page for deduplication and
# retrieval-accuracy matching.
if target_url and "#" in target_url:
target_url = target_url.split("#")[0]
return target_url
# ============================================================================
# Screenshot Capture
# ============================================================================
# Lazy import screenshot utilities
_capture_screenshot = None
_encode_image = None
_encode_image_for_vlm = None
def _init_screenshot_utils():
"""Initialize screenshot utilities (lazy import)."""
global _capture_screenshot, _encode_image, _encode_image_for_vlm
if _capture_screenshot is not None:
return True
try:
from .screenshot import capture_screenshot, encode_image, encode_image_for_vlm
_capture_screenshot = capture_screenshot
_encode_image = encode_image
_encode_image_for_vlm = encode_image_for_vlm
return True
except ImportError:
logger.warning("Screenshot utilities not available")
return False
def capture_screenshot_for_example(
example: dict, screenshot_dir: str = "screenshots"
) -> str | None:
"""Capture screenshot for a single example.
Args:
example: Example dict with metadata containing URL.
screenshot_dir: Directory to save screenshots.
Returns:
Path to screenshot file, or None if failed.
"""
if not _init_screenshot_utils():
return None
target_url = extract_url_from_metadata(example)
if not target_url:
return None
os.makedirs(screenshot_dir, exist_ok=True)
screenshot_filename = f"{example['id']}_fullhd.png"
screenshot_path = os.path.join(screenshot_dir, screenshot_filename)
# Check if valid screenshot already exists
if os.path.exists(screenshot_path) and os.path.getsize(screenshot_path) > 0:
logger.debug(f"Screenshot exists: {screenshot_path}")
return screenshot_path
# Capture screenshot
try:
if _capture_screenshot is None:
return None
success = _capture_screenshot(target_url, screenshot_path, True)
if (
success
and os.path.exists(screenshot_path)
and os.path.getsize(screenshot_path) > 0
):
file_size = os.path.getsize(screenshot_path) // 1024
logger.info(f"Screenshot saved: {screenshot_path} ({file_size}KB)")
return screenshot_path
else:
logger.warning(
f"Screenshot failed (no output): {target_url} -> {screenshot_path}"
)
except Exception as e:
logger.error(f"Screenshot error for {target_url}: {e}")
return None
async def capture_screenshot_async(
example: dict, screenshot_dir: str = "screenshots"
) -> str | None:
"""Async wrapper for screenshot capture."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None, capture_screenshot_for_example, example, screenshot_dir
)
def encode_screenshot(screenshot_path: str) -> str | None:
"""Encode screenshot to base64.
Args:
screenshot_path: Path to screenshot file, or already-encoded base64 string.
Returns:
Base64 encoded string, or None if failed.
"""
if not screenshot_path:
return None
if not os.path.exists(screenshot_path):
if len(screenshot_path) > 500 and "/" not in screenshot_path[:20]:
return screenshot_path
return None
if not _init_screenshot_utils():
return None
try:
if _encode_image is None:
return None
return _encode_image(screenshot_path)
except Exception as e:
logger.error(f"Image encoding failed for {screenshot_path}: {e}")
return None
async def encode_screenshot_async(screenshot_path: str) -> str | None:
"""Async wrapper for screenshot encoding."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, encode_screenshot, screenshot_path)
def encode_screenshot_for_vlm(
screenshot_path: str, max_pixels: int | None = None
) -> str | None:
"""Encode screenshot for VLM ground truth with configurable max_pixels.
Unlike encode_screenshot(), this function does NOT apply max_height limit.
You can control max_pixels to study the effect of resize on VLM performance.
Args:
screenshot_path: Path to screenshot file.
max_pixels: Maximum pixels before resize. If None, uses default (89M).
Common values:
- 16_777_216 (16M): Qwen3-VL default
- 12_845_056 (12.8M): Qwen2-VL default
- 4_000_000 (4M): ~4000 tokens
- 1_000_000 (1M): ~1000 tokens
Returns:
Base64 encoded string, or None if failed.
"""
if not _init_screenshot_utils():
return None
if not screenshot_path or not os.path.exists(screenshot_path):
return None
try:
if _encode_image_for_vlm is None:
return None
if max_pixels is not None:
return _encode_image_for_vlm(screenshot_path, max_pixels=max_pixels)
return _encode_image_for_vlm(screenshot_path)
except Exception as e:
logger.error(f"Image encoding (VLM) failed for {screenshot_path}: {e}")
return None
async def encode_screenshot_for_vlm_async(
screenshot_path: str, max_pixels: int | None = None
) -> str | None:
"""Async wrapper for VLM screenshot encoding."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None, encode_screenshot_for_vlm, screenshot_path, max_pixels
)
# ============================================================================
# Pixel-Compressed Encoding for Generation
# ============================================================================
def make_compressed_encoder(compress_ratio: int, save_dir: str | None = None):
"""Create an image encoder that downscales images before encoding to base64.
The compression ratio N divides the total pixel count by N, i.e. each
dimension is scaled by 1/sqrt(N). For a 1024x1024 tile:
- ratio 1 -> 1024x1024 (no compression, baseline)
- ratio 4 -> 512x512
- ratio 9 -> ~341x341
- ratio 16 -> 256x256
- ratio 25 -> ~205x205
Uses LANCZOS resampling (best quality for downscaling).
Compressed images are saved to ``save_dir`` (if provided) so they can
be visually inspected later. The mapping from original path to saved
compressed path is recorded in ``encoder.compressed_paths`` (a dict
attached to the returned function object).
Args:
compress_ratio: Pixel compression ratio (1 = no compression).
save_dir: Directory to save compressed images. If None, a default
directory ``compressed_tiles_{ratio}x`` is used.
Returns:
A function with the same signature as ``encode_screenshot`` that
first downscales the image, then encodes it to base64.
The function has an attribute ``compressed_paths: dict[str, str]``
mapping original_path -> compressed_path.
"""
if compress_ratio <= 1:
# No compression use the normal encoder
return encode_screenshot
import math
scale_factor = 1.0 / math.sqrt(compress_ratio)
# Set up save directory
if save_dir is None:
save_dir = f"compressed_tiles_{compress_ratio}x"
os.makedirs(save_dir, exist_ok=True)
logger.info(
f"Pixel compression enabled: ratio={compress_ratio}, "
f"scale_factor={scale_factor:.4f} per dimension, "
f"saving compressed images to {save_dir}"
)
# Shared dict to track original -> compressed path mapping
_compressed_paths: dict[str, str] = {}
def _compressed_encode(screenshot_path: str) -> str | None:
"""Encode image with pixel compression and save to disk."""
import base64 as _b64
from io import BytesIO
from PIL import Image as _Image
if not screenshot_path or not os.path.exists(screenshot_path):
return None
try:
_Image.MAX_IMAGE_PIXELS = 300_000_000
with _Image.open(screenshot_path) as img:
new_w = max(1, int(img.width * scale_factor))
new_h = max(1, int(img.height * scale_factor))
if img.mode != "RGB":
img = img.convert("RGB")
img_resized = img.resize((new_w, new_h), _Image.Resampling.LANCZOS)
# Save compressed image to disk
basename = os.path.splitext(os.path.basename(screenshot_path))[0]
compressed_filename = f"{basename}_compress{compress_ratio}x.png"
compressed_path = os.path.join(save_dir, compressed_filename)
img_resized.save(compressed_path, format="PNG")
_compressed_paths[screenshot_path] = compressed_path
# Encode to base64 from the saved file
buf = BytesIO()
img_resized.save(buf, format="PNG")
return _b64.b64encode(buf.getvalue()).decode("utf-8")
except Exception as e:
logger.error(f"Compressed encode failed for {screenshot_path}: {e}")
return None
# Attach the path mapping dict to the function so callers can access it
_compressed_encode.compressed_paths = _compressed_paths
_compressed_encode.compress_ratio = compress_ratio
_compressed_encode.save_dir = save_dir
return _compressed_encode
# ============================================================================
# Text Fetching
# ============================================================================
def fetch_webpage_text(url: str, max_chars: int = 50000) -> str | None:
"""Fetch webpage and extract clean text content using trafilatura.
Args:
url: URL to fetch.
max_chars: Maximum characters to return.
Returns:
Extracted text content, or None if failed.
"""
try:
downloaded = trafilatura.fetch_url(url)
if downloaded is None:
logger.warning(f"Failed to download {url}")
return None
text = trafilatura.extract(
downloaded,
include_comments=False,
include_tables=True,
no_fallback=False,
)
if text is None:
logger.warning(f"Failed to extract text from {url}")
return None
# Clean up excessive newlines
text = re.sub(r"\n{3,}", "\n\n", text)
# Truncate if needed
if max_chars and len(text) > max_chars:
text = text[:max_chars] + "\n...[truncated]"
return text
except Exception as e:
logger.warning(f"Fetch failed for {url}: {e}")
return None
def fetch_text_for_example(
example: dict, max_chars: int = 50000, text_cache: dict | None = None
) -> tuple[str | None, str | None]:
"""Fetch text content for a single example.
Args:
example: Example dict with metadata containing URL.
max_chars: Maximum characters to return.
text_cache: Optional pre-fetched text cache.
Returns:
Tuple of (text_content, source_url).
"""
example_id = example.get("id")
# Check cache first
if text_cache and example_id in text_cache:
cached = text_cache[example_id]
text = cached.get("text")
url = cached.get("extracted_url")
if text:
return text, url
# Extract URL and fetch
target_url = extract_url_from_metadata(example)
if not target_url:
return None, None
text = fetch_webpage_text(target_url, max_chars)
return text, target_url
async def fetch_text_async(
example: dict, max_chars: int = 50000, text_cache: dict | None = None
) -> tuple[str | None, str | None]:
"""Async wrapper for text fetching."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None, fetch_text_for_example, example, max_chars, text_cache
)
# ============================================================================
# Image Tiling
# ============================================================================
def split_image_to_tiles(
image_path: str,
output_dir: str,
tile_size: int | tuple[int, int] = 512,
overlap: int = 0,
) -> list[str]:
"""Split an image into fixed-size tiles.
Args:
image_path: Path to the source image.
output_dir: Directory to save tiles.
tile_size: Size of each tile. Can be int (square) or tuple (width, height).
overlap: Overlap between tiles in pixels.
Returns:
List of tile file paths.
"""
from PIL import Image
import glob
if not os.path.exists(image_path):
return []
os.makedirs(output_dir, exist_ok=True)
# Get base name without extension
base_name = os.path.splitext(os.path.basename(image_path))[0]
# Check if tiles already exist for this image
existing_tiles = sorted(
glob.glob(os.path.join(output_dir, f"{base_name}_tile_*.png"))
)
if existing_tiles:
# Tiles already exist, return them
return existing_tiles
# Support both square and rectangular tiles
if isinstance(tile_size, tuple):
tile_w, tile_h = tile_size
else:
tile_w = tile_h = tile_size
try:
Image.MAX_IMAGE_PIXELS = 300_000_000
img = Image.open(image_path)
width, height = img.size
tile_paths = []
step_x = tile_w - overlap
step_y = tile_h - overlap
row = 0
y = 0
while y < height:
col = 0
x = 0
while x < width:
# Calculate tile boundaries
x2 = min(x + tile_w, width)
y2 = min(y + tile_h, height)
# Calculate tile dimensions
tile_width = x2 - x
tile_height = y2 - y
# Skip tiles with extreme aspect ratios (> 10:1)
# This prevents issues with ColQwen which requires aspect ratio < 200
if tile_width > 0 and tile_height > 0:
aspect_ratio = max(
tile_width / tile_height, tile_height / tile_width
)
if aspect_ratio > 10:
col += 1
x += step_x
if x >= width:
break
continue
# Crop tile
tile = img.crop((x, y, x2, y2))
# Save tile
tile_filename = f"{base_name}_tile_{row}_{col}.png"
tile_path = os.path.join(output_dir, tile_filename)
tile.save(tile_path)
tile_paths.append(tile_path)
col += 1
x += step_x
if x >= width:
break
row += 1
y += step_y
if y >= height:
break
img.close()
return tile_paths
except Exception as e:
logger.warning(f"Failed to split image {image_path}: {e}")
return []
def prepare_tiles_for_screenshots(
screenshot_dir: str, tiles_dir: str, tile_size: int = 512, overlap: int = 0
) -> dict[str, list[str]]:
"""Split all screenshots in a directory into tiles.
Args:
screenshot_dir: Directory containing full screenshots.
tiles_dir: Directory to save tiles.
tile_size: Size of each tile.
overlap: Overlap between tiles.
Returns:
Dict mapping original image path to list of tile paths.
"""
os.makedirs(tiles_dir, exist_ok=True)
result = {}
for filename in os.listdir(screenshot_dir):
if not filename.endswith(".png"):
continue
image_path = os.path.join(screenshot_dir, filename)
tile_paths = split_image_to_tiles(image_path, tiles_dir, tile_size, overlap)
if tile_paths:
result[image_path] = tile_paths
logger.info(f"Split {filename} into {len(tile_paths)} tiles")
logger.info(
f"Total: {sum(len(v) for v in result.values())} tiles from {len(result)} images"
)
return result
# ============================================================================
# NQ (Natural Questions) Data Loading
# ============================================================================
def load_nq_data(
num_examples: int | None = 1000, split: str = "validation"
) -> list[dict]:
"""Load Natural Questions (full) split.
For validation, follows the short-answer protocol used by our NQ eval:
keep only examples where >=2 of 5 annotators marked a non-null short
answer. The train split has a single annotation per example, so train keeps
examples with a non-null short answer.
Source: HuggingFace google-research-datasets/natural_questions.
Reference: Kwiatkowski et al. (2019).
Args:
num_examples: Number of examples to return. Default 1000.
split: HuggingFace split to stream ("train" or "validation").
Returns:
List of dicts with id, problem, gold_answers, metadata.
"""
from datasets import load_dataset
import html as _html
if split not in {"train", "validation"}:
raise ValueError(
f"Unsupported NQ split: {split!r}. Expected 'train' or 'validation'."
)
logger.info(f"Loading NQ {split} split (streaming)...")
ds = load_dataset(
"google-research-datasets/natural_questions",
split=split,
streaming=True,
)
data = []
for ex in ds:
# Extract short answers from all 5 annotators
annotations = ex["annotations"]
short_answer_texts = set()
non_null_annotators = 0
# annotations is a dict with list values (one per annotator)
num_annotators = len(annotations["id"])
for i in range(num_annotators):
texts = annotations["short_answers"][i].get("text", [])
if texts:
non_null_annotators += 1
for t in texts:
if t.strip():
short_answer_texts.add(t.strip())
min_non_null = 2 if split == "validation" else 1
if non_null_annotators < min_non_null:
continue
if not short_answer_texts:
continue
question_text = ex["question"]["text"]
doc_url = ex["document"]["url"]
# Clean up HTML entities in URL (e.g., &amp; -> &)
doc_url = _html.unescape(doc_url)
# Normalize NQ URL format: /w/index.php?title=Foo&oldid=123 -> /wiki/Foo
_title_match = re.search(r"[?&]title=([^&]+)", doc_url)
if _title_match:
doc_url = f"https://en.wikipedia.org/wiki/{urllib.parse.quote(_title_match.group(1), safe='/:(),-')}"
example = {
"id": hashlib.md5(question_text.encode()).hexdigest(),
"problem": question_text,
"gold_answers": sorted(short_answer_texts),
"metadata": {
"urls": [doc_url],
"dataset": "nq",
"document_title": ex["document"]["title"],
},
}
data.append(example)
if num_examples and len(data) >= num_examples:
break
filter_desc = (
">=2 annotator agreement" if split == "validation" else "non-null short answer"
)
logger.info(f"Loaded {len(data)} NQ {split} examples (filtered by {filter_desc}).")
return data
# ============================================================================
# TriviaQA Data Loading
# ============================================================================
def load_triviaqa_data(num_examples: int | None = 1000) -> list[dict]:
"""Load TriviaQA rc.wikipedia validation split.
Uses entity_pages.title to construct ground truth Wikipedia URLs.
gold_answers includes answer.value + answer.aliases (following TriviaQA official eval).
Source: HuggingFace mandarjoshi/trivia_qa, config rc.wikipedia, validation split.
Reference: Joshi et al. (2017).
Args:
num_examples: Number of examples to return. Default 1000.
Returns:
List of dicts with id, problem, gold_answers, metadata.
"""
from datasets import load_dataset
import ast as _ast
from urllib.parse import quote as _url_quote
logger.info("Loading TriviaQA rc.wikipedia validation split (streaming)...")
ds = load_dataset(
"mandarjoshi/trivia_qa",
"rc.wikipedia",
split="validation",
streaming=True,
)
data = []
for ex in ds:
question = ex["question"]
answer_obj = ex["answer"]
# Extract gold answers: value + aliases
gold_answers = set()
value = answer_obj.get("value", "")
if value:
gold_answers.add(value)
# aliases is stored as a string repr of a list
aliases_raw = answer_obj.get("aliases", "")
if isinstance(aliases_raw, str) and aliases_raw:
try:
aliases = _ast.literal_eval(aliases_raw)
if isinstance(aliases, list):
for a in aliases:
if a and a.strip():
gold_answers.add(a.strip())
except (ValueError, SyntaxError):
pass
elif isinstance(aliases_raw, list):
for a in aliases_raw:
if a and a.strip():
gold_answers.add(a.strip())
if not gold_answers:
continue
# Construct Wikipedia URL from entity_pages.title
urls = []
entity_titles = ex.get("entity_pages", {}).get("title", [])
if entity_titles:
for title in entity_titles:
if title:
wiki_url = f"https://en.wikipedia.org/wiki/{_url_quote(title.replace(' ', '_'))}"
urls.append(wiki_url)
example = {
"id": hashlib.md5(question.encode()).hexdigest(),
"problem": question,
"gold_answers": sorted(gold_answers),
"question_type": ex.get("question_source", ""),
"metadata": {
"urls": urls,
"dataset": "triviaqa",
"question_id": ex.get("question_id", ""),
},
}
data.append(example)
if num_examples and len(data) >= num_examples:
break
logger.info(f"Loaded {len(data)} TriviaQA examples.")
return data
# ============================================================================
# NQ-Tables Data Loading
# ============================================================================
def load_nq_tables_data(num_examples: int | None = 1000) -> list[dict]:
"""Load NQ-Tables dev split (table subset of Natural Questions).
NQ-Tables filters Natural Questions to only keep examples where the gold
answer resides inside a Wikipedia HTML table. Each example includes the
full table content (columns + rows) and the Wikipedia URL.
Source: GCS gs://tapas_models/2021_07_22/nq_tables/interactions/dev.jsonl
Reference: Herzig et al. (2021), "Open Domain Question Answering over
Tables via Dense Retrieval" (NAACL 2021).
Args:
num_examples: Number of examples to return. Default 1000.
Returns:
List of dicts with id, problem, gold_answers, metadata.
"""
import html as _html
data_path = os.path.join(
os.path.dirname(__file__), "..", "..", "data", "nq_tables", "dev.jsonl"
)
data_path = os.path.abspath(data_path)
if not os.path.exists(data_path):
raise FileNotFoundError(
f"NQ-Tables data not found at {data_path}. "
"Download with: gsutil cp gs://tapas_models/2021_07_22/nq_tables/interactions/dev.jsonl data/nq_tables/"
)
logger.info(f"Loading NQ-Tables dev split from {data_path}...")
import json as _json
data = []
with open(data_path) as f:
for line in f:
ex = _json.loads(line)
questions = ex.get("questions", [])
if not questions:
continue
q = questions[0]
question_text = q.get("originalText", "")
answer_texts = q.get("answer", {}).get("answerTexts", [])
if not question_text or not answer_texts:
continue
gold_answers = [a.strip() for a in answer_texts if a.strip()]
if not gold_answers:
continue
# Extract Wikipedia URL from table metadata
table = ex.get("table", {})
doc_url = table.get("documentUrl", "")
doc_url = _html.unescape(doc_url)
# Normalize NQ URL format: /w/index.php?title=Foo&oldid=123 -> /wiki/Foo
_title_match = re.search(r"[?&]title=([^&]+)", doc_url)
if _title_match:
doc_url = f"https://en.wikipedia.org/wiki/{urllib.parse.quote(_title_match.group(1), safe='/:(),-')}"
example = {
"id": ex.get("id", hashlib.md5(question_text.encode()).hexdigest()),
"problem": question_text,
"gold_answers": gold_answers,
"metadata": {
"urls": [doc_url] if doc_url else [],
"dataset": "nq_tables",
"document_title": table.get("documentTitle", ""),
"table_id": table.get("tableId", ""),
},
}
data.append(example)
if num_examples and len(data) >= num_examples:
break
logger.info(f"Loaded {len(data)} NQ-Tables examples.")
return data
# ============================================================================
# Multiple-Choice Reasoning Benchmarks
# ============================================================================
LETTERS = ["A", "B", "C", "D", "E"]
def _format_mc_options(labels: list[str], texts: list[str]) -> str:
"""Format MC options as 'A. text1\nB. text2\n...'"""
return "\n".join(f"{label}. {text}" for label, text in zip(labels, texts))
MC_INSTRUCTION = "Choose the best answer from the options above. Reply with ONLY the letter (e.g. A, B, C, or D)."
def load_piqa_data(num_examples: int | None = None) -> list[dict]:
"""Load PIQA (Physical Intuition QA) validation split.
2-choice physical commonsense benchmark. Label is 0 or 1.
Source: HuggingFace `ybisk/piqa`, validation split.
Returns list of dicts with problem (question only), gold_answers (letter),
additional_instructions (options + MC instruction), metadata.
"""
from datasets import load_dataset
logger.info("Loading PIQA validation split...")
ds = load_dataset("ybisk/piqa", split="validation", revision="refs/convert/parquet")
data = []
for ex in ds:
question = ex["goal"]
options = [ex["sol1"], ex["sol2"]]
label = int(ex["label"])
gold_letter = LETTERS[label]
options_text = _format_mc_options(LETTERS[:2], options)
example = {
"id": hashlib.md5(question.encode()).hexdigest(),
"problem": question,
"gold_answers": [gold_letter],
"additional_instructions": f"{options_text}\n\n{MC_INSTRUCTION}",
"metadata": {"dataset": "piqa", "urls": [], "gold_letter": gold_letter},
}
data.append(example)
if num_examples and len(data) >= num_examples:
break
logger.info(f"Loaded {len(data)} PIQA examples.")
return data
def load_hellaswag_data(num_examples: int | None = None) -> list[dict]:
"""Load HellaSwag validation split.
4-choice sentence completion benchmark. Label is "0"-"3".
Source: HuggingFace `Rowan/hellaswag`, validation split.
"""
from datasets import load_dataset
logger.info("Loading HellaSwag validation split...")
ds = load_dataset(
"Rowan/hellaswag", split="validation", revision="refs/convert/parquet"
)
data = []
for ex in ds:
question = ex["ctx"]
options = ex["endings"]
label = int(ex["label"])
gold_letter = LETTERS[label]
options_text = _format_mc_options(LETTERS[: len(options)], options)
example = {
"id": hashlib.md5(question.encode()).hexdigest(),
"problem": question,
"gold_answers": [gold_letter],
"additional_instructions": f"{options_text}\n\n{MC_INSTRUCTION}",
"metadata": {
"dataset": "hellaswag",
"urls": [],
"gold_letter": gold_letter,
},
}
data.append(example)
if num_examples and len(data) >= num_examples:
break
logger.info(f"Loaded {len(data)} HellaSwag examples.")
return data
def load_commonsenseqa_data(num_examples: int | None = None) -> list[dict]:
"""Load CommonsenseQA validation split.
5-choice commonsense reasoning benchmark. answerKey is A-E.
Source: HuggingFace `tau/commonsense_qa`, validation split.
"""
from datasets import load_dataset
logger.info("Loading CommonsenseQA validation split...")
ds = load_dataset("tau/commonsense_qa", split="validation")
data = []
for ex in ds:
question = ex["question"]
labels = ex["choices"]["label"]
texts = ex["choices"]["text"]
gold_letter = ex["answerKey"]
options_text = _format_mc_options(labels, texts)
example = {
"id": hashlib.md5(question.encode()).hexdigest(),
"problem": question,
"gold_answers": [gold_letter],
"additional_instructions": f"{options_text}\n\n{MC_INSTRUCTION}",
"metadata": {
"dataset": "commonsense_qa",
"urls": [],
"gold_letter": gold_letter,
},
}
data.append(example)
if num_examples and len(data) >= num_examples:
break
logger.info(f"Loaded {len(data)} CommonsenseQA examples.")
return data
def load_openbookqa_data(num_examples: int | None = None) -> list[dict]:
"""Load OpenBookQA test split.
4-choice science QA benchmark. answerKey is A-D.
Source: HuggingFace `allenai/openbookqa`, main config, test split.
"""
from datasets import load_dataset
logger.info("Loading OpenBookQA test split...")
ds = load_dataset("allenai/openbookqa", "main", split="test")
data = []
for ex in ds:
question = ex["question_stem"]
labels = ex["choices"]["label"]
texts = ex["choices"]["text"]
gold_letter = ex["answerKey"]
options_text = _format_mc_options(labels, texts)
example = {
"id": hashlib.md5(question.encode()).hexdigest(),
"problem": question,
"gold_answers": [gold_letter],
"additional_instructions": f"{options_text}\n\n{MC_INSTRUCTION}",
"metadata": {
"dataset": "openbookqa",
"urls": [],
"gold_letter": gold_letter,
},
}
data.append(example)
if num_examples and len(data) >= num_examples:
break
logger.info(f"Loaded {len(data)} OpenBookQA examples.")
return data
def load_arc_data(
config: str = "ARC-Challenge", num_examples: int | None = None
) -> list[dict]:
"""Load ARC (AI2 Reasoning Challenge) test split.
3-5 choice science exam benchmark. answerKey is A-E or 1-5 (normalized to letters).
Source: HuggingFace `allenai/ai2_arc`, ARC-Challenge or ARC-Easy config, test split.
Args:
config: "ARC-Challenge" or "ARC-Easy"
num_examples: Max examples to return. None = all.
"""
from datasets import load_dataset
dataset_name = config.lower().replace("-", "_")
logger.info(f"Loading ARC {config} test split...")
ds = load_dataset("allenai/ai2_arc", config, split="test")
# ARC answerKey can be "1","2","3","4","5" instead of letters
DIGIT_TO_LETTER = {"1": "A", "2": "B", "3": "C", "4": "D", "5": "E"}
data = []
for ex in ds:
question = ex["question"]
labels = ex["choices"]["label"]
texts = ex["choices"]["text"]
gold_letter = ex["answerKey"]
gold_letter = DIGIT_TO_LETTER.get(gold_letter, gold_letter)
options_text = _format_mc_options(labels, texts)
example = {
"id": hashlib.md5(question.encode()).hexdigest(),
"problem": question,
"gold_answers": [gold_letter],
"additional_instructions": f"{options_text}\n\n{MC_INSTRUCTION}",
"metadata": {
"dataset": dataset_name,
"urls": [],
"gold_letter": gold_letter,
},
}
data.append(example)
if num_examples and len(data) >= num_examples:
break
logger.info(f"Loaded {len(data)} ARC {config} examples.")
return data