use serde::Serialize;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct RecallSample {
pub query: String,
pub expected: Vec<String>,
pub returned_ids: Vec<String>,
}
impl RecallSample {
pub fn new(query: impl Into<String>, expected: Vec<String>, returned_ids: Vec<String>) -> Self {
Self {
query: query.into(),
expected,
returned_ids,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct RecallReport {
pub k: usize,
pub per_query: Vec<PerQueryScore>,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct PerQueryScore {
pub query: String,
pub expected_count: usize,
pub first_hit_rank: Option<usize>,
pub precision_at_k: f64,
pub recall_at_k: f64,
pub ndcg_at_k: f64,
}
impl RecallReport {
pub fn hit_rate(&self) -> f64 {
if self.per_query.is_empty() {
return 0.0;
}
let hits = self
.per_query
.iter()
.filter(|q| q.first_hit_rank.is_some())
.count();
hits as f64 / self.per_query.len() as f64
}
pub fn mean_reciprocal_rank(&self) -> f64 {
if self.per_query.is_empty() {
return 0.0;
}
let sum: f64 = self
.per_query
.iter()
.map(|q| {
q.first_hit_rank
.map(|rank| 1.0 / rank as f64)
.unwrap_or(0.0)
})
.sum();
sum / self.per_query.len() as f64
}
pub fn mean_precision_at_k(&self) -> f64 {
mean(self.per_query.iter().map(|q| q.precision_at_k))
}
pub fn mean_recall_at_k(&self) -> f64 {
mean(
self.per_query
.iter()
.filter(|q| q.expected_count > 0)
.map(|q| q.recall_at_k),
)
}
pub fn mean_ndcg_at_k(&self) -> f64 {
mean(
self.per_query
.iter()
.filter(|q| q.expected_count > 0)
.map(|q| q.ndcg_at_k),
)
}
}
pub fn score_recall(samples: &[RecallSample], k: usize) -> RecallReport {
let per_query = samples
.iter()
.map(|sample| score_sample(sample, k))
.collect();
RecallReport { k, per_query }
}
fn score_sample(sample: &RecallSample, k: usize) -> PerQueryScore {
let window = sample.returned_ids.iter().take(k).collect::<Vec<_>>();
let first_hit_rank = first_hit_rank(&window, &sample.expected);
let precision_at_k = precision_at_k(&window, &sample.expected, k);
let recall_at_k = recall_at_k(&window, &sample.expected);
let ndcg_at_k = ndcg_at_k(&window, &sample.expected, k);
PerQueryScore {
query: sample.query.clone(),
expected_count: sample.expected.len(),
first_hit_rank,
precision_at_k,
recall_at_k,
ndcg_at_k,
}
}
fn first_hit_rank(window: &[&String], expected: &[String]) -> Option<usize> {
window
.iter()
.position(|id| expected.iter().any(|e| e == *id))
.map(|i| i + 1)
}
fn precision_at_k(window: &[&String], expected: &[String], k: usize) -> f64 {
if k == 0 {
return 0.0;
}
let intersect = window
.iter()
.filter(|id| expected.iter().any(|e| e == **id))
.count();
intersect as f64 / k as f64
}
fn recall_at_k(window: &[&String], expected: &[String]) -> f64 {
if expected.is_empty() {
return 0.0;
}
let intersect = window
.iter()
.filter(|id| expected.iter().any(|e| e == **id))
.count();
intersect as f64 / expected.len() as f64
}
fn ndcg_at_k(window: &[&String], expected: &[String], k: usize) -> f64 {
if expected.is_empty() || k == 0 {
return 0.0;
}
let dcg: f64 = window
.iter()
.enumerate()
.map(|(idx, id)| {
let relevance = if expected.iter().any(|e| e == *id) {
1.0
} else {
0.0
};
relevance / ((idx + 2) as f64).log2()
})
.sum();
let ideal_hits = expected.len().min(k);
let idcg: f64 = (0..ideal_hits)
.map(|idx| 1.0 / ((idx + 2) as f64).log2())
.sum();
if idcg == 0.0 {
0.0
} else {
dcg / idcg
}
}
fn mean(iter: impl IntoIterator<Item = f64>) -> f64 {
let (sum, count) = iter
.into_iter()
.fold((0.0, 0usize), |(s, c), v| (s + v, c + 1));
if count == 0 {
0.0
} else {
sum / count as f64
}
}
#[cfg(test)]
mod tests;