Current section

Files

Jump to
crucible_datasets lib dataset_manager evaluator.ex
Raw

lib/dataset_manager/evaluator.ex

defmodule CrucibleDatasets.Evaluator do
@moduledoc """
Evaluate model predictions against ground truth with multiple metrics.
Supports:
- Exact match (with normalization)
- F1 score (token-level)
- Custom metrics
"""
alias CrucibleDatasets.{Dataset, EvaluationResult, Loader}
alias CrucibleDatasets.Evaluator.{BLEU, ExactMatch, F1, ROUGE}
@type prediction :: %{
id: String.t(),
predicted: any(),
metadata: map()
}
@doc """
Evaluate predictions against a dataset.
## Options
* `:dataset` - Dataset name or dataset struct (required)
* `:metrics` - List of metrics to compute (default: [:exact_match, :f1])
* `:model_name` - Model identifier for tracking (default: "unknown")
## Examples
predictions = [
%{id: "q1", predicted: "Paris", metadata: %{}},
%{id: "q2", predicted: "42", metadata: %{}}
]
{:ok, results} = CrucibleDatasets.Evaluator.evaluate(
predictions,
dataset: :mmlu_stem,
metrics: [:exact_match, :f1],
model_name: "ensemble_5x_flash"
)
results.accuracy
# => 0.85
"""
@spec evaluate([prediction()], keyword()) :: {:ok, EvaluationResult.t()} | {:error, term()}
def evaluate(predictions, opts \\ []) do
metrics = Keyword.get(opts, :metrics, [:exact_match, :f1])
model_name = Keyword.get(opts, :model_name, "unknown")
start_time = System.monotonic_time(:millisecond)
with {:ok, dataset} <- load_dataset(opts),
:ok <- validate_predictions(predictions, dataset),
{:ok, item_results} <- evaluate_items(predictions, dataset, metrics, opts),
{:ok, aggregated} <- aggregate_metrics(item_results, metrics) do
duration_ms = System.monotonic_time(:millisecond) - start_time
result =
EvaluationResult.new(
dataset.name,
dataset.version,
model_name,
item_results,
aggregated,
duration_ms
)
{:ok, result}
end
end
@doc """
Batch evaluate multiple models on same dataset.
Returns comparative results.
"""
@spec evaluate_batch([{String.t(), [prediction()]}], keyword()) ::
{:ok, [EvaluationResult.t()]} | {:error, term()}
def evaluate_batch(model_predictions, opts \\ []) do
results =
Enum.map(model_predictions, fn {model_name, predictions} ->
opts_with_model = Keyword.put(opts, :model_name, model_name)
evaluate(predictions, opts_with_model)
end)
if Enum.all?(results, &match?({:ok, _}, &1)) do
{:ok, Enum.map(results, fn {:ok, result} -> result end)}
else
first_error = Enum.find(results, &match?({:error, _}, &1))
first_error
end
end
# Private helpers
defp load_dataset(opts) do
case Keyword.fetch(opts, :dataset) do
{:ok, %Dataset{} = dataset} ->
{:ok, dataset}
{:ok, dataset_name} ->
Loader.load(dataset_name)
:error ->
{:error, :dataset_required}
end
end
defp validate_predictions(predictions, dataset) do
dataset_ids = MapSet.new(dataset.items, & &1.id)
prediction_ids = MapSet.new(predictions, & &1.id)
# Check if all prediction IDs exist in dataset
invalid_ids = MapSet.difference(prediction_ids, dataset_ids) |> MapSet.to_list()
if invalid_ids == [] do
:ok
else
{:error, {:invalid_prediction_ids, invalid_ids}}
end
end
defp evaluate_items(predictions, dataset, metrics, opts) do
# Create lookup map for expected values
expected_map = Map.new(dataset.items, fn item -> {item.id, item} end)
item_results =
Enum.map(predictions, fn pred ->
expected_item = Map.fetch!(expected_map, pred.id)
evaluate_single_item(pred, expected_item, metrics, opts)
end)
{:ok, item_results}
end
defp evaluate_single_item(prediction, expected_item, metrics, opts) do
metric_scores =
metrics
|> Enum.map(fn metric ->
score = compute_metric(metric, prediction.predicted, expected_item.expected, opts)
{metric, score}
end)
|> Map.new()
correct? = Map.get(metric_scores, :exact_match, 0.0) == 1.0
%{
id: prediction.id,
predicted: prediction.predicted,
expected: expected_item.expected,
correct: correct?,
score: Map.get(metric_scores, :exact_match, 0.0),
metrics: metric_scores,
metadata: Map.merge(expected_item.metadata || %{}, prediction.metadata || %{})
}
end
defp compute_metric(:exact_match, predicted, expected, _opts) do
ExactMatch.compute(predicted, expected)
end
defp compute_metric(:f1, predicted, expected, _opts) do
F1.compute(predicted, expected)
end
defp compute_metric(:bleu, predicted, expected, opts) do
bleu_opts = Keyword.get(opts, :bleu_opts, [])
BLEU.compute(predicted, expected, bleu_opts)
end
defp compute_metric(:rouge, predicted, expected, opts) do
rouge_opts = Keyword.get(opts, :rouge_opts, [])
rouge_scores = ROUGE.compute(predicted, expected, rouge_opts)
get_in(rouge_scores, [:rougel, :f1]) || 0.0
end
defp compute_metric(:rouge1, predicted, expected, opts) do
rouge_opts = Keyword.get(opts, :rouge_opts, [])
rouge_scores =
ROUGE.compute(predicted, expected, Keyword.merge([variants: [:rouge1]], rouge_opts))
get_in(rouge_scores, [:rouge1, :f1]) || 0.0
end
defp compute_metric(:rouge2, predicted, expected, opts) do
rouge_opts = Keyword.get(opts, :rouge_opts, [])
rouge_scores =
ROUGE.compute(predicted, expected, Keyword.merge([variants: [:rouge2]], rouge_opts))
get_in(rouge_scores, [:rouge2, :f1]) || 0.0
end
defp compute_metric(:rougel, predicted, expected, opts) do
rouge_opts = Keyword.get(opts, :rouge_opts, [])
rouge_scores =
ROUGE.compute(predicted, expected, Keyword.merge([variants: [:rougel]], rouge_opts))
get_in(rouge_scores, [:rougel, :f1]) || 0.0
end
defp compute_metric(custom_metric, predicted, expected, _opts)
when is_function(custom_metric, 2) do
custom_metric.(predicted, expected)
end
defp compute_metric(_unknown, _predicted, _expected, _opts), do: 0.0
defp aggregate_metrics(item_results, metrics) do
aggregated =
metrics
|> Enum.map(fn metric ->
scores = Enum.map(item_results, fn item -> item.metrics[metric] || 0.0 end)
avg_score = if scores != [], do: Enum.sum(scores) / length(scores), else: 0.0
{metric, avg_score}
end)
|> Map.new()
{:ok, aggregated}
end
end