use super::factory::cutoff_name;
use super::{Metric, argsort_desc, group_ranges, weighted_mean};
use crate::K_RT_EPS_F32;
use rayon::prelude::*;
fn grouped_average(
preds: &[f32],
labels: &[f32],
weights: Option<&[f32]>,
group: Option<&crate::data::GroupInfo>,
score: impl Fn(&[f32], &[f32]) -> f64 + Sync,
) -> f64 {
let ranges = group_ranges(preds.len(), group);
let weight = |start: usize| weights.map_or(1.0, |values| f64::from(values[start]));
let totals = fold_groups(
&ranges,
|start, end| (weight(start) != 0.0).then(|| score(&preds[start..end], &labels[start..end])),
(0.0, 0.0),
|(sum, weight_sum), (start, _), score| match score {
Some(score) => {
let weight = weight(start);
(sum + weight * score, weight_sum + weight)
}
None => (sum, weight_sum),
},
);
weighted_mean(totals)
}
const PARALLEL_GROUP_ROWS: usize = 4096;
pub(super) fn fold_groups<T: Send, A>(
ranges: &[(usize, usize)],
f: impl Fn(usize, usize) -> T + Sync,
init: A,
mut fold: impl FnMut(A, (usize, usize), T) -> A,
) -> A {
let rows: usize = ranges.iter().map(|(start, end)| end - start).sum();
if ranges.len() > 1 && rows >= PARALLEL_GROUP_ROWS && rayon::current_num_threads() > 1 {
let values: Vec<T> = ranges
.par_iter()
.map(|&(start, end)| f(start, end))
.collect();
ranges
.iter()
.zip(values)
.fold(init, |acc, (&range, value)| fold(acc, range, value))
} else {
ranges.iter().fold(init, |acc, &(start, end)| {
fold(acc, (start, end), f(start, end))
})
}
}
#[derive(Debug, Clone)]
pub(crate) struct Ndcg {
k: Option<usize>,
name: String,
}
impl Default for Ndcg {
fn default() -> Self {
Self::new(None)
}
}
impl Ndcg {
pub(crate) fn new(k: Option<usize>) -> Self {
Ndcg {
k,
name: cutoff_name("ndcg", k),
}
}
fn group_ndcg(&self, preds: &[f32], labels: &[f32]) -> f64 {
let m = preds.len();
let cut = self.k.map_or(m, |k| k.min(m));
let order = argsort_desc(preds);
let dcg: f64 = order[..cut]
.iter()
.enumerate()
.map(|(p, &i)| ndcg_gain(f64::from(labels[i])) * ndcg_discount(p))
.sum();
let idcg = ideal_dcg(labels, cut);
if idcg <= 0.0 { 0.0 } else { dcg / idcg }
}
}
impl Metric for Ndcg {
fn name(&self) -> &str {
&self.name
}
fn maximize(&self) -> bool {
true
}
fn supports_label_matrix(&self) -> bool {
false
}
fn eval(&self, preds: &[f32], labels: &[f32], weights: Option<&[f32]>) -> f64 {
self.eval_grouped(preds, labels, weights, None)
}
fn eval_grouped(
&self,
preds: &[f32],
labels: &[f32],
weights: Option<&[f32]>,
group: Option<&crate::data::GroupInfo>,
) -> f64 {
nan_unless_consistent!(preds, labels, weights, 1);
grouped_average(preds, labels, weights, group, |p, l| self.group_ndcg(p, l))
}
}
#[inline]
fn ndcg_gain(rel: f64) -> f64 {
(2.0f64).powf(rel) - 1.0
}
#[inline]
fn ndcg_discount(p: usize) -> f64 {
1.0 / ((p + 2) as f64).log2()
}
fn ideal_dcg(labels: &[f32], cut: usize) -> f64 {
let mut ideal: Vec<f64> = labels.iter().map(|&l| f64::from(l)).collect();
ideal.sort_by(|a, b| b.total_cmp(a));
ideal[..cut]
.iter()
.enumerate()
.map(|(p, &l)| ndcg_gain(l) * ndcg_discount(p))
.sum()
}
#[derive(Debug, Clone)]
pub(crate) struct MeanAveragePrecision {
k: Option<usize>,
name: String,
}
impl Default for MeanAveragePrecision {
fn default() -> Self {
Self::new(None)
}
}
impl MeanAveragePrecision {
pub(crate) fn new(k: Option<usize>) -> Self {
MeanAveragePrecision {
k,
name: cutoff_name("map", k),
}
}
fn group_ap(&self, preds: &[f32], labels: &[f32]) -> f64 {
let m = preds.len();
let cut = self.k.map_or(m, |k| k.min(m));
let order = argsort_desc(preds);
let num_rel = labels.iter().filter(|&&l| l > 0.0).count();
if num_rel == 0 {
return 0.0;
}
let mut hits = 0usize;
let mut ap = 0.0f64;
for (p, &i) in order[..cut].iter().enumerate() {
if labels[i] > 0.0 {
hits += 1;
ap += hits as f64 / (p + 1) as f64;
}
}
ap / num_rel as f64
}
}
impl Metric for MeanAveragePrecision {
fn name(&self) -> &str {
&self.name
}
fn maximize(&self) -> bool {
true
}
fn supports_label_matrix(&self) -> bool {
false
}
fn eval(&self, preds: &[f32], labels: &[f32], weights: Option<&[f32]>) -> f64 {
self.eval_grouped(preds, labels, weights, None)
}
fn eval_grouped(
&self,
preds: &[f32],
labels: &[f32],
weights: Option<&[f32]>,
group: Option<&crate::data::GroupInfo>,
) -> f64 {
nan_unless_consistent!(preds, labels, weights, 1);
grouped_average(preds, labels, weights, group, |p, l| self.group_ap(p, l))
}
}
const DEFAULT_TOP_K: usize = 32;
#[derive(Debug, Clone)]
pub(crate) struct Precision {
k: usize,
name: String,
}
impl Precision {
pub(super) fn new(k: Option<usize>) -> Self {
Precision {
k: k.unwrap_or(DEFAULT_TOP_K),
name: cutoff_name("pre", k),
}
}
}
impl Metric for Precision {
fn name(&self) -> &str {
&self.name
}
fn maximize(&self) -> bool {
true
}
fn eval(&self, preds: &[f32], labels: &[f32], weights: Option<&[f32]>) -> f64 {
self.eval_grouped(preds, labels, weights, None)
}
fn eval_grouped(
&self,
preds: &[f32],
labels: &[f32],
weights: Option<&[f32]>,
group: Option<&crate::data::GroupInfo>,
) -> f64 {
nan_unless_consistent!(preds, labels, weights, 1);
let binary = |y: f32| (y - 1.0).abs() < K_RT_EPS_F32 || y.abs() < K_RT_EPS_F32;
if !labels.iter().all(|&y| binary(y)) {
return f64::NAN;
}
let ranges = group_ranges(preds.len(), group);
let weight = |start: usize| weights.map_or(1.0f32, |w| w[start]);
let precision = |start: usize, end: usize| {
let weight = weight(start);
(weight != 0.0).then(|| {
let order = argsort_desc(&preds[start..end]);
let n = self.k.min(end - start);
let hits: f64 = order[..n]
.iter()
.map(|&i| f64::from(labels[start + i] * weight))
.sum();
hits / n as f64
})
};
let totals = fold_groups(
&ranges,
precision,
(0.0f64, 0.0f64),
|(score, weight_sum), (start, _), precision| match precision {
Some(precision) => (score + precision, weight_sum + f64::from(weight(start))),
None => (score, weight_sum),
},
);
weighted_mean(totals).min(1.0)
}
fn supports_label_matrix(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::GroupInfo;
#[test]
fn precision_at_k_per_group_and_clipped() {
let preds = [0.9, 0.8, 0.1, 0.7, 0.2, 0.6, 0.5, 0.1];
let labels = [1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0];
let group = GroupInfo::from_sizes(&[4, 4]);
let at2 = Precision::new(Some(2));
let v = at2.eval_grouped(&preds, &labels, None, Some(&group));
assert!((v - 0.75).abs() < 1e-12, "{v}");
let v = Precision::new(None).eval_grouped(&preds, &labels, None, Some(&group));
assert!((v - 0.5).abs() < 1e-12, "{v}");
}
#[test]
fn precision_weights_groups() {
let preds = [0.9, 0.1, 0.9, 0.1];
let labels = [1.0, 0.0, 0.0, 1.0];
let weights = [3.0, 3.0, 1.0, 1.0];
let group = GroupInfo::from_sizes(&[2, 2]);
let v = Precision::new(Some(1)).eval_grouped(&preds, &labels, Some(&weights), Some(&group));
assert!((v - 0.75).abs() < 1e-12, "{v}");
}
#[test]
fn precision_rejects_graded_labels() {
let v = Precision::new(None).eval(&[0.5, 0.2], &[2.0, 0.0], None);
assert!(v.is_nan());
}
#[test]
fn precision_of_empty_input_and_groups() {
use crate::metric::{DEFAULT_SOURCE, named};
for name in ["pre@5", "ndcg", "map"] {
let m = named(name, 1, &DEFAULT_SOURCE).unwrap();
assert_eq!(m.eval(&[], &[], Some(&[])), 0.0, "{name}");
assert_eq!(m.eval(&[], &[], None), 0.0, "{name}");
}
let pre = Precision::new(Some(1));
let group = GroupInfo::from_sizes(&[2, 0]);
let v = pre.eval_grouped(&[0.9, 0.1], &[1.0, 0.0], Some(&[1.0, 1.0]), Some(&group));
assert_eq!(v, 1.0);
}
#[test]
fn factory_names_and_rejections() {
use crate::metric::{DEFAULT_SOURCE, XgboostMetricSource, named};
for name in ["pre", "pre@5"] {
let m = named(name, 1, &DEFAULT_SOURCE).unwrap();
assert_eq!(m.name(), name);
assert!(m.maximize());
}
let flat = XgboostMetricSource {
huber_slope: 0.0,
..DEFAULT_SOURCE
};
assert!(named("mphe", 1, &flat).is_err());
}
}