ml-agent / eval /generate_rubrics.py
akseljoonas's picture
akseljoonas HF Staff
generated, filled in and verfied 250 eval questions
8541221
#!/usr/bin/env env python3
"""
Rubric Generation Script for HF-Agent Benchmark
Generates instance-specific evaluation rubrics following the "Rubrics as Rewards" paper.
Uses LiteLLM to call LLM models for rubric synthesis with expert grounding via reference answers.
"""
import argparse
import json
import os
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Dict, List
import litellm
import pandas as pd
from dotenv import load_dotenv
from pydantic import BaseModel
from eval.hf_io import df_to_hub
class Rubric(BaseModel):
title: str
description: str
weight: int
class RubricList(BaseModel):
rubrics: List[Rubric]
# Load environment variables
load_dotenv()
# Rubric generation prompt template based on RaR paper
PROMPT_TEMPLATE = """You are an expert rubric writer. Your job is to generate a self-contained set of evaluation criteria ("rubrics") for judging how good, helpful and complete an agent's trajectory is to a given user question/request.
Rubrics can cover aspects of a response such as, but not limited to, factual correctness, helpfulness, completeness, harmlessness, correctness of using Hugging Face best practices (based on HF documentation), depth of
reasoning, contextual relevance and usefulness. Each item must be self-contained – non expert readers should not need to
infer anything or consult external information. Begin each description with its category: "Essential Criteria: . . . ", "Important
Criteria: . . . ", "Optional Criteria: . . . ", or "Pitfall Criteria: Does not mention . . . ".
Inputs:
- question: <<<{question}>>>
- example_solution (NOT ground truth - just an okay attempt): <<<{example_solution}>>>
- example_trace (NOT ground truth - just an okay attempt showing what tool usage might look like): <<<{example_trace}>>>
IMPORTANT: The example_solution and example_trace provided are NOT ground truth or ideal solutions. They represent
an attempt at solving the task - they give you a general idea of the shape of the problem and what tool usage
might look like, but they contain mistakes and incomplete solutions, suboptimal approaches, or incomplete answers. Your rubrics MUST be designed to fairly grade a PERFECT solution. The perfect solution is complete in all aspects of solving the task and verifing it's correctness before giving the final answer. It tells the user what was done and why, and provides the final answer clearly answering the user's question.
Total items:
• Choose 7–20 rubric items based on the complexity of the question.
Each rubric item:
• title (2–4 words).
• description: One sentence starting with its category prefix that explicitly states exactly what to look for. For example:
– Essential Criteria: Writes a up-to-date, correct, complete and working training loop using the latest Hugging Face best practices. Launches the training with hf-jobs.
– Pitfall Criteria: Deprecated launcher usage. Uses python -m torch.distributed.launch instead of torchrun / accelerate.
– Important Criteria: Explains common DDP knobs. Mentions ddp_find_unused_parameters=False for models with conditional branches; optional ddp_timeout; brief note on when they matter and why.
– Optional Criteria: Briefly notes --deepspeed ds_config.json as an alternative scaler when models get big (but stays on DDP for this Q).
• weight: For Essential/Important/Optional, use 1–5 (5 = most important); for Pitfall, use –1 or –2.
Category guidance:
• Essential: Critical actions to answer/complete the user's question/request; if missing, the response is invalid and useless (weight 5).
• Important: Key reasoning, completeness, or clarity; strongly affects quality and usefulness (weight 3–4).
• Optional: Helpfulness in educating the user or providing extra depth; nice to have but not deal-breaking (weight 1–2).
• Pitfall: Common mistakes or omissions specific to this prompt—identify things a respondent often forgets or misstates.
Each Pitfall description must begin with "Pitfall Criteria: Does not mention . . . " or "Pitfall Criteria: Recommends . . . "
and use weight –1 or –2.
To ensure self-contained guidance:
• When referring to answer choices, explicitly say "Identifies (A)", "Identifies (B)", etc., rather than vague phrasing.
• If the format requires an action like calling a tool or launching a training run, include a rubric item such as:
– Essential Criteria: Includes a clear statement "Launches the training with hf-jobs.".
• If reasoning should precede the answer, include a rubric like:
– Important Criteria: Presents the explanation and reasoning before stating the final answer.
• If brevity is valued, include a rubric like:
– Optional Criteria: Remains concise and avoids unnecessary detail.
• If the question context demands mention of specific findings/best practices, include that explicitly (e.g., "Essential Criteria: Mentions
that training data must be in "messages" column for LLM training").
Output: Provide a JSON array of rubric objects. Each object must contain exactly three keys—title, description, and weight.
Do not copy large blocks of the question or example_solution into the text. Each description must begin with its category
prefix, and no extra keys are allowed.
Remember: The example_solution and example_trace are NOT ideal answers - they are just rough attempts to show the
general approach. Design rubrics that can fairly evaluate any solution, including ones that are better than the example."""
def build_prompt(
question: str,
example_solution: str,
example_trace: List[Dict[str, Any]],
) -> List[Dict[str, str]]:
"""
Build the messages list for LiteLLM completion.
Args:
question: The question/task to evaluate
difficulty: The difficulty level of the task
example_solution: An example solution attempt (not ground truth)
example_trace: The agent's message trace showing tool usage
Returns:
List of message dicts for LiteLLM
"""
# Format the trace for readability - only include key parts
formatted_trace = format_trace_for_prompt(example_trace)
prompt = PROMPT_TEMPLATE.format(
question=question,
example_solution=example_solution,
example_trace=formatted_trace,
)
return [{"role": "user", "content": prompt}]
def format_trace_for_prompt(messages: List[Dict[str, Any]]) -> str:
"""
Format the agent message trace for inclusion in the prompt.
Extracts key information while keeping it readable.
"""
if not messages:
return "(No trace available)"
formatted_parts = []
for msg in messages:
role = msg.get("role", "unknown")
content = msg.get("content", "")
# Skip system messages
if role == "system":
continue
# Handle tool calls
if "tool_calls" in msg and msg["tool_calls"]:
tool_info = []
for tc in msg["tool_calls"]:
if isinstance(tc, dict) and "function" in tc:
func = tc["function"]
tool_name = func.get("name", "unknown_tool")
tool_info.append(f" - Called: {tool_name}")
if tool_info:
formatted_parts.append(
"[Assistant Tool Calls]\n" + "\n".join(tool_info)
)
# Handle regular content
if content:
# Truncate very long content
if len(content) > 500:
content = content[:500] + "... (truncated)"
formatted_parts.append(f"[{role.title()}]\n{content}")
return "\n\n".join(formatted_parts) if formatted_parts else "(Empty trace)"
def validate_rubric(rubric_list: List[Dict[str, Any]]) -> bool:
"""
Validate that rubric meets basic requirements.
Args:
rubric_list: List of rubric items to validate
Returns:
True if valid, False otherwise
"""
# Check count
if not (7 <= len(rubric_list) <= 20):
return False
# Check each item
category_prefixes = [
"Essential Criteria:",
"Important Criteria:",
"Optional Criteria:",
"Pitfall Criteria:",
]
for item in rubric_list:
# Check keys
if set(item.keys()) != {"title", "description", "weight"}:
return False
# Check description starts with category prefix
if not any(
item["description"].startswith(prefix) for prefix in category_prefixes
):
return False
return True
def generate_rubric(row: pd.Series, model: str, timeout: int = 120) -> Dict[str, Any]:
"""
Generate rubric for a single question using LiteLLM.
Args:
row: DataFrame row containing question, difficulty, solution, and messages
model: Model name for LiteLLM
timeout: Request timeout in seconds
Returns:
Dict with rubric_list and rubric_count, or None on failure
"""
messages = build_prompt(
question=row["question"],
example_solution=row["solution"],
example_trace=row.get("messages", []),
)
try:
response = litellm.completion(
model=model,
messages=messages,
timeout=timeout,
response_format=RubricList,
)
# Parse structured output
rubric_list: RubricList = RubricList.model_validate_json(
response.choices[0].message.content
)
return rubric_list.model_dump_json()
except Exception as e:
print(f"Error generating rubric: {e}", file=sys.stderr)
return None
def load_input_data(infile: str) -> pd.DataFrame:
"""
Load input data from CSV or JSONL file.
Args:
infile: Path to input file
Returns:
DataFrame with loaded data
"""
path = Path(infile)
if not path.exists():
raise FileNotFoundError(f"Input file not found: {infile}")
if path.suffix == ".csv":
# Try to auto-detect delimiter (comma or semicolon)
df = pd.read_csv(infile, sep=None, engine="python")
elif path.suffix == ".jsonl":
df = pd.read_json(infile, lines=True)
else:
raise ValueError(f"Unsupported file format: {path.suffix}. Use .csv or .jsonl")
# Validate required columns
required_cols = [
"question",
"solution",
]
optional_cols = ["difficulty", "messages", "error"]
missing_cols = [col for col in required_cols if col not in df.columns]
if missing_cols:
raise ValueError(f"Missing required columns: {missing_cols}")
# Log available optional columns
available_optional = [col for col in optional_cols if col in df.columns]
print(f"Found optional columns: {available_optional}")
return df
def main():
parser = argparse.ArgumentParser(
description="Generate rubrics for HF-agent benchmark evaluation"
)
parser.add_argument(
"--infile", type=str, required=True, help="Input file path (.csv or .jsonl)"
)
parser.add_argument(
"--outfile", type=str, required=True, help="Output JSONL file path"
)
parser.add_argument(
"--model",
type=str,
default="anthropic/claude-sonnet-4-5-20250929",
help="LiteLLM model name (default: from LITELLM_MODEL env or gpt-4o-mini)",
)
parser.add_argument(
"--timeout",
type=int,
default=120,
help="Request timeout in seconds (default: 120)",
)
parser.add_argument(
"--max-concurrent",
type=int,
default=30,
help="Maximum number of concurrent workers (default: 30)",
)
parser.add_argument(
"--push-to-hub",
type=str,
default=None,
help="Push to HuggingFace dataset (e.g., username/dataset@rubrics)",
)
args = parser.parse_args()
# Determine model
model = args.model or os.getenv("LITELLM_MODEL", "gpt-4o-mini")
print(f"Using model: {model}")
# Load input data
print(f"Loading data from {args.infile}...")
df = load_input_data(args.infile)
print(f"Loaded {len(df)} examples")
# Run rubric generation in parallel using ThreadPoolExecutor
print(f"Running generation with {args.max_concurrent} parallel workers...")
with ThreadPoolExecutor(max_workers=args.max_concurrent) as executor:
# Submit all tasks
future_to_idx = {}
for idx, row in df.iterrows():
future = executor.submit(
generate_rubric,
row=row,
model=model,
timeout=args.timeout,
)
future_to_idx[future] = idx
# Collect results in order
results = [None] * len(df)
completed = 0
for future in as_completed(future_to_idx):
idx = future_to_idx[future]
results[idx] = future.result()
completed += 1
print(f"Completed: {completed}/{len(df)}", end="\r")
print() # New line after progress
# Prepare results DataFrame
print("Preparing results...")
output_rows = []
success_count = 0
failure_count = 0
for idx, (_, row) in enumerate(df.iterrows()):
rubric_result = results[idx]
if rubric_result is None:
failure_count += 1
continue
# Merge with original data
output_row = row.to_dict()
output_row["messages"] = json.dumps(output_row["messages"])
output_row["rubric"] = rubric_result
output_rows.append(output_row)
success_count += 1
# Create DataFrame with results
results_df = pd.DataFrame(output_rows)
# Upload to HuggingFace if specified (before saving JSONL)
if args.push_to_hub:
print(f"\nUploading to HuggingFace: {args.push_to_hub}")
upload_success = df_to_hub(
df=results_df,
dataset_spec=args.push_to_hub,
split="train",
private=False,
)
if not upload_success:
print("Warning: HuggingFace push failed, but continuing to save JSONL...")
# Write results to JSONL file
print(f"\nWriting results to {args.outfile}...")
with open(args.outfile, "w") as outf:
for output_row in output_rows:
outf.write(json.dumps(output_row, default=str) + "\n")
print("\nComplete!")
print(f"Success: {success_count}/{len(df)}")
print(f"Failures: {failure_count}/{len(df)}")
print(f"Output written to: {args.outfile}")
if args.push_to_hub and upload_success:
print(f"Pushed to: {args.push_to_hub}")
if __name__ == "__main__":
main()