项目文件夹

文件
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 行
42 KiB
Python

"""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