vocal-semantic-search / tests /test_evaluate.py
Nam Dao
Deploy: catalog, vectors, previews and ONNX export
edfebcc
Raw History Blame Contribute Delete
4.62 kB
"""Metric tests against hand-computed values.
Eval code that is silently wrong is worse than no eval, so every metric is
checked against a case whose answer can be worked out on paper.
"""
from __future__ import annotations
import numpy as np
import pytest
from mss.evaluate import (
average_precision,
evaluate_retrieval,
markdown_table,
ndcg_at_k,
random_baseline,
rank_matrix,
recall_at_k,
reciprocal_rank,
)
def test_recall_counts_relevant_items_found():
ranking = np.array([3, 1, 4, 0, 2])
assert recall_at_k(ranking, {3}, 1) == 1.0
assert recall_at_k(ranking, {4}, 1) == 0.0
assert recall_at_k(ranking, {4}, 3) == 1.0
# two relevant, one inside the cut -> half of them recalled
assert recall_at_k(ranking, {3, 2}, 3) == 0.5
def test_reciprocal_rank_uses_the_first_hit():
ranking = np.array([3, 1, 4, 0, 2])
assert reciprocal_rank(ranking, {3}) == 1.0
assert reciprocal_rank(ranking, {1}) == pytest.approx(1 / 2)
assert reciprocal_rank(ranking, {2}) == pytest.approx(1 / 5)
# first of several relevant items is what counts
assert reciprocal_rank(ranking, {4, 2}) == pytest.approx(1 / 3)
assert reciprocal_rank(ranking, set()) == 0.0
def test_average_precision_hand_computed():
ranking = np.array([0, 1, 2, 3, 4])
# relevant at positions 1 and 3 (1-based): precision 1/1 and 2/3
assert average_precision(ranking, {0, 2}) == pytest.approx((1.0 + 2 / 3) / 2)
# a single relevant item makes AP equal to the reciprocal rank
assert average_precision(ranking, {2}) == pytest.approx(1 / 3)
def test_average_precision_penalises_unretrieved_relevants():
# only the top 3 can ever be hit here; the 4th relevant item is missing
ranking = np.array([0, 1, 2])
ap = average_precision(ranking, {0, 1, 2, 99})
assert ap == pytest.approx((1.0 + 1.0 + 1.0) / 4)
def test_ndcg_is_one_for_a_perfect_ranking():
ranking = np.array([0, 1, 2, 3])
assert ndcg_at_k(ranking, {0, 1}, 4) == pytest.approx(1.0)
def test_ndcg_hand_computed_for_an_imperfect_ranking():
ranking = np.array([0, 1, 2, 3])
# one relevant item at position 2 -> DCG = 1/log2(3), IDCG = 1/log2(2) = 1
assert ndcg_at_k(ranking, {1}, 4) == pytest.approx(1 / np.log2(3))
def test_ndcg_ignores_relevants_beyond_the_cut():
ranking = np.array([9, 8, 7, 0])
assert ndcg_at_k(ranking, {0}, 3) == 0.0
def test_rank_matrix_orders_by_cosine():
items = np.eye(4, dtype=np.float32)
queries = np.array([[0, 0, 1, 0]], dtype=np.float32)
assert rank_matrix(queries, items)[0][0] == 2
def test_rank_matrix_is_deterministic_under_ties():
items = np.ones((5, 3), dtype=np.float32)
queries = np.ones((1, 3), dtype=np.float32)
a = rank_matrix(queries, items)
b = rank_matrix(queries, items)
np.testing.assert_array_equal(a, b)
np.testing.assert_array_equal(a[0], np.arange(5))
def test_perfect_retrieval_scores_one():
vecs = np.eye(6, dtype=np.float32)
res = evaluate_retrieval(vecs, vecs, [{i} for i in range(6)])
assert res.metrics["R@1"] == pytest.approx(1.0)
assert res.metrics["MRR"] == pytest.approx(1.0)
assert res.metrics["mAP"] == pytest.approx(1.0)
assert res.metrics["median_rank"] == 1.0
def test_metrics_are_bounded_and_ordered():
rng = np.random.default_rng(0)
q = rng.standard_normal((30, 32)).astype(np.float32)
it = rng.standard_normal((80, 32)).astype(np.float32)
q /= np.linalg.norm(q, axis=1, keepdims=True)
it /= np.linalg.norm(it, axis=1, keepdims=True)
res = evaluate_retrieval(q, it, [{i} for i in range(30)])
for key, val in res.metrics.items():
if key != "median_rank":
assert 0.0 <= val <= 1.0, key
# recall cannot shrink as the cut widens
assert res.metrics["R@1"] <= res.metrics["R@5"] <= res.metrics["R@10"]
def test_random_baseline_lands_near_theory():
n_items = 1000
res = random_baseline(n_items, [{i} for i in range(400)], seed=1)
# with one relevant item among n, expected R@k is k/n
assert res.metrics["R@10"] == pytest.approx(10 / n_items, abs=0.01)
assert res.metrics["median_rank"] == n_items / 2
def test_markdown_table_has_a_row_per_result():
rng = np.random.default_rng(2)
v = rng.standard_normal((5, 8)).astype(np.float32)
v /= np.linalg.norm(v, axis=1, keepdims=True)
r1 = evaluate_retrieval(v, v, [{i} for i in range(5)], name="a")
r2 = random_baseline(5, [{i} for i in range(5)])
table = markdown_table([r1, r2])
assert table.count("\n") == 3 # header + separator + 2 rows
assert "| a |" in table