use serde::{Deserialize, Serialize};
use crate::core::SortingOutput;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PairwiseTrainMatch {
pub num_spikes_a: usize,
pub num_spikes_b: usize,
pub num_matches: usize,
pub false_negatives: usize,
pub false_positives: usize,
pub agreement_score: f32,
pub precision: f32,
pub recall: f32,
pub accuracy: f32,
pub false_negative_rate: f32,
pub false_positive_rate: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct UnitMatchSummary {
pub unit_id_a: usize,
pub unit_id_b: usize,
pub metrics: PairwiseTrainMatch,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SortingComparison {
pub sorter_a: String,
pub sorter_b: String,
pub unit_ids_a: Vec<usize>,
pub unit_ids_b: Vec<usize>,
pub delta_time_ms: f64,
pub tolerance_samples: u64,
pub agreement_matrix: Vec<f32>,
pub matched_units: Vec<UnitMatchSummary>,
pub well_detected_units_a: Vec<usize>,
pub missed_units_a: Vec<usize>,
pub false_positive_units_b: Vec<usize>,
pub mean_matched_agreement: f32,
}
pub fn compare_spike_trains(
train_a: &[u64],
train_b: &[u64],
tolerance_samples: u64,
) -> PairwiseTrainMatch {
let mut a = train_a.to_vec();
let mut b = train_b.to_vec();
if !a.windows(2).all(|w| w[0] <= w[1]) {
a.sort_unstable();
}
if !b.windows(2).all(|w| w[0] <= w[1]) {
b.sort_unstable();
}
let n_a = a.len();
let n_b = b.len();
if n_a == 0 && n_b == 0 {
return PairwiseTrainMatch {
num_spikes_a: 0,
num_spikes_b: 0,
num_matches: 0,
false_negatives: 0,
false_positives: 0,
agreement_score: f32::NAN,
precision: f32::NAN,
recall: f32::NAN,
accuracy: f32::NAN,
false_negative_rate: f32::NAN,
false_positive_rate: f32::NAN,
};
}
let mut i = 0usize;
let mut j = 0usize;
let mut matches = 0usize;
while i < n_a && j < n_b {
let ta = a[i];
let tb = b[j];
let diff = ta.abs_diff(tb);
if diff <= tolerance_samples {
matches += 1;
i += 1;
j += 1;
} else if ta < tb {
i += 1;
} else {
j += 1;
}
}
let fn_count = n_a.saturating_sub(matches);
let fp_count = n_b.saturating_sub(matches);
let union = (n_a + n_b).saturating_sub(matches);
let agreement = if union > 0 {
(matches as f32) / (union as f32)
} else {
0.0
};
let precision = if n_b > 0 {
(matches as f32) / (n_b as f32)
} else {
0.0
};
let recall = if n_a > 0 {
(matches as f32) / (n_a as f32)
} else {
0.0
};
PairwiseTrainMatch {
num_spikes_a: n_a,
num_spikes_b: n_b,
num_matches: matches,
false_negatives: fn_count,
false_positives: fp_count,
agreement_score: agreement,
precision,
recall,
accuracy: agreement,
false_negative_rate: if n_a > 0 { 1.0 - recall } else { 0.0 },
false_positive_rate: if n_b > 0 { 1.0 - precision } else { 0.0 },
}
}
pub fn compare_sortings(
sorting_a: &SortingOutput,
sorting_b: &SortingOutput,
delta_time_ms: f64,
agreement_threshold: f32,
) -> SortingComparison {
let fs = sorting_a.sample_rate_hz.max(sorting_b.sample_rate_hz).max(1.0);
let tolerance_samples = ((delta_time_ms.max(0.0) * 1e-3 * fs).round() as u64).max(1);
let k_a = sorting_a.units.len();
let k_b = sorting_b.units.len();
let unit_ids_a: Vec<usize> = sorting_a.units.iter().map(|u| u.unit_id).collect();
let unit_ids_b: Vec<usize> = sorting_b.units.iter().map(|u| u.unit_id).collect();
let mut agreement_matrix = vec![0.0f32; k_a * k_b];
let mut pair_metrics: Vec<(usize, usize, PairwiseTrainMatch)> = Vec::with_capacity(k_a * k_b);
for (ia, ua) in sorting_a.units.iter().enumerate() {
for (ib, ub) in sorting_b.units.iter().enumerate() {
let m = compare_spike_trains(&ua.spike_samples, &ub.spike_samples, tolerance_samples);
agreement_matrix[ia * k_b + ib] = m.agreement_score;
pair_metrics.push((ia, ib, m));
}
}
pair_metrics.sort_by(|x, y| {
y.2.agreement_score
.partial_cmp(&x.2.agreement_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut used_a = vec![false; k_a];
let mut used_b = vec![false; k_b];
let mut matched_units = Vec::new();
for (ia, ib, m) in pair_metrics {
if m.agreement_score <= 0.0 {
break;
}
if !used_a[ia] && !used_b[ib] {
used_a[ia] = true;
used_b[ib] = true;
matched_units.push(UnitMatchSummary {
unit_id_a: unit_ids_a[ia],
unit_id_b: unit_ids_b[ib],
metrics: m,
});
}
}
let mut well_detected_units_a = Vec::new();
let mut missed_units_a = Vec::new();
for (ia, &uid_a) in unit_ids_a.iter().enumerate() {
let best_score = (0..k_b)
.map(|ib| agreement_matrix[ia * k_b + ib])
.fold(0.0f32, f32::max);
if best_score >= agreement_threshold {
well_detected_units_a.push(uid_a);
} else {
missed_units_a.push(uid_a);
}
}
let mut false_positive_units_b = Vec::new();
for (ib, &uid_b) in unit_ids_b.iter().enumerate() {
let best_score = (0..k_a)
.map(|ia| agreement_matrix[ia * k_b + ib])
.fold(0.0f32, f32::max);
if best_score < agreement_threshold {
false_positive_units_b.push(uid_b);
}
}
let mean_matched_agreement = if matched_units.is_empty() {
0.0
} else {
matched_units
.iter()
.map(|u| u.metrics.agreement_score)
.sum::<f32>()
/ (matched_units.len() as f32)
};
SortingComparison {
sorter_a: sorting_a.sorter_name.clone(),
sorter_b: sorting_b.sorter_name.clone(),
unit_ids_a,
unit_ids_b,
delta_time_ms,
tolerance_samples,
agreement_matrix,
matched_units,
well_detected_units_a,
missed_units_a,
false_positive_units_b,
mean_matched_agreement,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::SortedUnit;
#[test]
fn test_compare_spike_trains_and_sortings() {
let fs = 30_000.0;
let total_samples = 300_000;
let gt_u0: Vec<u64> = (1..=100).map(|i| i * 2000).collect();
let gt_u1: Vec<u64> = (1..=80).map(|i| i * 2500 + 300).collect();
let b_u10: Vec<u64> = gt_u0.iter().map(|&s| s + 3).collect();
let b_u20: Vec<u64> = gt_u1[..76].iter().map(|&s| s - 2).collect();
let b_u99: Vec<u64> = vec![500, 15_000, 45_000, 95_000];
let sorting_gt = SortingOutput::new(
"ground_truth",
fs,
total_samples,
None,
vec![
SortedUnit::from_spikes(0, 0, gt_u0, Vec::new(), Vec::new(), None, fs, total_samples, 5.0),
SortedUnit::from_spikes(1, 1, gt_u1, Vec::new(), Vec::new(), None, fs, total_samples, 5.0),
],
None,
);
let sorting_test = SortingOutput::new(
"test_sorter",
fs,
total_samples,
None,
vec![
SortedUnit::from_spikes(10, 0, b_u10, Vec::new(), Vec::new(), None, fs, total_samples, 5.0),
SortedUnit::from_spikes(20, 1, b_u20, Vec::new(), Vec::new(), None, fs, total_samples, 5.0),
SortedUnit::from_spikes(99, 2, b_u99, Vec::new(), Vec::new(), None, fs, total_samples, 5.0),
],
None,
);
let cmp = compare_sortings(&sorting_gt, &sorting_test, 0.4, 0.8);
assert_eq!(cmp.matched_units.len(), 2);
assert_eq!(cmp.matched_units[0].unit_id_a, 0);
assert_eq!(cmp.matched_units[0].unit_id_b, 10);
assert!((cmp.matched_units[0].metrics.agreement_score - 1.0).abs() < 1e-6);
assert_eq!(cmp.matched_units[1].unit_id_a, 1);
assert_eq!(cmp.matched_units[1].unit_id_b, 20);
assert!((cmp.matched_units[1].metrics.recall - 76.0 / 80.0).abs() < 1e-5);
assert!((cmp.matched_units[1].metrics.precision - 1.0).abs() < 1e-5);
assert_eq!(cmp.well_detected_units_a, vec![0, 1]);
assert!(cmp.missed_units_a.is_empty());
assert_eq!(cmp.false_positive_units_b, vec![99]);
}
}