Evaluation¶
This guide covers running evaluations, computing metrics, and saving results.
TrajectoryRunner¶
The TrajectoryRunner orchestrates evaluation trajectories:
from llenvs.core.registry import environment_registry
from llenvs.inference.backends import OpenAIBackend
from llenvs.inference import SamplingParams, build_standard_pipeline
from llenvs.evaluation import TrajectoryRunner
env = environment_registry.get(name="leg_counting", adapter="reasoning_gym", size=100, seed=42)
backend = OpenAIBackend(model="gpt-4o")
runner = TrajectoryRunner(
environment=env,
backend=backend,
sampling_params=SamplingParams(temperature=0.0, max_tokens=1024),
prompt_pipeline=build_standard_pipeline(
system_prompt="You are a helpful assistant.",
use_cot=True,
answer_format="xml_answer",
),
)
# Run single trajectory
result = runner.run_trajectory(task_index=0)
print(f"Success: {result.success}")
print(f"Total reward: {result.total_reward}")
# Run batch
batch_result = runner.run_batch(
task_indices=list(range(50)),
progress_callback=lambda c, t: print(f"\r{c}/{t}", end=""),
)
print(f"\nSuccess rate: {batch_result.success_rate:.2%}")
Tool Environments¶
TrajectoryRunner auto-detects tool environments via available_tools on the observation and uses generate_with_tools_batch() automatically:
from llenvs.core.registry import environment_registry
from llenvs.evaluation import TrajectoryRunner
env = environment_registry.get(name="math:GSM8K", adapter="gem", tool_types=("python",))
runner = TrajectoryRunner(
environment=env,
backend=backend,
sampling_params=SamplingParams(temperature=0.0),
system_prompt="Use Python to solve. Submit your final answer.",
)
result = runner.run_trajectory(task_index=0)
Batched Evaluation¶
run_batch() automatically batches inference calls using lockstep execution. All active trajectories advance one step together, and the backend's generate_chat_batch() (or generate_with_tools_batch()) is called once per step with all active conversations. Trajectories that finish early drop out of subsequent batches.
This applies to both runner types:
- TrajectoryRunner: Batches generate_chat_batch() calls per step. For tool environments, uses generate_with_tools_batch() automatically.
- SegmentedTrajectoryRunner: Batches segment generation via generate_segment_batch() on the continuation strategy. Callbacks (step_callback) still run per-trajectory after each step.
This gives significant speedups:
- vLLM / HuggingFace: All prompts go through the GPU in one batched call per step.
- API backends (OpenAI, Anthropic, OpenRouter): Concurrent async HTTP requests limited by max_concurrency.
Single-trajectory run_trajectory() is unchanged.
# This automatically batches inference across all 100 tasks
batch_result = runner.run_batch(
task_indices=list(range(100)),
progress_callback=lambda c, t: print(f"\r{c}/{t}", end=""),
)
Use batch_size to limit how many trajectories run in each lockstep batch (useful for GPU memory management):
# Process 32 tasks at a time instead of all 100
batch_result = runner.run_batch(
task_indices=list(range(100)),
batch_size=32,
)
The same progress_callback(completed, total) pattern is also available on run_batch_from_states() and run_batch_from_state_actions(), which is useful for Monte-Carlo rollout code that starts from arbitrary states instead of resetting tasks from scratch.
For cross-environment batching (interleaving trajectories from multiple environments into a single lockstep loop), see run_multi_evaluation() in the Parallelization guide.
See the Parallelization guide for architecture details, max_concurrency tuning, and performance tips.
Logging¶
Structured logging during evaluation runs is configured via LogConfig. Three targets are available: console (Python logging), file (JSONL), and W&B.
from llenvs.evaluation import LogConfig
# Console logging (uses Python logging at INFO/DEBUG levels)
result = run_evaluation(env, backend, log=LogConfig(targets=("console",)))
# File logging (JSONL in .logs/{env_name}/)
result = run_evaluation(env, backend, log=LogConfig(targets=("file",)))
# W&B logging
result = run_evaluation(env, backend, log=LogConfig(targets=("wandb",), wandb_project="my-evals"))
# Multiple targets
result = run_evaluation(env, backend, log=LogConfig(targets=("console", "file", "wandb")))
# W&B with existing run (e.g. from verl)
result = run_evaluation(env, backend, log=LogConfig(targets=("wandb",), wandb_run=existing_run))
The log parameter is available on TrajectoryRunner, SegmentedTrajectoryRunner, run_evaluation(), run_segmented_evaluation(), and run_multi_evaluation() (via per-entry runner configs).
File logging writes JSONL to {log_dir}/{env_name}/{timestamp}.jsonl with one JSON object per line. Each line has an "event" key (batch_start, step, trajectory_end, batch_end, error).
Console logging uses logging.getLogger("llenvs.evaluation"). To see step-level details, set the logger to DEBUG:
progress_callback is unchanged and orthogonal to logging.
Metrics and Statistics¶
The evaluation module separates metrics (what we measure) from statistics (how we summarize):
- Metrics: Measurable quantities like
action_reward,trajectory_reward,accuracy - Statistics: Summary computations like mean, std_dev, quantiles, confidence intervals
Two statistics types exist for different metric categories:
- ContinuousStatistics: For continuous-valued metrics (rewards, scores, etc.)
- BinaryStatistics: For binary metrics (success/failure) with pass@k support
Computing Metrics¶
from llenvs.evaluation import (
compute_accuracy,
compute_trajectory_reward,
compute_action_reward,
compute_format_compliance,
)
# Binary metrics (accuracy, format_compliance) return BinaryStatistics
accuracy = compute_accuracy(batch_result.trajectory_results)
stats = accuracy.statistics
print(f"Accuracy: {stats.mean:.3f} ({stats.count}/{stats.n})")
print(f"95% CI: [{stats.ci_lower:.3f}, {stats.ci_upper:.3f}]")
# Continuous metrics (rewards) return ContinuousStatistics
trajectory_reward = compute_trajectory_reward(batch_result.trajectory_results)
stats = trajectory_reward.statistics
print(f"Mean trajectory reward: {stats.mean:.3f}")
print(f"Std dev: {stats.std_dev:.3f}")
print(f"Min: {stats.min:.3f}, Max: {stats.max:.3f}")
# Action-level metrics
action_reward = compute_action_reward(batch_result.trajectory_results)
stats = action_reward.statistics
print(f"Mean action reward: {stats.mean:.3f}")
print(f"Median: {stats.median:.3f}")
print(f"Q25-Q75: [{stats.q25:.3f}, {stats.q75:.3f}]")
format_compliance = compute_format_compliance(batch_result.trajectory_results)
stats = format_compliance.statistics
print(f"Format compliance: {stats.mean:.1%} ({stats.count}/{stats.n} actions)")
Statistics Types¶
ContinuousStatistics (for rewards, scores, etc.):
| Field | Description |
|---|---|
n |
Sample size |
mean |
Arithmetic mean |
std_dev |
Sample standard deviation |
std_error |
Standard error of the mean |
min |
Minimum value |
max |
Maximum value |
median |
Median (50th percentile) |
q25 |
25th percentile |
q75 |
75th percentile |
ci_lower |
Lower bound of 95% CI |
ci_upper |
Upper bound of 95% CI |
BinaryStatistics (for success/failure metrics):
| Field | Description |
|---|---|
n |
Sample size |
mean |
Success rate (proportion of successes) |
count |
Number of successes |
std_error |
Standard error sqrt(p(1-p)/n) |
ci_lower |
Lower bound of Wilson score CI |
ci_upper |
Upper bound of Wilson score CI |
BinaryStatistics also provides the pass_at_k(k) method.
Computing Statistics Directly¶
You can compute statistics from any sequence of values:
from llenvs.evaluation import compute_continuous_statistics, compute_binary_statistics
# Continuous data
values = [0.8, 0.85, 0.9, 0.78, 0.92]
stats = compute_continuous_statistics(values, confidence_level=0.95)
print(f"Mean: {stats.mean:.3f}")
print(f"Std dev: {stats.std_dev:.3f}")
print(f"Range: [{stats.min:.3f}, {stats.max:.3f}]")
# Binary data
successes = [1, 1, 0, 1, 0, 1, 1, 0, 1, 1] # 7 successes out of 10
stats = compute_binary_statistics(successes)
print(f"Success rate: {stats.mean:.1%} ({stats.count}/{stats.n})")
Pass@k¶
Pass@k measures the probability of at least one success in k samples. It's available as a method on BinaryStatistics:
# Run multiple times per task
results = []
for _ in range(10): # 10 samples
result = runner.run_trajectory(task_index=0)
results.append(result)
# Get accuracy statistics (BinaryStatistics)
accuracy = compute_accuracy(results)
stats = accuracy.statistics
# Compute Pass@k for different values of k
print(f"Pass@1: {stats.pass_at_k(1):.3f}")
print(f"Pass@5: {stats.pass_at_k(5):.3f}")
print(f"Pass@10: {stats.pass_at_k(10):.3f}")
The pass_at_k(k) method uses the exact formula: 1 - C(n-c, k) / C(n, k) where n is the total number of samples and c is the number of successes.
All Metrics at Once¶
from llenvs.evaluation import compute_all_metrics, ContinuousStatistics, BinaryStatistics
metrics = compute_all_metrics(batch_result)
for name, metric in metrics.metrics.items():
stats = metric.statistics
if isinstance(stats, ContinuousStatistics):
print(f"{name}: {stats.mean:.4f} (std: {stats.std_dev:.4f})")
else: # BinaryStatistics
print(f"{name}: {stats.mean:.4f} ({stats.count}/{stats.n})")
Aggregating Metrics¶
Combine metrics from multiple evaluations:
from llenvs.evaluation import (
aggregate_continuous_metrics,
aggregate_binary_metrics,
)
# Aggregate trajectory rewards from multiple task groups
math_rewards = compute_trajectory_reward(math_results)
logic_rewards = compute_trajectory_reward(logic_results)
combined = aggregate_continuous_metrics(
[math_rewards, logic_rewards],
name="all_reasoning_reward",
)
print(f"Combined: {combined.statistics.mean:.3f} (n={combined.statistics.n})")
# Aggregate accuracy across task groups
math_acc = compute_accuracy(math_results)
logic_acc = compute_accuracy(logic_results)
combined_acc = aggregate_binary_metrics([math_acc, logic_acc])
print(f"Combined accuracy: {combined_acc.statistics.mean:.1%}")
print(f"Pass@5: {combined_acc.statistics.pass_at_k(5):.3f}")
Note: Aggregated continuous metrics have median, q25, q75 set to None because these cannot be accurately reconstructed from summary statistics alone.
Saving Results¶
from datetime import datetime
from llenvs.evaluation.results import create_evaluation_result, print_summary
# Create formatted result
eval_result = create_evaluation_result(
batch_result=batch_result,
model_name="gpt-4o",
environment_name="leg_counting",
start_time=datetime.now(),
config={"temperature": 0.0, "max_tokens": 1024},
include_detailed_results=True,
)
# Print summary
print_summary(eval_result)
# Save to JSON
eval_result.save("results/eval_20240115.json")
# Save without per-trajectory details (smaller file)
eval_result.save("results/eval_summary.json", include_results=False)
Result Structure¶
{
"metadata": {
"timestamp": "2024-01-15T10:30:00",
"model": "gpt-4o",
"environment": "leg_counting",
"duration_seconds": 123.4,
"num_trajectories": 100,
"config": {...},
},
"metrics": {
# Binary metrics (BinaryStatistics)
"accuracy": {"n": 100, "mean": 0.85, "count": 85, "std_error": 0.036, ...},
"format_compliance": {"n": 100, "mean": 0.95, "count": 95, ...},
# Continuous metrics (ContinuousStatistics)
"trajectory_reward": {"n": 100, "mean": 0.85, "std_dev": 0.12, "min": 0.0, ...},
"action_reward": {"n": 100, "mean": 0.82, "std_dev": 0.15, ...},
},
"summary": {
"success_rate": 0.85,
"mean_reward": 0.85,
"num_trajectories": 100,
},
"results": [...] # Per-trajectory details if included
}
Prompt Templates and Model Profiles¶
The runner supports prompt templates (wrapping questions) and model profiles (model-specific adjustments):
from llenvs.inference import TEMPLATE_REGISTRY, PROFILE_REGISTRY
runner = TrajectoryRunner(
environment=env,
backend=backend,
sampling_params=SamplingParams(temperature=0.0),
system_prompt="You are an expert mathematician.",
prompt_template=TEMPLATE_REGISTRY["math"], # Wraps questions
model_profile=PROFILE_REGISTRY["deepseek_r1"], # Model-specific
)
The message build order is: system prompt → observation → prompt template → model profile → prompt pipeline. See the Prompts guide for full details.
For multi-turn environments with structured observations, you can also control how much conversation history the model sees. See the History Control guide.
Turn Info¶
Multi-turn environments have a maximum step count (EnvironmentSpec.max_steps) but the model doesn't see it by default. TurnInfoConfig provides an opt-in mechanism to inject turn counters and limits into the structured messages the runner builds.
from llenvs.evaluation import TrajectoryRunner, TurnInfoConfig
# Enable with defaults
runner = TrajectoryRunner(
environment=env,
backend=backend,
turn_info=True, # shorthand for TurnInfoConfig()
)
With turn_info=True (default config), the runner:
- Appends "\n\nYou have a maximum of {max_steps} turns to complete this task." to the task description
- Prepends "[Turn {turn}/{max_steps}]\n" to each state observation
Turn numbers are 1-indexed. When the environment doesn't declare max_steps, the _no_max variants are used instead (empty task suffix, "[Turn {turn}]\n" state prefix).
Custom formatting:
runner = TrajectoryRunner(
environment=env,
backend=backend,
turn_info=TurnInfoConfig(
task_suffix="\n\n({max_steps} steps available)",
state_prefix="Step {turn}: ",
task_suffix_no_max="",
state_prefix_no_max="Step {turn}: ",
),
)
Available placeholders: {max_steps}, {turn} (1-indexed), {turns_remaining}.
Turn info only applies in structured mode (when obs.task/obs.state are set). Legacy mode is unaffected. History entries are not modified — only the task description and the current state observation receive injections.
The run_evaluation() convenience function also accepts turn_info:
Convenience Function¶
from llenvs.evaluation import run_evaluation
from llenvs.inference import TEMPLATE_REGISTRY
result = run_evaluation(
environment=env,
backend=backend,
num_tasks=50,
sampling_params=SamplingParams(temperature=0.0),
system_prompt="Think step by step. Use <answer>...</answer> tags.",
prompt_template=TEMPLATE_REGISTRY["math"], # Optional
)
print(f"Accuracy: {result.success_rate:.2%}")
Error Handling¶
The runner handles errors gracefully:
batch_result = runner.run_batch(task_indices)
# Check for errors
for result in batch_result.trajectory_results:
if "error" in result.metadata:
print(f"Task {result.metadata['task_index']} failed: {result.metadata['error']}")
Built-in Reward Functions¶
StepPenalty¶
StepPenalty emits a small negative reward on every step, incentivizing agents to solve tasks in fewer turns:
from llenvs.core import StepPenalty
# Add as extra_rewards on any environment
env = SomeEnvironment(
...,
extra_rewards=(StepPenalty(penalty=0.1),),
)
The penalty parameter is a positive float (default 0.1) that gets negated in the signal. Combined with an OUTCOME reward for correctness, this creates pressure to be efficient without sacrificing accuracy.
Configuration options:
| Parameter | Default | Description |
|---|---|---|
penalty |
0.1 |
Per-step penalty (positive value, applied as negative reward) |
_name |
"step_penalty" |
Signal name |
_reward_type |
RewardType.STEP |
Signal type |
_weight |
1.0 |
Weight for aggregation |
Custom Reward Analysis¶
from llenvs.core import RewardType
for result in batch_result.trajectory_results:
for transition in result.trajectory.transitions:
# Get signals by type
outcome_signals = transition.rewards.by_type(RewardType.OUTCOME)
format_signals = transition.rewards.by_type(RewardType.FORMAT)
# Get specific signal
correctness = transition.rewards.by_name("correctness")
if correctness:
print(f"Correctness: {correctness.reward}")
# Get only signals with numeric rewards (skips feedback-only)
numeric = transition.rewards.numeric_signals()
# Collect textual feedback from all signals
feedback = transition.rewards.feedback_texts()
if feedback:
print(f"Feedback: {feedback}")