Current section

Files

Jump to
arcana lib arcana evaluation metrics.ex
Raw

lib/arcana/evaluation/metrics.ex

defmodule Arcana.Evaluation.Metrics do
@moduledoc """
Computes retrieval evaluation metrics.
Supports Recall@K, Precision@K, MRR (Mean Reciprocal Rank),
and Hit Rate@K for standard K values [1, 3, 5, 10].
"""
@k_values [1, 3, 5, 10]
@doc """
Returns the K values used for evaluation.
"""
def k_values, do: @k_values
@doc """
Evaluates a single test case against search results.
Returns a map with per-K metrics and debugging info.
"""
def evaluate_case(test_case, search_results) do
retrieved_ids = Enum.map(search_results, & &1.id)
expected_ids = test_case.relevant_chunks |> Enum.map(& &1.id) |> MapSet.new()
%{
test_case_id: test_case.id,
question: test_case.question,
expected_chunk_ids: MapSet.to_list(expected_ids),
retrieved_chunk_ids: retrieved_ids,
recall: recall_at_k(retrieved_ids, expected_ids),
precision: precision_at_k(retrieved_ids, expected_ids),
reciprocal_rank: reciprocal_rank(retrieved_ids, expected_ids),
hit: hit_at_k(retrieved_ids, expected_ids)
}
end
@doc """
Aggregates per-case results into summary metrics.
"""
def aggregate(case_results) when is_list(case_results) do
n = length(case_results)
if n == 0 do
empty_metrics()
else
%{
recall_at_1: avg(case_results, [:recall, 1]),
recall_at_3: avg(case_results, [:recall, 3]),
recall_at_5: avg(case_results, [:recall, 5]),
recall_at_10: avg(case_results, [:recall, 10]),
precision_at_1: avg(case_results, [:precision, 1]),
precision_at_3: avg(case_results, [:precision, 3]),
precision_at_5: avg(case_results, [:precision, 5]),
precision_at_10: avg(case_results, [:precision, 10]),
mrr: avg_field(case_results, :reciprocal_rank),
hit_rate_at_1: hit_rate(case_results, 1),
hit_rate_at_3: hit_rate(case_results, 3),
hit_rate_at_5: hit_rate(case_results, 5),
hit_rate_at_10: hit_rate(case_results, 10),
test_case_count: n
}
end
end
defp empty_metrics do
%{
recall_at_1: 0.0,
recall_at_3: 0.0,
recall_at_5: 0.0,
recall_at_10: 0.0,
precision_at_1: 0.0,
precision_at_3: 0.0,
precision_at_5: 0.0,
precision_at_10: 0.0,
mrr: 0.0,
hit_rate_at_1: 0.0,
hit_rate_at_3: 0.0,
hit_rate_at_5: 0.0,
hit_rate_at_10: 0.0,
test_case_count: 0
}
end
# Recall@K: what fraction of relevant docs appear in top K?
defp recall_at_k(retrieved, expected) do
expected_size = MapSet.size(expected)
Map.new(@k_values, fn k ->
top_k = retrieved |> Enum.take(k) |> MapSet.new()
hits = MapSet.intersection(top_k, expected) |> MapSet.size()
{k, if(expected_size > 0, do: hits / expected_size, else: 0.0)}
end)
end
# Precision@K: what fraction of top K are relevant?
defp precision_at_k(retrieved, expected) do
Map.new(@k_values, fn k ->
top_k = Enum.take(retrieved, k)
hits = Enum.count(top_k, &MapSet.member?(expected, &1))
{k, hits / k}
end)
end
# Reciprocal Rank: 1/position of first relevant result
defp reciprocal_rank(retrieved, expected) do
case Enum.find_index(retrieved, &MapSet.member?(expected, &1)) do
nil -> 0.0
idx -> 1.0 / (idx + 1)
end
end
# Hit@K: did we find at least one relevant doc in top K?
defp hit_at_k(retrieved, expected) do
Map.new(@k_values, fn k ->
top_k = retrieved |> Enum.take(k) |> MapSet.new()
has_hit = MapSet.intersection(top_k, expected) |> MapSet.size() > 0
{k, has_hit}
end)
end
defp avg(results, path) do
values = Enum.map(results, &get_in(&1, path))
Enum.sum(values) / length(values)
end
defp avg_field(results, field) do
values = Enum.map(results, &Map.get(&1, field))
Enum.sum(values) / length(values)
end
defp hit_rate(results, k) do
hits = Enum.count(results, &get_in(&1, [:hit, k]))
hits / length(results)
end
end