Skip to main content

qualia_core_db/solvers/learning/active/
uncertainty.rs

1//! Uncertainty sampling — query the points where a *single* model is least sure.
2//! Operates on a predicted class-probability matrix (`n_samples × n_classes`, each row
3//! a distribution); returns per-sample informativeness and a ranking.
4
5use super::{argsort_desc, ActiveError};
6use crate::solvers::statistics::information::entropy;
7
8/// Which uncertainty measure to rank by. All are oriented so **higher = more
9/// informative** (more worth a human's label).
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Strategy {
12    /// `1 − max_c p(c)` — least confidence in the top prediction.
13    LeastConfidence,
14    /// `1 − (p₁ − p₂)` — small margin between the top two classes ⇒ informative.
15    Margin,
16    /// Shannon entropy of the row — diffuse predictions ⇒ informative.
17    Entropy,
18}
19
20fn top_two(row: &[f64]) -> (f64, f64) {
21    let mut a = f64::NEG_INFINITY; // largest
22    let mut b = f64::NEG_INFINITY; // second
23    for &p in row {
24        if p > a {
25            b = a;
26            a = p;
27        } else if p > b {
28            b = p;
29        }
30    }
31    if b.is_infinite() {
32        b = 0.0;
33    }
34    (a, b)
35}
36
37/// Informativeness of one predicted distribution under `strategy`.
38pub fn row_score(row: &[f64], strategy: Strategy) -> Result<f64, ActiveError> {
39    if row.is_empty() {
40        return Err(ActiveError::InvalidDimension);
41    }
42    Ok(match strategy {
43        Strategy::LeastConfidence => {
44            let (top, _) = top_two(row);
45            1.0 - top
46        }
47        Strategy::Margin => {
48            let (p1, p2) = top_two(row);
49            1.0 - (p1 - p2)
50        }
51        Strategy::Entropy => entropy(row).ok_or(ActiveError::InvalidDimension)?,
52    })
53}
54
55/// Per-sample informativeness for a probability matrix (each inner slice a row).
56pub fn score(probs: &[Vec<f64>], strategy: Strategy) -> Result<Vec<f64>, ActiveError> {
57    if probs.is_empty() {
58        return Err(ActiveError::InsufficientData);
59    }
60    let n_classes = probs[0].len();
61    if n_classes == 0 || probs.iter().any(|r| r.len() != n_classes) {
62        return Err(ActiveError::InvalidDimension);
63    }
64    probs.iter().map(|r| row_score(r, strategy)).collect()
65}
66
67/// Rank pool indices most-informative first under `strategy`.
68pub fn rank_informative(probs: &[Vec<f64>], strategy: Strategy) -> Result<Vec<usize>, ActiveError> {
69    Ok(argsort_desc(&score(probs, strategy)?))
70}
71
72/// The single most-informative pool index (the next item to ask a human about).
73pub fn most_informative(probs: &[Vec<f64>], strategy: Strategy) -> Result<usize, ActiveError> {
74    rank_informative(probs, strategy)?
75        .first()
76        .copied()
77        .ok_or(ActiveError::InsufficientData)
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    const EPS: f64 = 1e-9;
84
85    #[test]
86    fn least_confidence_picks_the_flat_distribution() {
87        // Sample 0 confident (0.9), sample 1 uniform (0.5/0.5) → 1 more informative.
88        let probs = vec![vec![0.9, 0.1], vec![0.5, 0.5]];
89        let s = score(&probs, Strategy::LeastConfidence).unwrap();
90        assert!((s[0] - 0.1).abs() < EPS);
91        assert!((s[1] - 0.5).abs() < EPS);
92        assert_eq!(
93            most_informative(&probs, Strategy::LeastConfidence).unwrap(),
94            1
95        );
96    }
97
98    #[test]
99    fn margin_uses_top_two_gap() {
100        // Three classes: [0.5,0.3,0.2] margin .2 → score .8; [0.4,0.4,0.2] margin 0 → 1.0
101        let probs = vec![vec![0.5, 0.3, 0.2], vec![0.4, 0.4, 0.2]];
102        let s = score(&probs, Strategy::Margin).unwrap();
103        assert!((s[0] - 0.8).abs() < EPS);
104        assert!((s[1] - 1.0).abs() < EPS);
105        assert_eq!(most_informative(&probs, Strategy::Margin).unwrap(), 1);
106    }
107
108    #[test]
109    fn entropy_ranks_diffuse_highest() {
110        let probs = vec![vec![1.0, 0.0], vec![0.5, 0.5]];
111        let r = rank_informative(&probs, Strategy::Entropy).unwrap();
112        assert_eq!(r[0], 1); // uniform row has max entropy
113        let s = score(&probs, Strategy::Entropy).unwrap();
114        assert!((s[0]).abs() < EPS); // entropy of a one-hot is 0
115    }
116
117    #[test]
118    fn fails_closed_on_empty_and_ragged() {
119        assert_eq!(
120            score(&[], Strategy::Entropy).unwrap_err(),
121            ActiveError::InsufficientData
122        );
123        let ragged = vec![vec![0.5, 0.5], vec![1.0]];
124        assert_eq!(
125            score(&ragged, Strategy::Entropy).unwrap_err(),
126            ActiveError::InvalidDimension
127        );
128    }
129}