use std::{collections::HashSet, hash::Hash};
use diskann_utils::{
strided::StridedView,
views::{Matrix, MatrixView},
};
use thiserror::Error;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct RecallMetrics {
pub recall_k: usize,
pub recall_n: usize,
pub num_queries: usize,
pub average: f64,
}
#[derive(Debug, Error)]
pub enum ComputeRecallError {
#[error("results matrix has {0} rows but ground truth has {1}")]
RowsMismatch(usize, usize),
#[error("distances matrix has {0} rows but ground truth has {1}")]
DistanceRowsMismatch(usize, usize),
#[error("recall k value {0} must be less than or equal to recall n {1}")]
RecallKAndNError(usize, usize),
#[error(
"number of groundtruth values per query {0} must be at least the specified recall n {1}"
)]
NotEnoughGroundTruth(usize, usize),
#[error("number of groundtruth distances {0} does not match groundtruth entries {1}")]
GroundTruthDistanceMismatch(usize, usize),
}
pub trait Rows<T> {
fn nrows(&self) -> usize;
fn row(&self, i: usize) -> &[T];
fn ncols(&self) -> Option<usize> {
None
}
}
impl<T> Rows<T> for Matrix<T> {
fn nrows(&self) -> usize {
Matrix::<T>::nrows(self)
}
fn row(&self, i: usize) -> &[T] {
Matrix::<T>::row(self, i)
}
fn ncols(&self) -> Option<usize> {
Some(Matrix::<T>::ncols(self))
}
}
impl<T> Rows<T> for MatrixView<'_, T> {
fn nrows(&self) -> usize {
MatrixView::<'_, T>::nrows(self)
}
fn row(&self, i: usize) -> &[T] {
MatrixView::<'_, T>::row(self, i)
}
fn ncols(&self) -> Option<usize> {
Some(MatrixView::<'_, T>::ncols(self))
}
}
impl<T> Rows<T> for Vec<Vec<T>> {
fn nrows(&self) -> usize {
self.len()
}
fn row(&self, i: usize) -> &[T] {
&self[i]
}
}
pub trait RecallCompatible: Eq + Hash + Clone + std::fmt::Debug {}
impl<T> RecallCompatible for T where T: Eq + Hash + Clone + std::fmt::Debug {}
#[derive(Copy, Clone, Debug)]
pub enum GroundTruthMode {
Fixed,
Flexible,
}
pub fn knn<T>(
groundtruth: &dyn Rows<T>,
groundtruth_distances: Option<StridedView<'_, f32>>,
results: &dyn Rows<T>,
recall_k: usize,
recall_n: usize,
ground_truth_mode: GroundTruthMode,
) -> Result<RecallMetrics, ComputeRecallError>
where
T: RecallCompatible,
{
if recall_k > recall_n {
return Err(ComputeRecallError::RecallKAndNError(recall_k, recall_n));
}
let nrows = results.nrows();
if nrows != groundtruth.nrows() {
return Err(ComputeRecallError::RowsMismatch(nrows, groundtruth.nrows()));
}
if let GroundTruthMode::Fixed = ground_truth_mode {
for i in 0..nrows {
let gt_row = groundtruth.row(i);
if gt_row.len() < recall_k {
return Err(ComputeRecallError::NotEnoughGroundTruth(
gt_row.len(),
recall_k,
));
}
}
}
if let Some(distances) = groundtruth_distances {
if nrows != distances.nrows() {
return Err(ComputeRecallError::DistanceRowsMismatch(
distances.nrows(),
nrows,
));
}
for i in 0..nrows {
let gt_row = groundtruth.row(i);
let distances_row = distances.row(i);
if gt_row.len() != distances_row.len() {
return Err(ComputeRecallError::GroundTruthDistanceMismatch(
distances_row.len(),
gt_row.len(),
));
}
}
}
let mut recall_values: Vec<f64> = Vec::new();
let mut this_groundtruth = HashSet::new();
let mut this_results = HashSet::new();
for i in 0..results.nrows() {
let result = results.row(i);
let gt_row = groundtruth.row(i);
let this_recall_k = gt_row.len().min(recall_k);
if this_recall_k == 0 {
continue;
}
this_groundtruth.clear();
this_groundtruth.extend(gt_row.iter().take(this_recall_k).cloned());
if let Some(distances) = groundtruth_distances {
let distances_row = distances.row(i);
let last_distance = distances_row[this_recall_k - 1];
for (d, g) in distances_row.iter().zip(gt_row.iter()).skip(this_recall_k) {
if *d == last_distance {
this_groundtruth.insert(g.clone());
} else {
break;
}
}
}
this_results.clear();
this_results.extend(result.iter().take(recall_n).cloned());
let r = this_groundtruth
.iter()
.filter(|i| this_results.contains(i))
.count()
.min(this_recall_k);
let recall = (r as f64) / (this_recall_k as f64);
recall_values.push(recall);
}
let total: f64 = recall_values.iter().sum();
let average = if recall_values.is_empty() {
0.0
} else {
total / (recall_values.len() as f64)
};
Ok(RecallMetrics {
recall_k,
recall_n,
num_queries: nrows,
average,
})
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct AveragePrecisionMetrics {
pub num_queries: usize,
pub average_precision: f64,
}
#[derive(Debug, Error)]
pub enum AveragePrecisionError {
#[error("results has {0} elements but ground truth has {1}")]
EntriesMismatch(usize, usize),
}
pub fn average_precision<T>(
results: &dyn Rows<T>,
groundtruth: &dyn Rows<T>,
) -> Result<AveragePrecisionMetrics, AveragePrecisionError>
where
T: RecallCompatible,
{
let nrows = results.nrows();
let groundtruth_nrows = groundtruth.nrows();
if nrows != groundtruth_nrows {
return Err(AveragePrecisionError::EntriesMismatch(
nrows,
groundtruth_nrows,
));
}
let mut num_gt_results = 0;
let mut num_reported_results = 0;
let mut scratch = HashSet::new();
let nrows = results.nrows();
for i in 0..nrows {
let result = results.row(i);
let gt = groundtruth.row(i);
scratch.clear();
scratch.extend(result.iter().cloned());
num_reported_results += gt.iter().filter(|i| scratch.contains(i)).count();
num_gt_results += gt.len();
}
let average_precision = (num_reported_results as f64) / (num_gt_results as f64);
Ok(AveragePrecisionMetrics {
average_precision,
num_queries: nrows,
})
}
#[cfg(test)]
mod tests {
use diskann_utils::views::{self, Matrix};
use super::*;
fn test_rows_inner(rows: &dyn Rows<usize>, ncols: Option<usize>) {
assert_eq!(rows.ncols(), ncols);
assert_eq!(rows.nrows(), 3);
assert_eq!(rows.row(0), &[0, 1, 2, 3]);
assert_eq!(rows.row(1), &[4, 5, 6, 7]);
assert_eq!(rows.row(2), &[8, 9, 10, 11]);
}
#[test]
fn test_rows() {
let mut i = 0usize;
let mat = Matrix::new(
views::Init(|| {
let v = i;
i += 1;
v
}),
3,
4,
);
test_rows_inner(&mat, Some(4));
test_rows_inner(&(mat.as_view()), Some(4));
let vecs = vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7], vec![8, 9, 10, 11]];
test_rows_inner(&vecs, None);
}
struct ExpectedRecall {
recall_k: usize,
recall_n: usize,
components: Vec<usize>,
}
impl ExpectedRecall {
fn new(recall_k: usize, recall_n: usize, components: Vec<usize>) -> Self {
assert!(recall_k <= recall_n);
components.iter().for_each(|x| {
assert!(*x <= recall_k);
});
Self {
recall_k,
recall_n,
components,
}
}
fn compute_recall(&self) -> f64 {
(self.components.iter().sum::<usize>() as f64)
/ ((self.components.len() * self.recall_k) as f64)
}
}
#[test]
fn test_happy_path() {
let groundtruth = Matrix::try_from(
vec![
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ]
.into(),
4,
10,
)
.unwrap();
let distances = Matrix::try_from(
vec![
0.0, 1.0, 2.0, 3.0, 3.0, 3.0, 3.0, 4.0, 5.0, 6.0, 2.0, 3.0, 3.0, 3.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 0.0, 1.0, 2.0, 3.0, 3.0, 3.0, 3.0, 4.0, 5.0, 6.0, ]
.into(),
4,
10,
)
.unwrap();
let our_results = Matrix::try_from(
vec![
100, 0, 1, 2, 5, 6, 100, 101, 7, 8, 9, 10, 0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5, ]
.into(),
4,
6,
)
.unwrap();
let expected_no_ties = vec![
ExpectedRecall::new(1, 1, vec![0, 0, 1, 1]),
ExpectedRecall::new(2, 2, vec![1, 0, 2, 2]),
ExpectedRecall::new(3, 3, vec![2, 1, 3, 3]),
ExpectedRecall::new(4, 4, vec![3, 2, 4, 4]),
ExpectedRecall::new(5, 5, vec![3, 3, 5, 5]),
ExpectedRecall::new(6, 6, vec![4, 4, 6, 6]),
ExpectedRecall::new(1, 2, vec![1, 0, 1, 1]),
ExpectedRecall::new(1, 3, vec![1, 0, 1, 1]),
ExpectedRecall::new(2, 3, vec![2, 0, 2, 2]),
ExpectedRecall::new(3, 5, vec![3, 1, 3, 3]),
];
let epsilon = 1e-6;
for (i, expected) in expected_no_ties.iter().enumerate() {
assert_eq!(expected.components.len(), our_results.nrows());
let recall = knn(
&groundtruth,
None,
&our_results,
expected.recall_k,
expected.recall_n,
GroundTruthMode::Fixed,
)
.unwrap();
let left = recall.average;
let right = expected.compute_recall();
assert!(
(left - right).abs() < epsilon,
"left = {}, right = {} on input {}",
left,
right,
i
);
assert_eq!(recall.num_queries, our_results.nrows());
assert_eq!(recall.recall_k, expected.recall_k);
assert_eq!(recall.recall_n, expected.recall_n);
}
let expected_with_ties = vec![
ExpectedRecall::new(1, 1, vec![0, 0, 1, 1]),
ExpectedRecall::new(2, 2, vec![1, 0, 2, 2]),
ExpectedRecall::new(3, 3, vec![2, 1, 3, 3]),
ExpectedRecall::new(4, 4, vec![3, 2, 4, 4]),
ExpectedRecall::new(5, 5, vec![4, 3, 5, 5]), ExpectedRecall::new(6, 6, vec![5, 4, 6, 6]), ExpectedRecall::new(1, 2, vec![1, 0, 1, 1]),
ExpectedRecall::new(1, 3, vec![1, 0, 1, 1]),
ExpectedRecall::new(2, 3, vec![2, 1, 2, 2]),
ExpectedRecall::new(4, 5, vec![4, 3, 4, 4]),
];
for (i, expected) in expected_with_ties.iter().enumerate() {
assert_eq!(expected.components.len(), our_results.nrows());
let recall = knn(
&groundtruth,
Some(distances.as_view().into()),
&our_results,
expected.recall_k,
expected.recall_n,
GroundTruthMode::Fixed,
)
.unwrap();
let left = recall.average;
let right = expected.compute_recall();
assert!(
(left - right).abs() < epsilon,
"left = {}, right = {} on input {}",
left,
right,
i
);
assert_eq!(recall.num_queries, our_results.nrows());
assert_eq!(recall.recall_k, expected.recall_k);
assert_eq!(recall.recall_n, expected.recall_n);
}
}
#[test]
fn test_error_recall_k_and_n() {
let groundtruth = Matrix::<u32>::new(0, 10, 10);
let results = Matrix::<u32>::new(0, 10, 10);
let err = knn(&groundtruth, None, &results, 11, 10, GroundTruthMode::Fixed).unwrap_err();
assert!(matches!(err, ComputeRecallError::RecallKAndNError(..)));
}
#[test]
fn test_error_rows_mismatch() {
let groundtruth = Matrix::<u32>::new(0, 11, 10);
let results = Matrix::<u32>::new(0, 10, 10);
let err = knn(&groundtruth, None, &results, 10, 10, GroundTruthMode::Fixed).unwrap_err();
assert!(matches!(err, ComputeRecallError::RowsMismatch(..)));
let err_allow_insufficient_results =
knn(&groundtruth, None, &results, 10, 10, GroundTruthMode::Fixed).unwrap_err();
assert!(matches!(
err_allow_insufficient_results,
ComputeRecallError::RowsMismatch(..)
));
}
#[test]
fn test_error_not_enough_groundtruth() {
let groundtruth = Matrix::<u32>::new(0, 10, 5);
let results = Matrix::<u32>::new(0, 10, 10);
let err = knn(&groundtruth, None, &results, 10, 10, GroundTruthMode::Fixed).unwrap_err();
assert!(matches!(err, ComputeRecallError::NotEnoughGroundTruth(..)));
let err_allow_insufficient_results =
knn(&groundtruth, None, &results, 10, 10, GroundTruthMode::Fixed).unwrap_err();
assert!(matches!(
err_allow_insufficient_results,
ComputeRecallError::NotEnoughGroundTruth(..)
));
}
#[test]
fn test_dynamic_groundtruth_valid() {
let groundtruth: Vec<_> = (0..10).map(|_| vec![0u32; 5]).collect();
let results = Matrix::<u32>::new(0, 10, 10);
let recall_flexible = knn(
&groundtruth,
None,
&results,
10,
10,
GroundTruthMode::Flexible,
)
.unwrap();
assert_eq!(recall_flexible.num_queries, 10);
let err = knn(&groundtruth, None, &results, 10, 10, GroundTruthMode::Fixed).unwrap_err();
assert!(matches!(err, ComputeRecallError::NotEnoughGroundTruth(..)));
assert_eq!(recall_flexible.num_queries, 10);
}
#[test]
fn test_dynamic_groundtruth_full_match() {
let gt_row: Vec<u32> = (1..=5).collect();
let groundtruth: Vec<_> = (0..10).map(|_| gt_row.clone()).collect();
let mut results = Matrix::<u32>::new(0, 10, 10);
for i in 0..10 {
for (j, v) in (1u32..=10).enumerate() {
results[(i, j)] = v;
}
}
let recall = knn(
&groundtruth,
None,
&results,
10,
10,
GroundTruthMode::Flexible,
)
.unwrap();
assert!((recall.average - 1.0).abs() < 1e-10);
}
#[test]
fn test_dynamic_groundtruth_partial_match() {
let gt_row: Vec<u32> = (1..=5).collect();
let groundtruth: Vec<_> = (0..10).map(|_| gt_row.clone()).collect();
let mut results = Matrix::<u32>::new(0, 10, 10);
let res_row: Vec<u32> = vec![1, 2, 3, 6, 7, 8, 9, 10, 11, 12];
for i in 0..10 {
for (j, &v) in res_row.iter().enumerate() {
results[(i, j)] = v;
}
}
let recall = knn(
&groundtruth,
None,
&results,
10,
10,
GroundTruthMode::Flexible,
)
.unwrap();
assert!((recall.average - 0.6).abs() < 1e-10);
}
#[test]
fn test_dynamic_groundtruth_mixed_zero_nonzero() {
let mut groundtruth: Vec<Vec<u32>> = Vec::new();
for _ in 0..5 {
groundtruth.push((1..=5).collect());
}
for _ in 0..5 {
groundtruth.push(vec![]);
}
let mut results = Matrix::<u32>::new(0, 10, 10);
for i in 0..10 {
for (j, v) in (1u32..=10).enumerate() {
results[(i, j)] = v;
}
}
let recall = knn(
&groundtruth,
None,
&results,
10,
10,
GroundTruthMode::Flexible,
)
.unwrap();
assert_eq!(recall.num_queries, 10);
assert!((recall.average - 1.0).abs() < 1e-10);
}
#[test]
fn test_dynamic_groundtruth_all_zero() {
let groundtruth: Vec<Vec<u32>> = (0..10).map(|_| vec![]).collect();
let results = Matrix::<u32>::new(0, 10, 10);
let recall = knn(
&groundtruth,
None,
&results,
10,
10,
GroundTruthMode::Flexible,
)
.unwrap();
assert_eq!(recall.num_queries, 10);
assert_eq!(recall.average, 0.0);
assert!(!recall.average.is_nan());
assert!(!recall.average.is_infinite());
}
#[test]
fn test_error_distance_rows_mismatch() {
let groundtruth = Matrix::<u32>::new(0, 10, 10);
let distances = Matrix::<f32>::new(0.0, 9, 10);
let results = Matrix::<u32>::new(0, 10, 10);
let err = knn(
&groundtruth,
Some(distances.as_view().into()),
&results,
10,
10,
GroundTruthMode::Fixed,
)
.unwrap_err();
assert!(matches!(err, ComputeRecallError::DistanceRowsMismatch(..)));
}
#[test]
fn test_error_distance_cols_mismatch() {
let groundtruth = Matrix::<u32>::new(0, 10, 10);
let distances = Matrix::<f32>::new(0.0, 10, 9);
let results = Matrix::<u32>::new(0, 10, 10);
let err = knn(
&groundtruth,
Some(distances.as_view().into()),
&results,
10,
10,
GroundTruthMode::Fixed,
)
.unwrap_err();
assert!(matches!(
err,
ComputeRecallError::GroundTruthDistanceMismatch(..)
));
}
#[test]
fn test_error_distance_cols_mismatch_variable_size_groundtruth() {
let groundtruth: Vec<Vec<u32>> = vec![vec![1, 2, 3], vec![4, 5]];
let distances: Vec<Vec<f32>> = vec![vec![0.1, 0.2], vec![0.3, 0.4]];
let distances = Matrix::try_from(
distances.into_iter().flatten().collect::<Vec<_>>().into(),
2,
2,
)
.unwrap();
let results: Vec<Vec<u32>> = vec![vec![1, 2, 3], vec![4, 5, 6]];
let err = knn(
&groundtruth,
Some(distances.as_view().into()),
&results,
3,
3,
GroundTruthMode::Flexible,
)
.unwrap_err();
println!("{err}");
assert!(matches!(
err,
ComputeRecallError::GroundTruthDistanceMismatch(2, 3)
));
}
}