Files
2026-07-13 13:35:10 +08:00

352 lines
12 KiB
Python

"""Utilities for comparing metrics across different implementations.
This module provides tools for comparing legacy and modern metric implementations,
including concurrent execution, statistical analysis, and result export capabilities.
"""
import asyncio
import time
from dataclasses import dataclass
from typing import Any, Dict, List, Tuple
import numpy as np
import pandas as pd
from ragas.dataset_schema import SingleTurnSample
@dataclass
class MetricDiffResult:
"""Container for metric comparison results.
Attributes:
old_scores: List of scores from the baseline/old metric
new_scores: List of scores from the new metric
diffs: List of differences (new - old)
mean_diff: Mean of differences
max_diff: Maximum difference
min_diff: Minimum difference
std_diff: Standard deviation of differences
old_mean: Mean of old metric scores
new_mean: Mean of new metric scores
old_time: Execution time for old metric (seconds)
new_time: Execution time for new metric (seconds)
"""
old_scores: List[float]
new_scores: List[float]
diffs: List[float]
mean_diff: float
max_diff: float
min_diff: float
std_diff: float
old_mean: float
new_mean: float
old_time: float
new_time: float
def to_dataframe(self) -> pd.DataFrame:
"""Convert results to a pandas DataFrame.
Returns:
DataFrame with columns: old_score, new_score, diff, abs_diff
"""
return pd.DataFrame(
{
"old_score": self.old_scores,
"new_score": self.new_scores,
"diff": self.diffs,
"abs_diff": [abs(d) for d in self.diffs],
}
)
def print_summary(self):
"""Print a formatted summary of the comparison results."""
print("=" * 60)
print("METRIC COMPARISON SUMMARY")
print("=" * 60)
print("\nScore Statistics:")
print(f" Old Metric Mean: {self.old_mean:.4f}")
print(f" New Metric Mean: {self.new_mean:.4f}")
print("\nDifference Statistics (new - old):")
print(f" Mean Diff: {self.mean_diff:.4f}")
print(f" Max Diff: {self.max_diff:.4f}")
print(f" Min Diff: {self.min_diff:.4f}")
print(f" Std Dev: {self.std_diff:.4f}")
print("\nExecution Time:")
print(f" Old Metric: {self.old_time:.2f}s")
print(f" New Metric: {self.new_time:.2f}s")
print(
f" Speedup: {self.old_time / self.new_time:.2f}x"
if self.new_time > 0
else " N/A"
)
print("=" * 60)
async def run_metric_on_dataset(
metric: Any,
dataset: List[Dict[str, Any]],
metric_type: str = "old",
max_concurrent: int = 10,
) -> Tuple[List[float], float]:
"""
Run a metric on a dataset with concurrent processing for better performance.
This function processes all samples concurrently with a semaphore to limit
the number of simultaneous API calls, preventing rate limiting issues.
Args:
metric: The metric instance (either old or new style)
dataset: List of dictionaries containing the data samples
metric_type: "old" for legacy metrics, "new" for collections metrics
max_concurrent: Maximum number of concurrent requests (default: 10)
Returns:
Tuple of (scores list, execution time in seconds)
Example:
>>> scores, time = await run_metric_on_dataset(
... metric=my_metric,
... dataset=[{"user_input": "q1", "response": "a1"}],
... metric_type="new",
... max_concurrent=5,
... )
"""
async def score_single_sample(sample_dict: Dict[str, Any]) -> float:
"""Score a single sample using the appropriate metric interface."""
try:
if metric_type == "old":
# Old metrics use SingleTurnSample
sample = SingleTurnSample(**sample_dict)
score = await metric._single_turn_ascore(sample, callbacks=None)
else:
# New metrics use direct kwargs
result = await metric.ascore(**sample_dict)
score = result.value
return float(score)
except Exception as e:
print(f"Error processing sample: {e}")
return np.nan
start_time = time.time()
# Use semaphore to limit concurrent requests (prevents API rate limiting)
semaphore = asyncio.Semaphore(max_concurrent)
async def score_with_limit(sample_dict: Dict[str, Any]) -> float:
"""Score with concurrency control."""
async with semaphore:
return await score_single_sample(sample_dict)
# Process all samples concurrently
scores = await asyncio.gather(*[score_with_limit(s) for s in dataset])
execution_time = time.time() - start_time
return list(scores), execution_time
async def compare_metrics(
old_metric: Any,
new_metric: Any,
dataset: List[Dict[str, Any]],
old_metric_type: str = "old",
new_metric_type: str = "new",
max_concurrent: int = 10,
parallel_metrics: bool = True,
) -> MetricDiffResult:
"""
Compare two metrics on the same dataset with optional parallel execution.
This function runs both metrics on the dataset and computes detailed
comparison statistics. Metrics can be run in parallel (faster) or
sequentially (more accurate individual timing).
Args:
old_metric: The baseline/old metric instance
new_metric: The new/updated metric instance
dataset: List of dictionaries containing the data samples
old_metric_type: Type identifier for old metric ("old" or "new")
new_metric_type: Type identifier for new metric ("old" or "new")
max_concurrent: Maximum number of concurrent requests per metric (default: 10)
parallel_metrics: If True, run both metrics in parallel. If False, run sequentially
for more accurate individual timing (default: True)
Returns:
MetricDiffResult containing detailed comparison statistics
Example:
>>> result = await compare_metrics(
... old_metric=legacy_metric,
... new_metric=modern_metric,
... dataset=test_data,
... max_concurrent=5,
... parallel_metrics=True,
... )
>>> result.print_summary()
"""
if parallel_metrics:
print(
f"Running both metrics in parallel on {len(dataset)} samples (max {max_concurrent} concurrent)..."
)
# Run both metrics concurrently using asyncio.gather
(old_scores, old_time), (new_scores, new_time) = await asyncio.gather(
run_metric_on_dataset(old_metric, dataset, old_metric_type, max_concurrent),
run_metric_on_dataset(new_metric, dataset, new_metric_type, max_concurrent),
)
else:
# Sequential execution for more accurate individual timing
print(
f"Running old metric on {len(dataset)} samples (max {max_concurrent} concurrent)..."
)
old_scores, old_time = await run_metric_on_dataset(
old_metric, dataset, old_metric_type, max_concurrent
)
print(
f"Running new metric on {len(dataset)} samples (max {max_concurrent} concurrent)..."
)
new_scores, new_time = await run_metric_on_dataset(
new_metric, dataset, new_metric_type, max_concurrent
)
# Calculate differences
diffs = [new - old for old, new in zip(old_scores, new_scores)]
return MetricDiffResult(
old_scores=old_scores,
new_scores=new_scores,
diffs=diffs,
mean_diff=float(np.mean(diffs)),
max_diff=float(np.max(diffs)),
min_diff=float(np.min(diffs)),
std_diff=float(np.std(diffs)),
old_mean=float(np.mean(old_scores)),
new_mean=float(np.mean(new_scores)),
old_time=old_time,
new_time=new_time,
)
async def run_metric_on_dataset_with_batching(
metric: Any,
dataset: List[Dict[str, Any]],
metric_type: str = "new",
batch_size: int = 5,
) -> Tuple[List[float], float]:
"""
Run metric using batch processing if available (for better performance).
This function attempts to use the metric's abatch_score method if available,
which can be more efficient than individual scoring. Falls back to concurrent
processing if batching is not supported.
Args:
metric: The metric instance
dataset: List of dictionaries containing the data samples
metric_type: "old" or "new" - old metrics don't support batching
batch_size: Number of samples per batch (default: 5)
Returns:
Tuple of (scores list, execution time in seconds)
Example:
>>> scores, time = await run_metric_on_dataset_with_batching(
... metric=my_metric,
... dataset=test_data,
... metric_type="new",
... batch_size=10,
... )
"""
# Check if metric supports batching
has_batch = hasattr(metric, "abatch_score")
if not has_batch or metric_type == "old":
# Fall back to concurrent processing
print(" Batching not available, using concurrent processing...")
return await run_metric_on_dataset(metric, dataset, metric_type)
start_time = time.time()
all_scores = []
# Process in batches
num_batches = (len(dataset) + batch_size - 1) // batch_size
print(
f" Processing {len(dataset)} samples in {num_batches} batches of {batch_size}..."
)
for i in range(0, len(dataset), batch_size):
batch = dataset[i : i + batch_size]
try:
results = await metric.abatch_score(batch)
scores = [r.value for r in results]
all_scores.extend(scores)
except Exception as e:
print(
f" Warning: Batch {i // batch_size + 1} failed ({e}), falling back to individual processing..."
)
# Fall back to individual processing for this batch
for sample in batch:
try:
result = await metric.ascore(**sample)
all_scores.append(result.value)
except Exception as e2:
print(f" Error processing sample: {e2}")
all_scores.append(np.nan)
execution_time = time.time() - start_time
return all_scores, execution_time
def export_comparison_results(
result: MetricDiffResult,
dataset: List[Dict[str, Any]],
filename: str = "metric_comparison_results.csv",
):
"""
Export comparison results to CSV file.
The CSV includes all scores, differences, and the original dataset fields,
plus a summary row with aggregate statistics.
Args:
result: MetricDiffResult object containing comparison data
dataset: Original dataset (to include context in export)
filename: Output CSV filename (default: "metric_comparison_results.csv")
Example:
>>> export_comparison_results(
... result=comparison_result,
... dataset=test_data,
... filename="context_recall_results.csv",
... )
"""
df = result.to_dataframe()
# Add dataset information
for key in dataset[0].keys():
df[key] = [sample.get(key, "") for sample in dataset]
# Add summary statistics as a separate row
summary = pd.DataFrame(
[
{
**{
key: "SUMMARY" if i == 0 else ""
for i, key in enumerate(dataset[0].keys())
},
"old_score": result.old_mean,
"new_score": result.new_mean,
"diff": result.mean_diff,
"abs_diff": np.mean([abs(d) for d in result.diffs]),
}
]
)
df = pd.concat([df, summary], ignore_index=True)
df.to_csv(filename, index=False)
print(f"Results exported to {filename}")