//! Does a detector's confidence predict whether it was right? //! //! `detect_bpm_key` returns a `bpm_confidence` and a `key_confidence` beside //! every answer, and nothing has ever checked that they mean anything. Both are //! read as facts downstream, and a confidence that does not rank correct calls //! above wrong ones is worse than no confidence at all: it is a number the UI //! shows and a gate could be built on, carrying no information. //! //! Two different questions, answered by two different shapes here. //! //! [`discrimination`] and [`bands`] ask whether the number ranks at all, over //! calls that have a right answer to be measured against. Discrimination is one //! scalar and bands are where it came from, because a scalar near 0.5 does not //! say whether the confidence is flat or non-monotone. //! //! [`gate_sweep`] asks the design question the spurious-key problem needs: the //! detector emits a key on 90.8% of loops annotators call keyless, so a gate //! that only lets a key through above some confidence has to be priced. Its //! population is not the scorable loops -- it is every loop, because what a //! gate costs is measured on the keyed ones and what it buys is measured on the //! keyless ones. //! //! Thresholds and band edges are taken from the observed quantiles rather than //! from a fixed grid. A detector whose confidences all sit between 0.62 and //! 0.71 would give an equal-width sweep one populated row and nine empty ones, //! and that is a plausible thing for these confidences to do. /// Whether two confidences are the same number. /// /// Exact equality, and deliberately so: a tie here means the detector returned /// the identical value twice, not two values close enough to round together. /// Ties drive midranks and band boundaries, and a tolerance would merge /// distinct confidences into one block and change both. #[expect( clippy::float_cmp, reason = "a tie is bit-equality; a tolerance would merge distinct confidences" )] fn tied(a: f64, b: f64) -> bool { a == b } /// One detection with a right answer to be scored against. #[derive(Clone, Copy)] pub(crate) struct Call { pub(crate) conf: f64, pub(crate) correct: bool, } /// Probability that a randomly chosen correct call carries a higher confidence /// than a randomly chosen wrong one, ties counting half. `None` when everything /// is right or everything is wrong, which is no evidence either way rather than /// a score of any particular value. /// /// 0.5 is a confidence that ranks no better than a coin. This is AUROC, by way /// of the Mann-Whitney U it equals; computed off the ranks rather than by /// counting pairs so 2,755 loops does not become 3.8M comparisons. pub(crate) fn discrimination(calls: &[Call]) -> Option { let correct = calls.iter().filter(|c| c.correct).count(); let wrong = calls.len() - correct; if correct == 0 || wrong == 0 { return None; } let mut sorted: Vec = calls.to_vec(); sorted.sort_by(|a, b| a.conf.total_cmp(&b.conf)); // Midranks, so a block of equal confidences contributes the half-credit // ties are supposed to get instead of whatever order the sort left them in. let mut rank_sum = 0.0; let mut i = 0; while i < sorted.len() { let mut j = i; while j + 1 < sorted.len() && tied(sorted[j + 1].conf, sorted[i].conf) { j += 1; } // Ranks are 1-based over i..=j, so their mean is this. let midrank = (i + j) as f64 / 2.0 + 1.0; rank_sum += sorted[i..=j].iter().filter(|c| c.correct).count() as f64 * midrank; i = j + 1; } let (n_c, n_w) = (correct as f64, wrong as f64); Some((rank_sum - n_c * (n_c + 1.0) / 2.0) / (n_c * n_w)) } /// One confidence band and how often the detector was right inside it. pub(crate) struct Band { pub(crate) low: f64, pub(crate) high: f64, pub(crate) n: usize, pub(crate) correct: usize, } impl Band { pub(crate) fn accuracy(&self) -> f64 { self.correct as f64 / self.n as f64 } } /// Split `calls` into up to `groups` bands of roughly equal population and /// report accuracy in each. Calibration is the claim that accuracy rises with /// the band, so this is the table that claim is read off. /// /// Equal population rather than equal width: a band nobody landed in reports a /// ratio over zero, and enough of those turn the table into a row of blanks /// with the whole detector inside one cell. pub(crate) fn bands(calls: &[Call], groups: usize) -> Vec { if calls.is_empty() || groups == 0 { return Vec::new(); } let mut sorted: Vec = calls.to_vec(); sorted.sort_by(|a, b| a.conf.total_cmp(&b.conf)); let mut out = Vec::new(); let mut start = 0; for g in 0..groups { if start >= sorted.len() { break; } let mut end = (sorted.len() * (g + 1)) / groups; // A tie must not straddle a boundary, or two bands report overlapping // confidence ranges and neither one means anything. while end < sorted.len() && end > start && tied(sorted[end - 1].conf, sorted[end].conf) { end += 1; } if end <= start { continue; } let slice = &sorted[start..end]; out.push(Band { low: slice[0].conf, high: slice[slice.len() - 1].conf, n: slice.len(), correct: slice.iter().filter(|c| c.correct).count(), }); start = end; } out } /// One loop's evidence for the key gate. #[derive(Clone, Copy)] pub(crate) struct KeyCall { /// The confidence the detector attached to the key it emitted. pub(crate) conf: f64, /// Whether the annotators say this loop has a key at all. pub(crate) has_truth_key: bool, /// Whether the emitted key was exactly right. Only meaningful when /// `has_truth_key`; a keyless loop has no key to match. pub(crate) exact: bool, } /// What a gate at one threshold would deliver. pub(crate) struct GatePoint { pub(crate) threshold: f64, /// Keys kept on loops annotators say are keyed. pub(crate) kept_true: usize, /// Of those, how many were exactly right. pub(crate) kept_exact: usize, /// Keys kept on loops annotators say are keyless. Every one is spurious. pub(crate) kept_spurious: usize, } impl GatePoint { /// Of the keyed loops that had a key emitted, the share this gate still /// lets through. What the gate costs. pub(crate) fn retention(&self, total_true: usize) -> Option { (total_true > 0).then(|| self.kept_true as f64 / total_true as f64) } /// The share of spurious keys this gate removes. What the gate buys. pub(crate) fn suppression(&self, total_spurious: usize) -> Option { (total_spurious > 0).then(|| 1.0 - self.kept_spurious as f64 / total_spurious as f64) } /// Of every key that survives the gate, the share that is exactly right. /// A spurious key counts against this, which is the point: it is what a /// user reading a key field would experience. pub(crate) fn precision(&self) -> Option { let kept = self.kept_true + self.kept_spurious; (kept > 0).then(|| self.kept_exact as f64 / kept as f64) } } /// Price a gate at each of `steps` quantiles of the observed confidences. /// /// `calls` is every loop the detector emitted a key for, keyed and keyless /// alike. A loop with no emitted key is not a gate decision and must be left /// out, or the sweep reports the gate suppressing keys it was never offered. pub(crate) fn gate_sweep(calls: &[KeyCall], steps: usize) -> Vec { if calls.is_empty() || steps == 0 { return Vec::new(); } let mut confs: Vec = calls.iter().map(|c| c.conf).collect(); confs.sort_by(f64::total_cmp); let mut thresholds: Vec = (0..steps) .map(|s| confs[(confs.len() - 1) * s / steps]) .collect(); thresholds.dedup(); thresholds .into_iter() .map(|threshold| { let kept = calls.iter().filter(|c| c.conf >= threshold); let mut point = GatePoint { threshold, kept_true: 0, kept_exact: 0, kept_spurious: 0, }; for call in kept { if call.has_truth_key { point.kept_true += 1; point.kept_exact += usize::from(call.exact); } else { point.kept_spurious += 1; } } point }) .collect() } #[cfg(test)] mod tests { use super::{Call, KeyCall, bands, discrimination, gate_sweep, tied}; fn call(conf: f64, correct: bool) -> Call { Call { conf, correct } } #[test] fn a_perfect_ranking_scores_one() { let calls = [call(0.1, false), call(0.2, false), call(0.9, true)]; assert_eq!(discrimination(&calls), Some(1.0)); } #[test] fn a_reversed_ranking_scores_zero() { let calls = [call(0.9, false), call(0.1, true)]; assert_eq!(discrimination(&calls), Some(0.0)); } #[test] fn a_confidence_that_says_nothing_scores_half() { // Every call carries the same number, so it cannot rank anything. let calls = [ call(0.7, true), call(0.7, false), call(0.7, true), call(0.7, false), ]; assert_eq!(discrimination(&calls), Some(0.5)); } #[test] fn one_sided_evidence_is_no_score_rather_than_a_default() { // Everything correct: nothing to rank against, and reporting 0.5 or 1.0 // would both read as a measurement that was made. assert!(discrimination(&[call(0.1, true), call(0.9, true)]).is_none()); assert!(discrimination(&[]).is_none()); } #[test] fn bands_split_by_population_and_carry_their_range() { let calls: Vec = (0..10).map(|i| call(i as f64 / 10.0, i >= 5)).collect(); let out = bands(&calls, 2); assert_eq!(out.len(), 2); assert_eq!((out[0].n, out[0].correct), (5, 0)); assert_eq!((out[1].n, out[1].correct), (5, 5)); assert!(tied(out[0].low, 0.0)); assert!(tied(out[1].high, 0.9)); } #[test] fn a_tie_does_not_straddle_a_band_boundary() { // Four of one value and two of another into three bands: splitting the // block of 0.5s would have two bands both reporting 0.5..0.5. let calls: Vec = [0.5, 0.5, 0.5, 0.5, 0.8, 0.9] .iter() .map(|c| call(*c, true)) .collect(); let out = bands(&calls, 3); for pair in out.windows(2) { assert!(pair[0].high < pair[1].low, "bands overlap"); } assert_eq!(out.iter().map(|b| b.n).sum::(), calls.len()); } #[test] fn a_gate_trades_spurious_keys_against_real_ones() { let calls = [ KeyCall { conf: 0.2, has_truth_key: false, exact: false, }, KeyCall { conf: 0.4, has_truth_key: false, exact: false, }, KeyCall { conf: 0.6, has_truth_key: true, exact: false, }, KeyCall { conf: 0.8, has_truth_key: true, exact: true, }, ]; let sweep = gate_sweep(&calls, 4); let open = &sweep[0]; assert_eq!(open.kept_spurious, 2); assert_eq!(open.suppression(2), Some(0.0)); assert_eq!(open.retention(2), Some(1.0)); // Wide open, half the emitted keys are on keyless loops and only one of // four is exactly right. assert_eq!(open.precision(), Some(0.25)); let tightest = sweep.last().expect("a sweep has rows"); assert!(tightest.threshold >= 0.6); assert_eq!(tightest.kept_spurious, 0); assert_eq!(tightest.suppression(2), Some(1.0)); } #[test] fn a_gate_sweep_over_one_confidence_is_a_single_row() { // Every key emitted with the same confidence: no threshold separates // anything, and ten identical rows would read as ten measurements. let calls: Vec = (0..8) .map(|i| KeyCall { conf: 0.7, has_truth_key: i % 2 == 0, exact: false, }) .collect(); assert_eq!(gate_sweep(&calls, 10).len(), 1); } }