Skip to main content

_scors/
lib.rs

1mod combine;
2
3use ndarray::{Array1,ArrayView,ArrayView2,ArrayView3,ArrayViewMut1,Ix1};
4use num;
5use num::traits::float::TotalOrder;
6use numpy::{Element,PyArray,PyArray1,PyArray2,PyArray3,PyArrayDescrMethods,PyArrayDyn,PyArrayMethods,PyReadonlyArray1,PyUntypedArray,PyUntypedArrayMethods,dtype};
7use pyo3::Bound;
8use pyo3::exceptions::PyTypeError;
9use pyo3::marker::Ungil;
10use pyo3::prelude::*;
11use rayon::prelude::*;
12use rayon::ThreadPoolBuilder;
13use std::cmp::{Ordering,PartialOrd};
14use std::iter::{DoubleEndedIterator,repeat};
15use std::ops::AddAssign;
16
17/// Total ordering for sorting — floats use `total_cmp` (NaN sorts last),
18/// integers and bool use their natural `Ord`.
19pub trait TotalCmp: Copy + Send + Sync {
20    fn total_cmp(&self, other: &Self) -> Ordering;
21}
22
23macro_rules! impl_total_cmp_float {
24    ($t:ty) => {
25        impl TotalCmp for $t {
26            fn total_cmp(&self, other: &Self) -> Ordering { <$t>::total_cmp(self, other) }
27        }
28    };
29}
30macro_rules! impl_total_cmp_ord {
31    ($t:ty) => {
32        impl TotalCmp for $t {
33            fn total_cmp(&self, other: &Self) -> Ordering { self.cmp(other) }
34        }
35    };
36}
37
38impl_total_cmp_float!(f32);
39impl_total_cmp_float!(f64);
40impl_total_cmp_ord!(i8);
41impl_total_cmp_ord!(i16);
42impl_total_cmp_ord!(i32);
43impl_total_cmp_ord!(i64);
44impl_total_cmp_ord!(u8);
45impl_total_cmp_ord!(u16);
46impl_total_cmp_ord!(u32);
47impl_total_cmp_ord!(u64);
48impl_total_cmp_ord!(bool);
49
50/// Parallel-aware sort helper.  `None` → sequential; `Some(pool)` → Rayon pool.
51fn do_argsort<T, D>(data: &D, len: usize, stable: bool, pool: Option<&rayon::ThreadPool>) -> Vec<usize>
52where T: TotalCmp, D: Data<T> + Sync + ?Sized
53{
54    let mut indices: Vec<usize> = (0..len).collect();
55    let cmp = |i: &usize, k: &usize| data.get_at(*i).total_cmp(&data.get_at(*k));
56    match pool {
57        None => {
58            // No specific pool requested — use the global Rayon pool (parallel).
59            if stable { indices.par_sort_by(cmp); } else { indices.par_sort_unstable_by(cmp); }
60        }
61        Some(p) => {
62            if stable { p.install(|| indices.par_sort_by(cmp)); }
63            else      { p.install(|| indices.par_sort_unstable_by(cmp)); }
64        }
65    }
66    indices
67}
68
69fn do_sort<T: TotalCmp>(data: &mut [T], stable: bool, pool: Option<&rayon::ThreadPool>) {
70    let cmp = |a: &T, b: &T| a.total_cmp(b);
71    match pool {
72        None => {
73            // No specific pool requested — use the global Rayon pool (parallel).
74            if stable { data.par_sort_by(cmp); } else { data.par_sort_unstable_by(cmp); }
75        }
76        Some(p) => {
77            if stable { p.install(|| data.par_sort_by(cmp)); }
78            else      { p.install(|| data.par_sort_unstable_by(cmp)); }
79        }
80    }
81}
82
83fn build_pool(num_threads: Option<usize>) -> Option<rayon::ThreadPool> {
84    num_threads.map(|n| ThreadPoolBuilder::new().num_threads(n).build().unwrap())
85}
86
87// Convenience bound for types that can be widened into f64 scores/weights.
88pub trait IntoF64: Into<f64> + Copy + PartialOrd {}
89impl<T: Into<f64> + Copy + PartialOrd> IntoF64 for T {}
90
91#[derive(Clone, Copy)]
92pub enum Order {
93    ASCENDING,
94    DESCENDING
95}
96
97/// Infinite iterator of a constant f64 weight — used for the no-weights path.
98#[derive(Clone, Copy)]
99struct ConstWeight {
100    value: f64,
101}
102
103impl ConstWeight {
104    fn one() -> Self {
105        return ConstWeight { value: 1.0 };
106    }
107}
108
109impl Iterator for ConstWeight {
110    type Item = f64;
111    fn next(&mut self) -> Option<f64> {
112        return Some(self.value);
113    }
114}
115
116impl DoubleEndedIterator for ConstWeight {
117    fn next_back(&mut self) -> Option<f64> {
118        return Some(self.value);
119    }
120}
121
122impl Data<f64> for ConstWeight {
123    fn get_iterator(&self) -> impl DoubleEndedIterator<Item = f64> + Clone {
124        return *self;
125    }
126
127    fn get_at(&self, _index: usize) -> f64 {
128        return self.value;
129    }
130}
131
132pub trait Data<T: Clone> {
133    fn get_iterator(&self) -> impl DoubleEndedIterator<Item = T> + Clone;
134    fn get_at(&self, index: usize) -> T;
135}
136
137/// Marker trait: implementors of `Data<T>` that also support sorting.
138///
139/// The default `argsort_unstable` uses `get_at` for element access so no
140/// copy is needed regardless of memory layout.  Implementors that can provide
141/// a contiguous `&[T]` should override `as_contiguous_slice` to enable a
142/// faster slice-based comparator that LLVM can optimize more aggressively.
143pub trait SortableData<T: TotalCmp + Clone>: Data<T> + Sync {
144    /// Return a contiguous slice if the data is laid out contiguously in
145    /// memory, `None` otherwise.  The default returns `None`.
146    fn as_contiguous_slice(&self) -> Option<&[T]> { None }
147
148    fn argsort_unstable(&self, pool: Option<&rayon::ThreadPool>) -> Vec<usize> {
149        let len = self.get_iterator().count();
150        if let Some(slice) = self.as_contiguous_slice() {
151            do_argsort(slice, slice.len(), false, pool)
152        } else {
153            do_argsort(self, len, false, pool)
154        }
155    }
156}
157
158impl <T: Clone> Data<T> for Vec<T> {
159    fn get_iterator(&self) -> impl DoubleEndedIterator<Item = T> + Clone {
160        return self.iter().cloned();
161    }
162    fn get_at(&self, index: usize) -> T {
163        return self[index].clone();
164    }
165}
166
167impl<F: TotalCmp + num::Float + TotalOrder + Clone + Sync> SortableData<F> for Vec<F> {
168    fn as_contiguous_slice(&self) -> Option<&[F]> { Some(self.as_slice()) }
169}
170
171impl <T: Clone> Data<T> for [T] {
172    fn get_iterator(&self) -> impl DoubleEndedIterator<Item = T> + Clone {
173        return self.iter().cloned();
174    }
175    fn get_at(&self, index: usize) -> T {
176        return self[index].clone();
177    }
178}
179
180impl <T: Clone> Data<T> for &[T] {
181    fn get_iterator(&self) -> impl DoubleEndedIterator<Item = T> + Clone {
182        return self.iter().cloned();
183    }
184    fn get_at(&self, index: usize) -> T {
185        return self[index].clone();
186    }
187}
188
189impl<F: TotalCmp + num::Float + TotalOrder + Clone + Sync> SortableData<F> for &[F] {
190    fn as_contiguous_slice(&self) -> Option<&[F]> { Some(self) }
191}
192
193impl <T: Clone, const N: usize> Data<T> for [T; N] {
194    fn get_iterator(&self) -> impl DoubleEndedIterator<Item = T> + Clone {
195        return self.iter().cloned();
196    }
197    fn get_at(&self, index: usize) -> T {
198        return self[index].clone();
199    }
200}
201
202impl<F: TotalCmp + num::Float + TotalOrder + Clone + Sync, const N: usize> SortableData<F> for [F; N] {
203    fn as_contiguous_slice(&self) -> Option<&[F]> { Some(self.as_slice()) }
204}
205
206impl <T: Clone> Data<T> for ArrayView<'_, T, Ix1> {
207    fn get_iterator(&self) -> impl DoubleEndedIterator<Item = T> + Clone {
208        return self.iter().cloned();
209    }
210    fn get_at(&self, index: usize) -> T {
211        return self[index].clone();
212    }
213}
214
215impl <F> SortableData<F> for ArrayView<'_, F, Ix1>
216where F: TotalCmp + num::Float + TotalOrder + Clone + Sync {
217    fn as_contiguous_slice(&self) -> Option<&[F]> { self.as_slice() }
218}
219
220pub trait BinaryLabel: Clone + Copy {
221    fn get_value(&self) -> bool;
222}
223
224impl BinaryLabel for bool {
225    fn get_value(&self) -> bool {
226        return self.clone();
227    }
228}
229
230impl BinaryLabel for u8 {
231    fn get_value(&self) -> bool {
232        return (self & 1u8) == 1u8;
233    }
234}
235
236impl BinaryLabel for u16 {
237    fn get_value(&self) -> bool {
238        return (self & 1u16) == 1u16;
239    }
240}
241
242impl BinaryLabel for u32 {
243    fn get_value(&self) -> bool {
244        return (self & 1u32) == 1u32;
245    }
246}
247
248impl BinaryLabel for u64 {
249    fn get_value(&self) -> bool {
250        return (self & 1u64) == 1u64;
251    }
252}
253
254impl BinaryLabel for i8 {
255    fn get_value(&self) -> bool {
256        return (self & 1i8) == 1i8;
257    }
258}
259
260impl BinaryLabel for i16 {
261    fn get_value(&self) -> bool {
262        return (self & 1i16) == 1i16;
263    }
264}
265
266impl BinaryLabel for i32 {
267    fn get_value(&self) -> bool {
268        return (self & 1i32) == 1i32;
269    }
270}
271
272impl BinaryLabel for i64 {
273    fn get_value(&self) -> bool {
274        return (self & 1i64) == 1i64;
275    }
276}
277
278fn select<T, I>(slice: &I, indices: &[usize]) -> Vec<T>
279where T: Copy, I: Data<T>
280{
281    let mut selection: Vec<T> = Vec::new();
282    selection.reserve_exact(indices.len());
283    for index in indices {
284        selection.push(slice.get_at(*index));
285    }
286    return selection;
287}
288
289pub trait ScoreSortedDescending {
290    fn _score(&self, labels_with_weights: impl Iterator<Item = (f64, (bool, f64))> + Clone) -> f64;
291    fn score<P, B, W>(&self, labels_with_weights: impl Iterator<Item = (P, (B, W))> + Clone) -> f64
292    where P: IntoF64, B: BinaryLabel, W: IntoF64
293    {
294        return self._score(
295            labels_with_weights.map(|(p, (b, w))| -> (f64, (bool, f64)) { (p.into(), (b.get_value(), w.into()))})
296        )
297    }
298}
299
300
301pub fn score_sorted_iterators<S, P, B, W>(
302    score: S,
303    predictions: impl Iterator<Item = P> + Clone,
304    labels: impl Iterator<Item = B> + Clone,
305    weights: impl Iterator<Item = W> + Clone,
306) -> f64
307where S: ScoreSortedDescending, P: IntoF64, B: BinaryLabel, W: IntoF64 {
308    let zipped = predictions.zip(labels.zip(weights));
309    return score.score(zipped);
310}
311
312
313pub fn score_sorted_sample<S, P, B, W>(
314    score: S,
315    predictions: &impl Data<P>,
316    labels: &impl Data<B>,
317    weights: &impl Data<W>,
318    order: Order,
319) -> f64
320where S: ScoreSortedDescending, P: IntoF64, B: BinaryLabel, W: IntoF64 + Clone {
321    let p = predictions.get_iterator();
322    let l = labels.get_iterator();
323    let w = weights.get_iterator();
324    return match order {
325        Order::ASCENDING => score_sorted_iterators(score, p.rev(), l.rev(), w.rev()),
326        Order::DESCENDING => score_sorted_iterators(score, p, l, w),
327    };
328}
329
330
331pub fn score_maybe_sorted_sample<S, P, B, W>(
332    score: S,
333    predictions: &(impl Data<P> + SortableData<P>),
334    labels: &impl Data<B>,
335    weights: Option<&impl Data<W>>,
336    order: Option<Order>,
337) -> f64
338where S: ScoreSortedDescending, P: IntoF64 + TotalCmp + num::Float + TotalOrder, B: BinaryLabel, W: IntoF64
339{
340    return match order {
341        Some(o) => {
342            match weights {
343                Some(w) => score_sorted_sample(score, predictions, labels, w, o),
344                None => score_sorted_sample(score, predictions, labels, &ConstWeight::one(), o),
345            }
346        }
347        None => {
348            let mut indices = predictions.argsort_unstable(None);
349            indices.reverse(); // score_maybe_sorted_sample needs descending order
350            let sorted_labels = select(labels, &indices);
351            let sorted_predictions = select(predictions, &indices);
352            match weights {
353                Some(w) => {
354                    let sorted_weights = select(w, &indices);
355                    score_sorted_sample(score, &sorted_predictions, &sorted_labels, &sorted_weights, Order::DESCENDING)
356                }
357                None => score_sorted_sample(score, &sorted_predictions, &sorted_labels, &ConstWeight::one(), Order::DESCENDING)
358            }
359        }
360    };
361}
362
363
364pub fn score_two_sorted_samples<S, P, B, W>(
365    score: S,
366    predictions1: impl Iterator<Item = P> + Clone,
367    label1: impl Iterator<Item = B> + Clone,
368    weight1: impl Iterator<Item = W> + Clone,
369    predictions2: impl Iterator<Item = P> + Clone,
370    label2: impl Iterator<Item = B> + Clone,
371    weight2: impl Iterator<Item = W> + Clone,
372) -> f64
373where S: ScoreSortedDescending, P: IntoF64, B: BinaryLabel + PartialOrd, W: IntoF64
374{
375    return score_two_sorted_samples_zipped(
376        score,
377        predictions1.zip(label1.zip(weight1)),
378        predictions2.zip(label2.zip(weight2)),
379    );
380}
381
382
383pub fn score_two_sorted_samples_zipped<S, P, B, W>(
384    score: S,
385    iter1: impl Iterator<Item = (P, (B, W))> + Clone,
386    iter2: impl Iterator<Item = (P, (B, W))> + Clone,
387) -> f64
388where S: ScoreSortedDescending, P: IntoF64, B: BinaryLabel + PartialOrd, W: IntoF64
389{
390    let combined_iter = combine::combine::CombineIterDescending::new(iter1, iter2);
391    return score.score(combined_iter);
392}
393
394
395struct AveragePrecision;
396
397
398#[derive(Clone,Copy,Debug)]
399struct Positives {
400    tps: f64,
401    fps: f64,
402}
403
404impl Positives {
405    fn new(tps: f64, fps: f64) -> Self {
406        return Positives { tps, fps };
407    }
408
409    fn zero() -> Self {
410        return Positives::new(0.0, 0.0);
411    }
412
413    fn add(&mut self, label: bool, weight: f64) {
414        let tp = weight * (label as u8) as f64;
415        let fp = weight - tp;
416        self.tps += tp;
417        self.fps += fp;
418    }
419
420    fn positives_sum(&self) -> f64 {
421        return self.tps + self.fps;
422    }
423
424    fn precision(&self) -> f64 {
425        return self.tps / self.positives_sum();
426    }
427}
428
429
430impl ScoreSortedDescending for AveragePrecision {
431    fn _score(&self, mut labels_with_weights: impl Iterator<Item = (f64, (bool, f64))> + Clone) -> f64
432    {
433        let mut positives = Positives::zero();
434        let mut last_p = f64::NAN;
435        let mut last_tps = 0.0f64;
436        let mut ap = 0.0f64;
437
438        // TODO can we unify this preparation step with the loop?
439        match labels_with_weights.next() {
440            None => (), // TODO: Should we return an error in this case?
441            Some((p, (label, w))) => {
442                positives.add(label, w);
443                last_p = p;
444            }
445        }
446
447        for (p, (label, w)) in labels_with_weights {
448            if last_p != p {
449                ap += (positives.tps - last_tps) * positives.precision();
450                last_p = p;
451                last_tps = positives.tps;
452            }
453            positives.add(label, w);
454        }
455
456        ap += (positives.tps - last_tps) * positives.precision();
457
458        // Special case for tps == 0 following sklearn
459        // https://github.com/scikit-learn/scikit-learn/blob/5cce87176a530d2abea45b5a7e5a4d837c481749/sklearn/metrics/_ranking.py#L1032-L1039
460        // I.e. if tps is 0.0, there are no positive samples in labels: Either all labels are 0, or all weights (for positive labels) are 0
461        return if positives.tps == 0.0 { 0.0 } else { ap / positives.tps };
462    }
463}
464
465
466struct RocAuc;
467
468
469impl ScoreSortedDescending for RocAuc {
470    fn _score(&self, mut labels_with_weights: impl Iterator<Item = (f64, (bool, f64))> + Clone) -> f64
471    {
472        let mut positives = Positives::zero();
473        let mut last_p = f64::NAN;
474        let mut last_counted_fp = 0.0f64;
475        let mut last_counted_tp = 0.0f64;
476        let mut area_under_curve = 0.0f64;
477
478        // TODO can we unify this preparation step with the loop?
479        match labels_with_weights.next() {
480            None => (), // TODO: Should we return an error in this case?
481            Some((p, (label, w))) => {
482                positives.add(label, w);
483                last_p = p;
484            }
485        }
486
487        for (p, (label, w)) in labels_with_weights {
488            if last_p != p {
489                area_under_curve += area_under_line_segment(
490                    last_counted_fp, positives.fps,
491                    last_counted_tp, positives.tps,
492                );
493                last_counted_fp = positives.fps;
494                last_counted_tp = positives.tps;
495                last_p = p;
496            }
497            positives.add(label, w);
498        }
499        area_under_curve += area_under_line_segment(
500            last_counted_fp, positives.fps,
501            last_counted_tp, positives.tps,
502        );
503        return area_under_curve / (positives.tps * positives.fps);
504    }
505}
506
507
508struct RocAucWithMaxFPR {
509    max_fpr: f64,
510}
511
512
513impl RocAucWithMaxFPR {
514    fn new(max_fpr: f64) -> Self {
515        return RocAucWithMaxFPR { max_fpr };
516    }
517
518    fn get_positive_sum(labels_with_weights: impl Iterator<Item = (bool, f64)>) -> Positives {
519        let mut positives = Positives::zero();
520        for (label, weight) in labels_with_weights {
521            positives.add(label, weight);
522        }
523        return positives;
524    }
525}
526
527
528impl ScoreSortedDescending for RocAucWithMaxFPR {
529    fn _score(&self, mut labels_with_weights: impl Iterator<Item = (f64, (bool, f64))> + Clone) -> f64
530    {
531        let total_positives = Self::get_positive_sum(labels_with_weights.clone().map(|(_p, (b, w))| (b, w)));
532        let max_fpr = self.max_fpr;
533        let false_positive_cutoff = max_fpr * total_positives.fps;
534
535        let mut positives = Positives::zero();
536        let mut last_p = f64::NAN;
537        let mut last_counted_fp = 0.0f64;
538        let mut last_counted_tp = 0.0f64;
539        let mut area_under_curve = 0.0f64;
540
541        // TODO can we unify this preparation step with the loop?
542        match labels_with_weights.next() {
543            None => (), // TODO: Should we return an error in this case?
544            Some((p, (label, w))) => {
545                positives.add(label, w);
546                last_p = p;
547            }
548        }
549
550        for (p, (label, w)) in labels_with_weights {
551            if last_p != p {
552                area_under_curve += area_under_line_segment(
553                    last_counted_fp, positives.fps,
554                    last_counted_tp, positives.tps,
555                );
556                last_counted_fp = positives.fps;
557                last_counted_tp = positives.tps;
558                last_p = p;
559            }
560            let mut next_pos = positives;
561            next_pos.add(label, w);
562            if next_pos.fps > false_positive_cutoff {
563                let dx = next_pos.fps - positives.fps;
564                let dy = next_pos.tps - positives.tps;
565                positives = Positives::new(
566                    positives.tps + dy * false_positive_cutoff / dx,
567                    false_positive_cutoff,
568                );
569                break;
570            } else {
571                positives = next_pos;
572            }
573        }
574
575        area_under_curve += area_under_line_segment(
576            last_counted_fp, positives.fps,
577            last_counted_tp, positives.tps,
578        );
579
580        let normalized_area_under_curve = area_under_curve / (total_positives.tps * total_positives.fps);
581        let min_area = 0.5 * max_fpr * max_fpr;
582        let max_area = max_fpr;
583        return 0.5 * (1.0 + (normalized_area_under_curve - min_area) / (max_area - min_area));
584    }
585}
586
587
588struct RocAucWithOptionalMaxFPR {
589    max_fpr: Option<f64>,
590}
591
592impl RocAucWithOptionalMaxFPR {
593    fn new(max_fpr: Option<f64>) -> Self {
594        return Self { max_fpr };
595    }
596}
597
598
599impl ScoreSortedDescending for RocAucWithOptionalMaxFPR {
600    fn _score(&self, labels_with_weights: impl Iterator<Item = (f64, (bool, f64))> + Clone) -> f64
601    {
602        return match self.max_fpr {
603            Some(mfpr) => RocAucWithMaxFPR::new(mfpr).score(labels_with_weights),
604            None => RocAuc.score(labels_with_weights),
605        }
606    }
607}
608
609
610pub fn average_precision<P, B, W>(
611    predictions: &(impl Data<P> + SortableData<P>),
612    labels: &impl Data<B>,
613    weights: Option<&impl Data<W>>,
614    order: Option<Order>,
615) -> f64
616where P: IntoF64 + TotalCmp + num::Float + TotalOrder, B: BinaryLabel, W: IntoF64
617{
618    return score_maybe_sorted_sample(AveragePrecision, predictions, labels, weights, order);
619}
620
621
622pub fn roc_auc<P, B, W>(
623    predictions: &(impl Data<P> + SortableData<P>),
624    labels: &impl Data<B>,
625    weights: Option<&impl Data<W>>,
626    order: Option<Order>,
627    max_fpr: Option<f64>,
628) -> f64
629where P: IntoF64 + TotalCmp + num::Float + TotalOrder, B: BinaryLabel, W: IntoF64
630{
631    return score_maybe_sorted_sample(RocAucWithOptionalMaxFPR::new(max_fpr), predictions, labels, weights, order);
632}
633
634
635fn area_under_line_segment(x0: f64, x1: f64, y0: f64, y1: f64) -> f64 {
636    let dx = x1 - x0;
637    let dy = y1 - y0;
638    return dx * y0 + dy * dx * 0.5;
639}
640
641
642/// Accumulate `row` into `sum` element-wise.
643///
644/// When monomorphized with `Copied<slice::Iter>` LLVM proves unit stride and
645/// emits SIMD.  When called with ndarray's strided `Iter` it stays scalar.
646#[inline]
647fn accum_row<F: num::Float + AddAssign>(row: impl Iterator<Item = F>, sum: &mut [F]) {
648    for (f, s) in row.zip(sum.iter_mut()) {
649        *s += f;
650    }
651}
652
653/// Compute (prod_sum, m_sqs, l_sqs) for one replicate row.
654///
655/// Same vectorization note as `accum_row`.
656#[inline]
657fn score_row<F: num::Float + AddAssign>(
658    row: impl Iterator<Item = F>,
659    sum: impl Iterator<Item = F>,
660    loo_weight_factor: F,
661) -> (F, F, F) {
662    let mut prod_sum = F::zero();
663    let mut m_sqs = F::zero();
664    let mut l_sqs = F::zero();
665    for (f, s) in row.zip(sum) {
666        let m_f = f;
667        let l_f = (s - f) * loo_weight_factor;
668        prod_sum += m_f * l_f;
669        m_sqs += m_f * m_f;
670        l_sqs += l_f * l_f;
671    }
672    (prod_sum, m_sqs, l_sqs)
673}
674
675/// Core two-pass loop shared by the contiguous and strided paths.
676///
677/// `rows` must be `Clone` because the algorithm makes two passes: one to
678/// accumulate `sum`, one to score.  The `Clone` is free — the iterator is
679/// just a pointer and two `usize`s on the stack.
680///
681/// LLVM monomorphizes separately for each `Row` type: when called with
682/// `Chunks`-derived slice iterators it emits SIMD; when called with
683/// ndarray's strided iterators it stays scalar.
684///
685/// Note: `impl Fn(&ArrayView1) -> I` would be cleaner at the call site but
686/// hits a Rust lifetime limitation — `as_slice()` ties its `&[F]` return to
687/// `&self` (the view reference), not to the underlying data lifetime, so the
688/// returned iterator borrows a local and the compiler rejects it.  The
689/// iterator solution avoids this: `chunks()` borrows directly from `mat`
690/// and `into_iter()` transfers the data lifetime from the view.
691#[inline]
692fn loo_cossim_loops<F, Row>(
693    rows: impl Iterator<Item = Row> + Clone,
694    sum: &mut [F],
695    loo_weight_factor: F,
696    num_replicates: usize,
697) -> F
698where
699    F: num::Float + AddAssign,
700    Row: Iterator<Item = F>,
701{
702    for row in rows.clone() {
703        accum_row(row, sum);
704    }
705    let mut result = F::zero();
706    for row in rows {
707        let (prod_sum, m_sqs, l_sqs) = score_row(row, sum.iter().copied(), loo_weight_factor);
708        result += prod_sum / (m_sqs * l_sqs).sqrt();
709    }
710    result / F::from(num_replicates).unwrap()
711}
712
713pub fn loo_cossim<F: num::Float + AddAssign>(mat: &ArrayView2<'_, F>, replicate_sum: &mut ArrayViewMut1<'_, F>) -> F {
714    let num_replicates = mat.shape()[0];
715    let loo_weight_factor = F::from(1).unwrap() / F::from(num_replicates - 1).unwrap();
716    let ncols = mat.shape()[1];
717    // replicate_sum is always created from Array1::zeros() inside Rust, so
718    // it is guaranteed to be contiguous.
719    let sum = replicate_sum.as_slice_mut().unwrap();
720
721    // mat.as_slice() succeeds iff the matrix is fully C-contiguous (strides
722    // == [ncols, 1]).  chunks() then yields &[F] slices that borrow directly
723    // from mat's data so LLVM sees plain pointer arithmetic and emits SIMD.
724    // For non-contiguous input (strided or Fortran-order) outer_iter() +
725    // into_iter() transfer the data lifetime from the view correctly.
726    //
727    // In the primary call path (loo_cossim_many, univariate sampling) each
728    // 2-D slice has strides (ncols, 1) and as_slice() always succeeds.
729    if let Some(mat_slice) = mat.as_slice() {
730        loo_cossim_loops(mat_slice.chunks(ncols).map(|s| s.iter().copied()), sum, loo_weight_factor, num_replicates)
731    } else {
732        loo_cossim_loops(mat.outer_iter().map(|r| r.into_iter().copied()), sum, loo_weight_factor, num_replicates)
733    }
734}
735
736
737pub fn loo_cossim_single<F: num::Float + AddAssign>(mat: &ArrayView2<'_, F>) -> F {
738    let mut replicate_sum = Array1::<F>::zeros(mat.shape()[1]);
739    return loo_cossim(mat, &mut replicate_sum.view_mut());
740}
741
742
743pub fn loo_cossim_many<F: num::Float + AddAssign>(mat: &ArrayView3<'_, F>) -> Array1<F> {
744    let mut cossims = Array1::<F>::zeros(mat.shape()[0]);
745    let mut replicate_sum = Array1::<F>::zeros(mat.shape()[2]);
746    for (m, c) in mat.outer_iter().zip(cossims.iter_mut()) {
747        replicate_sum.fill(F::zero());
748        *c = loo_cossim(&m, &mut replicate_sum.view_mut());
749    }
750    return cossims;
751}
752
753
754// Python bindings
755#[pyclass(eq, eq_int, from_py_object, name="Order")]
756#[derive(Clone, Copy, PartialEq)]
757pub enum PyOrder {
758    ASCENDING,
759    DESCENDING
760}
761
762fn py_order_as_order(order: PyOrder) -> Order {
763    return match order {
764        PyOrder::ASCENDING => Order::ASCENDING,
765        PyOrder::DESCENDING => Order::DESCENDING,
766    }
767}
768
769trait PyScoreGeneric<S: ScoreSortedDescending>: Ungil + Sync {
770
771    fn get_score(&self) -> S;
772
773    fn score_py<'py, P, B, W>(
774        &self,
775        py: Python<'py>,
776        labels: PyReadonlyArray1<'py, B>,
777        predictions: PyReadonlyArray1<'py, P>,
778        weights: Option<PyReadonlyArray1<'py, W>>,
779        order: Option<PyOrder>,
780    ) -> f64
781    where P: IntoF64 + TotalCmp + Element + num::Float + TotalOrder, B: BinaryLabel + Element, W: IntoF64 + Element
782    {
783        let labels = labels.as_array();
784        let predictions = predictions.as_array();
785        let order = order.map(py_order_as_order);
786        return match weights {
787            Some(weight) => {
788                let w = weight.as_array();
789                py.detach(move || {
790                    score_maybe_sorted_sample(self.get_score(), &predictions, &labels, Some(&w), order)
791                })
792            },
793            None => py.detach(move || {
794                score_maybe_sorted_sample(self.get_score(), &predictions, &labels, None::<&Vec<W>>, order)
795            })
796        };
797    }
798
799    fn score_two_sorted_samples_py_generic<'py, B, F, B1, F1, F2, W1, W2>(
800        &self,
801        py: Python<'py>,
802        labels1: PyReadonlyArray1<'py, B1>,
803        predictions1: PyReadonlyArray1<'py, F1>,
804        weights1: Option<PyReadonlyArray1<'py, W1>>,
805        labels2: PyReadonlyArray1<'py, B1>,
806        predictions2: PyReadonlyArray1<'py, F2>,
807        weights2: Option<PyReadonlyArray1<'py, W2>>,
808    ) -> f64
809    where B: BinaryLabel + PartialOrd + Ungil, F: IntoF64 + Ungil,
810          B1: Element + Into<B> + Clone,
811          F1: Element + Into<F> + Clone, F2: Element + Into<F> + Clone,
812          W1: Element + Into<f64> + Clone, W2: Element + Into<f64> + Clone
813    {
814        let l1 = labels1.as_array().into_iter().cloned().map(|l| -> B { l.into() });
815        let l2 = labels2.as_array().into_iter().cloned().map(|l| -> B { l.into() });
816        let p1 = predictions1.as_array().into_iter().cloned().map(|f| -> F { f.into() });
817        let p2 = predictions2.as_array().into_iter().cloned().map(|f| -> F { f.into() });
818
819        return match (weights1, weights2) {
820            (None, None) => {
821                py.detach(move || {
822                    score_two_sorted_samples(self.get_score(), p1, l1, repeat(1.0f64), p2, l2, repeat(1.0f64))
823                })
824            }
825            (Some(w1), None) => {
826                let w1i = w1.as_array().into_iter().cloned().map(|w| -> f64 { w.into() });
827                py.detach(move || {
828                    score_two_sorted_samples(self.get_score(), p1, l1, w1i, p2, l2, repeat(1.0f64))
829                })
830            }
831            (None, Some(w2)) => {
832                let w2i = w2.as_array().into_iter().cloned().map(|w| -> f64 { w.into() });
833                py.detach(move || {
834                    score_two_sorted_samples(self.get_score(), p1, l1, repeat(1.0f64), p2, l2, w2i)
835                })
836            }
837            (Some(w1), Some(w2)) => {
838                let w1i = w1.as_array().into_iter().cloned().map(|w| -> f64 { w.into() });
839                let w2i = w2.as_array().into_iter().cloned().map(|w| -> f64 { w.into() });
840                py.detach(move || {
841                    score_two_sorted_samples(self.get_score(), p1, l1, w1i, p2, l2, w2i)
842                })
843            }
844        };
845    }
846}
847
848struct AveragePrecisionPyGeneric;
849
850impl PyScoreGeneric<AveragePrecision> for AveragePrecisionPyGeneric {
851    fn get_score(&self) -> AveragePrecision {
852        return AveragePrecision;
853    }
854}
855
856struct RocAucPyGeneric {
857    max_fpr: Option<f64>,
858}
859
860impl RocAucPyGeneric {
861    fn new(max_fpr: Option<f64>) -> Self {
862        return RocAucPyGeneric { max_fpr };
863    }
864}
865
866impl PyScoreGeneric<RocAucWithOptionalMaxFPR> for RocAucPyGeneric {
867    fn get_score(&self) -> RocAucWithOptionalMaxFPR {
868        return RocAucWithOptionalMaxFPR::new(self.max_fpr);
869    }
870}
871
872// https://stackoverflow.com/questions/70128978/how-to-define-different-function-names-with-a-macro
873// https://stackoverflow.com/questions/70872059/using-a-rust-macro-to-generate-a-function-with-variable-parameters
874// https://doc.rust-lang.org/rust-by-example/macros/designators.html
875// https://users.rust-lang.org/t/is-there-a-way-to-convert-given-identifier-to-a-string-in-a-macro/42907
876macro_rules! average_precision_py {
877    ($fname: ident, $pyname:literal, $label_type:ty, $prediction_type:ty) => {
878        #[pyfunction(name = $pyname)]
879        #[pyo3(signature = (labels, predictions, *, weights=None, order=None))]
880        pub fn $fname<'py>(
881            py: Python<'py>,
882            labels: PyReadonlyArray1<'py, $label_type>,
883            predictions: PyReadonlyArray1<'py, $prediction_type>,
884            weights: Option<PyReadonlyArray1<'py, $prediction_type>>,
885            order: Option<PyOrder>
886        ) -> f64
887        {
888            return AveragePrecisionPyGeneric.score_py(py, labels, predictions, weights, order);
889        }
890    };
891    ($fname: ident, $pyname:literal, $label_type:ty, $prediction_type:ty, $py_module:ident) => {
892        average_precision_py!($fname, $pyname, $label_type, $prediction_type);
893        $py_module.add_function(wrap_pyfunction!($fname, $py_module)?).unwrap();
894    };
895}
896
897
898macro_rules! roc_auc_py {
899    ($fname: ident, $pyname:literal, $label_type:ty, $prediction_type:ty) => {
900        #[pyfunction(name = $pyname)]
901        #[pyo3(signature = (labels, predictions, *, weights=None, order=None, max_fpr=None))]
902        pub fn $fname<'py>(
903            py: Python<'py>,
904            labels: PyReadonlyArray1<'py, $label_type>,
905            predictions: PyReadonlyArray1<'py, $prediction_type>,
906            weights: Option<PyReadonlyArray1<'py, $prediction_type>>,
907            order: Option<PyOrder>,
908            max_fpr: Option<f64>,
909        ) -> f64
910        {
911            return RocAucPyGeneric::new(max_fpr).score_py(py, labels, predictions, weights, order);
912        }
913    };
914    ($fname: ident, $pyname:literal, $label_type:ty, $prediction_type:ty, $py_module:ident) => {
915        roc_auc_py!($fname, $pyname, $label_type, $prediction_type);
916        $py_module.add_function(wrap_pyfunction!($fname, $py_module)?).unwrap();
917    };
918}
919
920
921macro_rules! average_precision_on_two_sorted_samples_py {
922    ($fname: ident, $pyname:literal, $label_type:ty, $prediction_type:ty) => {
923        #[pyfunction(name = $pyname)]
924        #[pyo3(signature = (labels1, predictions1, weights1, labels2, predictions2, weights2, *))]
925        pub fn $fname<'py>(
926            py: Python<'py>,
927            labels1: PyReadonlyArray1<'py, $label_type>,
928            predictions1: PyReadonlyArray1<'py, $prediction_type>,
929            weights1: Option<PyReadonlyArray1<'py, $prediction_type>>,
930            labels2: PyReadonlyArray1<'py, $label_type>,
931            predictions2: PyReadonlyArray1<'py, $prediction_type>,
932            weights2: Option<PyReadonlyArray1<'py, $prediction_type>>,
933        ) -> f64
934        {
935            return AveragePrecisionPyGeneric.score_two_sorted_samples_py_generic::<$label_type, $prediction_type, $label_type, $prediction_type, $prediction_type, $prediction_type, $prediction_type>(py, labels1, predictions1, weights1, labels2, predictions2, weights2);
936        }
937    };
938    ($fname: ident, $pyname:literal, $label_type:ty, $prediction_type:ty, $py_module:ident) => {
939        average_precision_on_two_sorted_samples_py!($fname, $pyname, $label_type, $prediction_type);
940        $py_module.add_function(wrap_pyfunction!($fname, $py_module)?).unwrap();
941    };
942}
943
944
945macro_rules! roc_auc_on_two_sorted_samples_py {
946    ($fname: ident, $pyname:literal, $label_type:ty, $prediction_type:ty) => {
947        #[pyfunction(name = $pyname)]
948        #[pyo3(signature = (labels1, predictions1, weights1, labels2, predictions2, weights2, *, max_fpr=None))]
949        pub fn $fname<'py>(
950            py: Python<'py>,
951            labels1: PyReadonlyArray1<'py, $label_type>,
952            predictions1: PyReadonlyArray1<'py, $prediction_type>,
953            weights1: Option<PyReadonlyArray1<'py, $prediction_type>>,
954            labels2: PyReadonlyArray1<'py, $label_type>,
955            predictions2: PyReadonlyArray1<'py, $prediction_type>,
956            weights2: Option<PyReadonlyArray1<'py, $prediction_type>>,
957            max_fpr: Option<f64>,
958        ) -> f64
959        {
960            return RocAucPyGeneric::new(max_fpr).score_two_sorted_samples_py_generic::<$label_type, $prediction_type, $label_type, $prediction_type, $prediction_type, $prediction_type, $prediction_type>(py, labels1, predictions1, weights1, labels2, predictions2, weights2);
961        }
962    };
963    ($fname: ident, $pyname:literal, $label_type:ty, $prediction_type:ty, $py_module:ident) => {
964        roc_auc_on_two_sorted_samples_py!($fname, $pyname, $label_type, $prediction_type);
965        $py_module.add_function(wrap_pyfunction!($fname, $py_module)?).unwrap();
966    };
967}
968
969
970#[pyfunction(name = "loo_cossim")]
971#[pyo3(signature = (data))]
972pub fn loo_cossim_py<'py>(
973    py: Python<'py>,
974    data: &Bound<'py, PyUntypedArray>
975) -> PyResult<f64> {
976    if data.ndim() != 2 {
977        return Err(PyTypeError::new_err(format!("Expected 2-dimensional array for data (samples x features) but found {} dimenisons.", data.ndim())));
978    }
979
980    let dt = data.dtype();
981    if dt.is_equiv_to(&dtype::<f32>(py)) {
982        let typed_data = data.cast::<PyArray2<f32>>().unwrap().readonly();
983        let array = typed_data.as_array();
984        let score = py.detach(move || {
985            loo_cossim_single(&array)
986        });
987        return Ok(score as f64);
988    }
989    if dt.is_equiv_to(&dtype::<f64>(py)) {
990        let typed_data = data.cast::<PyArray2<f64>>().unwrap().readonly();
991        let array = typed_data.as_array();
992        let score = py.detach(move || {
993            loo_cossim_single(&array)
994        });
995        return Ok(score);
996    }
997    return Err(PyTypeError::new_err(format!("Only float32 and float64 data supported, but found {}", dt)));
998}
999
1000pub fn loo_cossim_many_generic_py<'py, F: num::Float + AddAssign + Element>(
1001    py: Python<'py>,
1002    data: &Bound<'py, PyArrayDyn<F>>
1003) -> PyResult<Bound<'py, PyArray1<F>>> {
1004    if data.ndim() != 3 {
1005        return Err(PyTypeError::new_err(format!("Expected 3-dimensional array for data (outer(?) x samples x features) but found {} dimenisons.", data.ndim())));
1006    }
1007    let typed_data = data.cast::<PyArray3<F>>().unwrap().readonly();
1008    let array = typed_data.as_array();
1009    let score = py.detach(move || {
1010        loo_cossim_many(&array)
1011    });
1012    // TODO how can we return this generically without making a copy at the end?
1013    let score_py = PyArray::from_owned_array(py, score);
1014    return Ok(score_py);
1015}
1016
1017#[pyfunction(name = "loo_cossim_many_f64")]
1018#[pyo3(signature = (data))]
1019pub fn loo_cossim_many_py_f64<'py>(
1020    py: Python<'py>,
1021    data: &Bound<'py, PyUntypedArray>
1022) -> PyResult<Bound<'py, PyArray1<f64>>> {
1023    if data.ndim() != 3 {
1024        return Err(PyTypeError::new_err(format!("Expected 3-dimensional array for data (outer(?) x samples x features) but found {} dimenisons.", data.ndim())));
1025    }
1026
1027    let dt = data.dtype();
1028    if !dt.is_equiv_to(&dtype::<f64>(py)) {
1029        return Err(PyTypeError::new_err(format!("Only float64 data supported, but found {}", dt)));
1030    }
1031    let typed_data = data.cast::<PyArrayDyn<f64>>().unwrap();
1032    return loo_cossim_many_generic_py(py, typed_data);
1033}
1034
1035#[pyfunction(name = "loo_cossim_many_f32")]
1036#[pyo3(signature = (data))]
1037pub fn loo_cossim_many_py_f32<'py>(
1038    py: Python<'py>,
1039    data: &Bound<'py, PyUntypedArray>
1040) -> PyResult<Bound<'py, PyArray1<f32>>> {
1041    if data.ndim() != 3 {
1042        return Err(PyTypeError::new_err(format!("Expected 3-dimensional array for data (outer(?) x samples x features) but found {} dimenisons.", data.ndim())));
1043    }
1044
1045    let dt = data.dtype();
1046    if !dt.is_equiv_to(&dtype::<f32>(py)) {
1047        return Err(PyTypeError::new_err(format!("Only float32 data supported, but found {}", dt)));
1048    }
1049    let typed_data = data.cast::<PyArrayDyn<f32>>().unwrap();
1050    return loo_cossim_many_generic_py(py, typed_data);
1051}
1052
1053// ── argsort / sort Python API ────────────────────────────────────────────────
1054//
1055// Differences from numpy.argsort / numpy.sort:
1056//   - 1D only (no `axis` parameter)
1057//   - `stable: bool = False` instead of numpy's `kind` string
1058//   - `num_threads: int | None = None`  (None → sequential; n → Rayon pool of n)
1059//   - No `order` parameter (no structured arrays)
1060//   - argsort always returns int64 (numpy returns intp, platform-dependent)
1061//   - NaN sorts to end (consistent with numpy float behaviour via total_cmp)
1062
1063macro_rules! argsort_py {
1064    ($fname:ident, $pyname:literal, $type:ty) => {
1065        #[pyfunction(name = $pyname)]
1066        #[pyo3(signature = (data, *, stable=false, num_threads=None))]
1067        pub fn $fname<'py>(
1068            py: Python<'py>,
1069            data: PyReadonlyArray1<'py, $type>,
1070            stable: bool,
1071            num_threads: Option<usize>,
1072        ) -> PyResult<Bound<'py, PyArray1<i64>>> {
1073            let arr = data.as_array();
1074            let len = arr.len();
1075            let pool = build_pool(num_threads);
1076            let indices = py.detach(|| do_argsort(&arr, len, stable, pool.as_ref()));
1077            let out: Vec<i64> = indices.into_iter().map(|i| i as i64).collect();
1078            Ok(PyArray1::from_vec(py, out))
1079        }
1080    };
1081    ($fname:ident, $pyname:literal, $type:ty, $module:ident) => {
1082        argsort_py!($fname, $pyname, $type);
1083        $module.add_function(wrap_pyfunction!($fname, $module)?).unwrap();
1084    };
1085}
1086
1087macro_rules! sort_inplace_py {
1088    ($fname:ident, $pyname:literal, $type:ty) => {
1089        #[pyfunction(name = $pyname)]
1090        #[pyo3(signature = (data, *, stable=false, num_threads=None))]
1091        pub fn $fname<'py>(
1092            py: Python<'py>,
1093            data: &Bound<'py, PyArray1<$type>>,
1094            stable: bool,
1095            num_threads: Option<usize>,
1096        ) -> PyResult<()> {
1097            let mut arr = unsafe { data.as_array_mut() };
1098            let slice = arr.as_slice_mut()
1099                .ok_or_else(|| PyTypeError::new_err("sort_inplace requires a C-contiguous array"))?;
1100            let pool = build_pool(num_threads);
1101            py.detach(|| do_sort(slice, stable, pool.as_ref()));
1102            Ok(())
1103        }
1104    };
1105    ($fname:ident, $pyname:literal, $type:ty, $module:ident) => {
1106        sort_inplace_py!($fname, $pyname, $type);
1107        $module.add_function(wrap_pyfunction!($fname, $module)?).unwrap();
1108    };
1109}
1110
1111#[pymodule(name = "_scors")]
1112fn scors(m: &Bound<'_, PyModule>) -> PyResult<()> {
1113    average_precision_py!(average_precision_bool_f32, "average_precision_bool_f32", bool, f32, m);
1114    average_precision_py!(average_precision_i8_f32, "average_precision_i8_f32", i8, f32, m);
1115    average_precision_py!(average_precision_i16_f32, "average_precision_i16_f32", i16, f32, m);
1116    average_precision_py!(average_precision_i32_f32, "average_precision_i32_f32", i32, f32, m);
1117    average_precision_py!(average_precision_i64_f32, "average_precision_i64_f32", i64, f32, m);
1118    average_precision_py!(average_precision_u8_f32, "average_precision_u8_f32", u8, f32, m);
1119    average_precision_py!(average_precision_u16_f32, "average_precision_u16_f32", u16, f32, m);
1120    average_precision_py!(average_precision_u32_f32, "average_precision_u32_f32", u32, f32, m);
1121    average_precision_py!(average_precision_u64_f32, "average_precision_u64_f32", u64, f32, m);
1122    average_precision_py!(average_precision_bool_f64, "average_precision_bool_f64", bool, f64, m);
1123    average_precision_py!(average_precision_i8_f64, "average_precision_i8_f64", i8, f64, m);
1124    average_precision_py!(average_precision_i16_f64, "average_precision_i16_f64", i16, f64, m);
1125    average_precision_py!(average_precision_i32_f64, "average_precision_i32_f64", i32, f64, m);
1126    average_precision_py!(average_precision_i64_f64, "average_precision_i64_f64", i64, f64, m);
1127    average_precision_py!(average_precision_u8_f64, "average_precision_u8_f64", u8, f64, m);
1128    average_precision_py!(average_precision_u16_f64, "average_precision_u16_f64", u16, f64, m);
1129    average_precision_py!(average_precision_u32_f64, "average_precision_u32_f64", u32, f64, m);
1130    average_precision_py!(average_precision_u64_f64, "average_precision_u64_f64", u64, f64, m);
1131
1132    roc_auc_py!(roc_auc_bool_f32, "roc_auc_bool_f32", bool, f32, m);
1133    roc_auc_py!(roc_auc_i8_f32, "roc_auc_i8_f32", i8, f32, m);
1134    roc_auc_py!(roc_auc_i16_f32, "roc_auc_i16_f32", i16, f32, m);
1135    roc_auc_py!(roc_auc_i32_f32, "roc_auc_i32_f32", i32, f32, m);
1136    roc_auc_py!(roc_auc_i64_f32, "roc_auc_i64_f32", i64, f32, m);
1137    roc_auc_py!(roc_auc_u8_f32, "roc_auc_u8_f32", u8, f32, m);
1138    roc_auc_py!(roc_auc_u16_f32, "roc_auc_u16_f32", u16, f32, m);
1139    roc_auc_py!(roc_auc_u32_f32, "roc_auc_u32_f32", u32, f32, m);
1140    roc_auc_py!(roc_auc_u64_f32, "roc_auc_u64_f32", u64, f32, m);
1141    roc_auc_py!(roc_auc_bool_f64, "roc_auc_bool_f64", bool, f64, m);
1142    roc_auc_py!(roc_auc_i8_f64, "roc_auc_i8_f64", i8, f64, m);
1143    roc_auc_py!(roc_auc_i16_f64, "roc_auc_i16_f64", i16, f64, m);
1144    roc_auc_py!(roc_auc_i32_f64, "roc_auc_i32_f64", i32, f64, m);
1145    roc_auc_py!(roc_auc_i64_f64, "roc_auc_i64_f64", i64, f64, m);
1146    roc_auc_py!(roc_auc_u8_f64, "roc_auc_u8_f64", u8, f64, m);
1147    roc_auc_py!(roc_auc_u16_f64, "roc_auc_u16_f64", u16, f64, m);
1148    roc_auc_py!(roc_auc_u32_f64, "roc_auc_u32_f64", u32, f64, m);
1149    roc_auc_py!(roc_auc_u64_f64, "roc_auc_u64_f64", u64, f64, m);
1150
1151    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_bool_f32, "average_precision_on_two_sorted_samples_bool_f32", bool, f32, m);
1152    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_i8_f32, "average_precision_on_two_sorted_samples_i8_f32", i8, f32, m);
1153    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_i16_f32, "average_precision_on_two_sorted_samples_i16_f32", i16, f32, m);
1154    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_i32_f32, "average_precision_on_two_sorted_samples_i32_f32", i32, f32, m);
1155    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_i64_f32, "average_precision_on_two_sorted_samples_i64_f32", i64, f32, m);
1156    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_u8_f32, "average_precision_on_two_sorted_samples_u8_f32", u8, f32, m);
1157    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_u16_f32, "average_precision_on_two_sorted_samples_u16_f32", u16, f32, m);
1158    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_u32_f32, "average_precision_on_two_sorted_samples_u32_f32", u32, f32, m);
1159    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_u64_f32, "average_precision_on_two_sorted_samples_u64_f32", u64, f32, m);
1160    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_bool_f64, "average_precision_on_two_sorted_samples_bool_f64", bool, f64, m);
1161    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_i8_f64, "average_precision_on_two_sorted_samples_i8_f64", i8, f64, m);
1162    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_i16_f64, "average_precision_on_two_sorted_samples_i16_f64", i16, f64, m);
1163    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_i32_f64, "average_precision_on_two_sorted_samples_i32_f64", i32, f64, m);
1164    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_i64_f64, "average_precision_on_two_sorted_samples_i64_f64", i64, f64, m);
1165    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_u8_f64, "average_precision_on_two_sorted_samples_u8_f64", u8, f64, m);
1166    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_u16_f64, "average_precision_on_two_sorted_samples_u16_f64", u16, f64, m);
1167    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_u32_f64, "average_precision_on_two_sorted_samples_u32_f64", u32, f64, m);
1168    average_precision_on_two_sorted_samples_py!(average_precision_on_two_sorted_samples_u64_f64, "average_precision_on_two_sorted_samples_u64_f64", u64, f64, m);
1169
1170    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_bool_f32, "roc_auc_on_two_sorted_samples_bool_f32", bool, f32, m);
1171    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_i8_f32, "roc_auc_on_two_sorted_samples_i8_f32", i8, f32, m);
1172    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_i16_f32, "roc_auc_on_two_sorted_samples_i16_f32", i16, f32, m);
1173    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_i32_f32, "roc_auc_on_two_sorted_samples_i32_f32", i32, f32, m);
1174    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_i64_f32, "roc_auc_on_two_sorted_samples_i64_f32", i64, f32, m);
1175    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_u8_f32, "roc_auc_on_two_sorted_samples_u8_f32", u8, f32, m);
1176    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_u16_f32, "roc_auc_on_two_sorted_samples_u16_f32", u16, f32, m);
1177    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_u32_f32, "roc_auc_on_two_sorted_samples_u32_f32", u32, f32, m);
1178    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_u64_f32, "roc_auc_on_two_sorted_samples_u64_f32", u64, f32, m);
1179    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_bool_f64, "roc_auc_on_two_sorted_samples_bool_f64", bool, f64, m);
1180    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_i8_f64, "roc_auc_on_two_sorted_samples_i8_f64", i8, f64, m);
1181    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_i16_f64, "roc_auc_on_two_sorted_samples_i16_f64", i16, f64, m);
1182    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_i32_f64, "roc_auc_on_two_sorted_samples_i32_f64", i32, f64, m);
1183    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_i64_f64, "roc_auc_on_two_sorted_samples_i64_f64", i64, f64, m);
1184    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_u8_f64, "roc_auc_on_two_sorted_samples_u8_f64", u8, f64, m);
1185    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_u16_f64, "roc_auc_on_two_sorted_samples_u16_f64", u16, f64, m);
1186    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_u32_f64, "roc_auc_on_two_sorted_samples_u32_f64", u32, f64, m);
1187    roc_auc_on_two_sorted_samples_py!(roc_auc_on_two_sorted_samples_u64_f64, "roc_auc_on_two_sorted_samples_u64_f64", u64, f64, m);
1188
1189    m.add_function(wrap_pyfunction!(loo_cossim_py, m)?).unwrap();
1190    m.add_function(wrap_pyfunction!(loo_cossim_many_py_f64, m)?).unwrap();
1191    m.add_function(wrap_pyfunction!(loo_cossim_many_py_f32, m)?).unwrap();
1192    m.add_class::<PyOrder>().unwrap();
1193
1194    argsort_py!(argsort_f32,  "argsort_f32",  f32,  m);
1195    argsort_py!(argsort_f64,  "argsort_f64",  f64,  m);
1196    argsort_py!(argsort_i8,   "argsort_i8",   i8,   m);
1197    argsort_py!(argsort_i16,  "argsort_i16",  i16,  m);
1198    argsort_py!(argsort_i32,  "argsort_i32",  i32,  m);
1199    argsort_py!(argsort_i64,  "argsort_i64",  i64,  m);
1200    argsort_py!(argsort_u8,   "argsort_u8",   u8,   m);
1201    argsort_py!(argsort_u16,  "argsort_u16",  u16,  m);
1202    argsort_py!(argsort_u32,  "argsort_u32",  u32,  m);
1203    argsort_py!(argsort_u64,  "argsort_u64",  u64,  m);
1204    argsort_py!(argsort_bool, "argsort_bool", bool, m);
1205
1206    sort_inplace_py!(sort_inplace_f32,  "sort_inplace_f32",  f32,  m);
1207    sort_inplace_py!(sort_inplace_f64,  "sort_inplace_f64",  f64,  m);
1208    sort_inplace_py!(sort_inplace_i8,   "sort_inplace_i8",   i8,   m);
1209    sort_inplace_py!(sort_inplace_i16,  "sort_inplace_i16",  i16,  m);
1210    sort_inplace_py!(sort_inplace_i32,  "sort_inplace_i32",  i32,  m);
1211    sort_inplace_py!(sort_inplace_i64,  "sort_inplace_i64",  i64,  m);
1212    sort_inplace_py!(sort_inplace_u8,   "sort_inplace_u8",   u8,   m);
1213    sort_inplace_py!(sort_inplace_u16,  "sort_inplace_u16",  u16,  m);
1214    sort_inplace_py!(sort_inplace_u32,  "sort_inplace_u32",  u32,  m);
1215    sort_inplace_py!(sort_inplace_u64,  "sort_inplace_u64",  u64,  m);
1216    sort_inplace_py!(sort_inplace_bool, "sort_inplace_bool", bool, m);
1217
1218    return Ok(());
1219}
1220
1221
1222#[cfg(test)]
1223mod tests {
1224    use super::*;
1225
1226    #[test]
1227    fn test_average_precision_on_sorted() {
1228        let labels: [u8; 4] = [1, 0, 1, 0];
1229        let predictions: [f64; 4] = [0.8, 0.4, 0.35, 0.1];
1230        let weights: [f64; 4] = [1.0, 1.0, 1.0, 1.0];
1231        let actual: f64 = score_sorted_sample(AveragePrecision, &predictions, &labels, &weights, Order::DESCENDING);
1232        assert_eq!(actual, 0.8333333333333333);
1233    }
1234
1235    #[test]
1236    fn test_average_precision_on_sorted_double() {
1237        let labels: [u8; 8] = [1, 1, 0, 0, 1, 1, 0, 0];
1238        let predictions: [f64; 8] = [0.8, 0.8, 0.4, 0.4, 0.35, 0.35, 0.1, 0.1];
1239        let weights: [f64; 8] = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
1240        let actual: f64 = score_sorted_sample(AveragePrecision, &predictions, &labels, &weights, Order::DESCENDING);
1241        assert_eq!(actual, 0.8333333333333333);
1242    }
1243
1244    #[test]
1245    fn test_average_precision_unsorted() {
1246        let labels: [u8; 4] = [0, 0, 1, 1];
1247        let predictions: [f64; 4] = [0.1, 0.4, 0.35, 0.8];
1248        let weights: [f64; 4] = [1.0, 1.0, 1.0, 1.0];
1249        let actual: f64 = average_precision(&predictions, &labels, Some(&weights), None);
1250        assert_eq!(actual, 0.8333333333333333);
1251    }
1252
1253    #[test]
1254    fn test_average_precision_sorted() {
1255        let labels: [u8; 4] = [1, 0, 1, 0];
1256        let predictions: [f64; 4] = [0.8, 0.4, 0.35, 0.1];
1257        let weights: [f64; 4] = [1.0, 1.0, 1.0, 1.0];
1258        let actual: f64 = average_precision(&predictions, &labels, Some(&weights), Some(Order::DESCENDING));
1259        assert_eq!(actual, 0.8333333333333333);
1260    }
1261
1262    #[test]
1263    fn test_average_precision_sorted_pair() {
1264        let labels: [u8; 4] = [1, 0, 1, 0];
1265        let predictions: [f64; 4] = [0.8, 0.4, 0.35, 0.1];
1266        let weights: [f64; 4] = [1.0, 1.0, 1.0, 1.0];
1267        let actual: f64 = score_two_sorted_samples(
1268            AveragePrecision,
1269            predictions.iter().cloned(),
1270            labels.iter().cloned(),
1271            weights.iter().cloned(),
1272            predictions.iter().cloned(),
1273            labels.iter().cloned(),
1274            weights.iter().cloned()
1275        );
1276        assert_eq!(actual, 0.8333333333333333);
1277    }
1278
1279    #[test]
1280    fn test_roc_auc() {
1281        let labels: [u8; 4] = [1, 0, 1, 0];
1282        let predictions: [f64; 4] = [0.8, 0.4, 0.35, 0.1];
1283        let weights: [f64; 4] = [1.0, 1.0, 1.0, 1.0];
1284        let actual: f64 = roc_auc(&predictions, &labels, Some(&weights), Some(Order::DESCENDING), None);
1285        assert_eq!(actual, 0.75);
1286    }
1287
1288    #[test]
1289    fn test_roc_auc_double() {
1290        let labels: [u8; 8] = [1, 0, 1, 0, 1, 0, 1, 0];
1291        let predictions: [f64; 8] = [0.8, 0.4, 0.35, 0.1, 0.8, 0.4, 0.35, 0.1];
1292        let actual: f64 = roc_auc(&predictions, &labels, None::<&[f64; 8]>, None, None);
1293        assert_eq!(actual, 0.75);
1294    }
1295
1296    #[test]
1297    fn test_roc_sorted_pair() {
1298        let labels: [u8; 4] = [1, 0, 1, 0];
1299        let predictions: [f64; 4] = [0.8, 0.4, 0.35, 0.1];
1300        let weights: [f64; 4] = [1.0, 1.0, 1.0, 1.0];
1301        let actual: f64 = score_two_sorted_samples(
1302            RocAuc,
1303            predictions.iter().cloned(),
1304            labels.iter().cloned(),
1305            weights.iter().cloned(),
1306            predictions.iter().cloned(),
1307            labels.iter().cloned(),
1308            weights.iter().cloned()
1309        );
1310        assert_eq!(actual, 0.75);
1311    }
1312
1313    #[test]
1314    fn test_roc_auc_max_fpr() {
1315        let labels: [u8; 4] = [1, 0, 1, 0];
1316        let predictions: [f64; 4] = [0.8, 0.4, 0.35, 0.1];
1317        let weights: [f64; 4] = [1.0, 1.0, 1.0, 1.0];
1318        let actual: f64 = roc_auc(&predictions, &labels, Some(&weights), Some(Order::DESCENDING), Some(0.25));
1319        assert_eq!(actual, 0.7142857142857143);
1320    }
1321
1322    #[test]
1323    fn test_roc_auc_max_fpr_double() {
1324        let labels: [u8; 8] = [1, 0, 1, 0, 1, 0, 1, 0];
1325        let predictions: [f64; 8] = [0.8, 0.4, 0.35, 0.1, 0.8, 0.4, 0.35, 0.1];
1326        let actual: f64 = roc_auc(&predictions, &labels, None::<&[f64; 8]>, None, Some(0.25));
1327        assert_eq!(actual, 0.7142857142857143);
1328    }
1329
1330    #[test]
1331    fn test_roc_auc_max_fpr_sorted_pair() {
1332        let labels: [u8; 4] = [1, 0, 1, 0];
1333        let predictions: [f64; 4] = [0.8, 0.4, 0.35, 0.1];
1334        let weights: [f64; 4] = [1.0, 1.0, 1.0, 1.0];
1335        let actual: f64 = score_two_sorted_samples(
1336            RocAucWithMaxFPR::new(0.25f64),
1337            predictions.iter().cloned(),
1338            labels.iter().cloned(),
1339            weights.iter().cloned(),
1340            predictions.iter().cloned(),
1341            labels.iter().cloned(),
1342            weights.iter().cloned()
1343        );
1344        assert_eq!(actual, 0.7142857142857143);
1345    }
1346}