Skip to main content

bixverse_core/core/base/
info.rs

1//! Information theory-based measures
2
3use faer::{ColRef, Mat, MatRef};
4use rayon::prelude::*;
5
6use crate::prelude::BixverseFloat;
7
8///////////////
9// MiScratch //
10///////////////
11
12/// Reusable count tables for the pairwise mutual information kernels
13pub(crate) struct MiScratch {
14    joint: Vec<usize>,
15    marginal_i: Vec<usize>,
16    marginal_j: Vec<usize>,
17    n_bins: usize,
18}
19
20impl MiScratch {
21    /// Allocate tables for `n_bins` bins
22    ///
23    /// ### Params
24    ///
25    /// * `n_bins` - Number of bins per variable
26    ///
27    /// ### Returns
28    ///
29    /// Zeroed scratch tables
30    pub(crate) fn new(n_bins: usize) -> Self {
31        Self {
32            joint: vec![0; n_bins * n_bins],
33            marginal_i: vec![0; n_bins],
34            marginal_j: vec![0; n_bins],
35            n_bins,
36        }
37    }
38
39    /// Tabulate the joint and marginal counts of two binned columns
40    fn count(&mut self, col_i: ColRef<usize>, col_j: ColRef<usize>) {
41        self.joint.fill(0);
42        self.marginal_i.fill(0);
43        self.marginal_j.fill(0);
44        for (&bin_i, &bin_j) in col_i.iter().zip(col_j.iter()) {
45            self.joint[bin_i * self.n_bins + bin_j] += 1;
46            self.marginal_i[bin_i] += 1;
47            self.marginal_j[bin_j] += 1;
48        }
49    }
50
51    /// Mutual information from the current tables
52    fn mutual_information<T: BixverseFloat>(&self, n_rows: usize) -> T {
53        let n = T::from_usize(n_rows).unwrap();
54        let prob_i: Vec<T> = self
55            .marginal_i
56            .iter()
57            .map(|&c| T::from_usize(c).unwrap() / n)
58            .collect();
59        let prob_j: Vec<T> = self
60            .marginal_j
61            .iter()
62            .map(|&c| T::from_usize(c).unwrap() / n)
63            .collect();
64        let mut mi = T::zero();
65        for i in 0..self.n_bins {
66            for j in 0..self.n_bins {
67                let joint_prob = T::from_usize(self.joint[i * self.n_bins + j]).unwrap() / n;
68                if joint_prob > T::zero() {
69                    mi += joint_prob * (joint_prob / (prob_i[i] * prob_j[j])).ln();
70                }
71            }
72        }
73        mi
74    }
75
76    /// Joint entropy from the current tables
77    fn joint_entropy<T: BixverseFloat>(&self, n_rows: usize) -> T {
78        let n = T::from_usize(n_rows).unwrap();
79        let mut joint_entropy = T::zero();
80        for &count in &self.joint {
81            let joint_prob = T::from_usize(count).unwrap() / n;
82            if joint_prob > T::zero() {
83                joint_entropy -= joint_prob * joint_prob.ln();
84            }
85        }
86        joint_entropy
87    }
88
89    /// Mutual information, and optionally the joint entropy, of a column pair
90    ///
91    /// ### Params
92    ///
93    /// * `col_i` - First binned column
94    /// * `col_j` - Second binned column
95    /// * `with_joint_entropy` - Also compute the joint entropy
96    ///
97    /// ### Returns
98    ///
99    /// `(mutual information, joint entropy)`; the entropy is zero when not
100    /// requested.
101    pub(crate) fn pair<T: BixverseFloat>(
102        &mut self,
103        col_i: ColRef<usize>,
104        col_j: ColRef<usize>,
105        with_joint_entropy: bool,
106    ) -> (T, T) {
107        self.count(col_i, col_j);
108        let n_rows = col_i.nrows();
109        let entropy = if with_joint_entropy {
110            self.joint_entropy(n_rows)
111        } else {
112            T::zero()
113        };
114        (self.mutual_information(n_rows), entropy)
115    }
116}
117
118/////////////
119// Helpers //
120/////////////
121
122/// Resolve the number of bins, defaulting to `sqrt(nrows)`
123///
124/// ### Params
125///
126/// * `n_bins` - Optional number of bins
127/// * `n_rows` - Number of rows of the binned data
128///
129/// ### Returns
130///
131/// The number of bins
132pub(crate) fn resolve_n_bins<T: BixverseFloat>(n_bins: Option<usize>, n_rows: usize) -> usize {
133    n_bins.unwrap_or_else(|| T::from_usize(n_rows).unwrap().sqrt().to_usize().unwrap())
134}
135
136/// Calculate the mutual information between two column references
137///
138/// The columns need to be binned
139///
140/// ### Params
141///
142/// * `col_i` - Column reference to the first column to compare
143/// * `col_j` - Column reference to the second column to compare
144/// * `n_bins` - Optional number of bins. If not provided, will default to
145///   `sqrt(nrows)`.
146///
147/// ### Returns
148///
149/// The mutual information between the two columns
150pub fn calculate_mi<T>(col_i: ColRef<usize>, col_j: ColRef<usize>, n_bins: Option<usize>) -> T
151where
152    T: BixverseFloat,
153{
154    let n_bins = resolve_n_bins::<T>(n_bins, col_i.nrows());
155    MiScratch::new(n_bins).pair(col_i, col_j, false).0
156}
157
158/// Calculates the joint entropy between two column references
159///
160/// ### Params
161///
162/// * `col_i` - Column reference to the first column to compare
163/// * `col_j` - Column reference to the second column to compare
164/// * `n_bins` - Optional number of bins. If not provided, will default to
165///   `sqrt(nrows)`.
166///
167/// ### Returns
168///
169/// The joint entropy
170pub fn calculate_joint_entropy<T>(
171    col_i: ColRef<usize>,
172    col_j: ColRef<usize>,
173    n_bins: Option<usize>,
174) -> T
175where
176    T: BixverseFloat,
177{
178    let n_bins = resolve_n_bins::<T>(n_bins, col_i.nrows());
179    MiScratch::new(n_bins).pair(col_i, col_j, true).1
180}
181
182/// Calculates the entropy of a column reference
183///
184/// The column needs to be binned
185///
186/// ### Params
187///
188/// * `col` - Column reference for which to calculate entropy
189/// * `n_bins` - Optional number of bins. If not provided, will default to
190///   `sqrt(nrows)`.
191///
192/// ### Returns
193///
194/// The entropy of the column
195pub fn calculate_entropy<T>(col: ColRef<usize>, n_bins: Option<usize>) -> T
196where
197    T: BixverseFloat,
198{
199    let n_rows = col.nrows();
200    let n_bins =
201        n_bins.unwrap_or_else(|| T::from_usize(n_rows).unwrap().sqrt().to_usize().unwrap());
202    let mut counts = vec![0usize; n_bins];
203    for i in 0..n_rows {
204        counts[col[i]] += 1;
205    }
206    let n = T::from_usize(n_rows).unwrap();
207    let mut entropy = T::zero();
208    for &count in &counts {
209        if count > 0 {
210            let prob = T::from_usize(count).unwrap() / n;
211            entropy -= prob * prob.ln();
212        }
213    }
214    entropy
215}
216
217/////////////
218// Binning //
219/////////////
220
221/// Binning strategy enum
222#[derive(Debug, Clone, Default, Copy)]
223pub enum BinningStrategy {
224    /// Equal width bins (equal distance between bin edges)
225    #[default]
226    EqualWidth,
227    /// Equal frequency bins (approximately equal number of values per bin)
228    EqualFrequency,
229}
230
231/// Parsing the binning strategy
232///
233/// ### Params
234///
235/// * `s` - string defining the binning strategy
236///
237/// ### Returns
238///
239/// The `BinningStrategy`.
240pub fn parse_bin_strategy_type(s: &str) -> Option<BinningStrategy> {
241    match s.to_lowercase().as_str() {
242        "equal_width" | "equalwidth" => Some(BinningStrategy::EqualWidth),
243        "equal_frequency" | "equal_freq" | "equalfreq" | "equalfrequency" => {
244            Some(BinningStrategy::EqualFrequency)
245        }
246        _ => None,
247    }
248}
249
250/// Equal width binning for a single column
251///
252/// ### Params
253///
254/// * `col` - Column refence to bin with equal width strategy
255/// * `n_bins` - Number of bins to use
256///
257/// ### Returns
258///
259/// Binned vector
260fn bin_equal_width<T>(col: &ColRef<T>, n_bins: usize) -> Vec<usize>
261where
262    T: BixverseFloat,
263{
264    let (min_val, max_val) = col.iter().fold(
265        (
266            T::from_f64(f64::INFINITY).unwrap(),
267            T::from_f64(f64::NEG_INFINITY).unwrap(),
268        ),
269        |(min, max), x| (min.min(*x), max.max(*x)),
270    );
271    let range = max_val - min_val;
272    if range == T::zero() {
273        return vec![0; col.nrows()];
274    }
275    let step = range / T::from_usize(n_bins).unwrap();
276    col.iter()
277        .map(|x| {
278            let bin = ((*x - min_val) / step).floor().to_usize().unwrap();
279            bin.min(n_bins - 1)
280        })
281        .collect()
282}
283
284/// Equal frequency binning for a single column
285///
286/// ### Params
287///
288/// * `col` - Column refence to bin with equal frequency strategy
289/// * `n_bins` - Number of bins to use
290///
291/// ### Returns
292///
293/// Binned vector
294fn bin_equal_frequency<T>(col: &ColRef<T>, n_bins: usize) -> Vec<usize>
295where
296    T: BixverseFloat,
297{
298    let n_rows = col.nrows();
299    let mut sorted_values: Vec<T> = col.iter().copied().collect();
300    sorted_values.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
301
302    let mut split_points = Vec::with_capacity(n_bins);
303    for k in 1..n_bins {
304        let idx = (k * n_rows) / n_bins;
305        split_points.push(sorted_values[idx.min(n_rows - 1)]);
306    }
307
308    let mut bin_assignments = vec![0; n_rows];
309    for (i, &value) in col.iter().enumerate() {
310        let mut bin = 0;
311        for &split in &split_points {
312            if value >= split {
313                bin += 1;
314            } else {
315                break;
316            }
317        }
318        bin_assignments[i] = bin.min(n_bins - 1);
319    }
320    bin_assignments
321}
322
323/// Column wise binning
324///
325/// ### Params
326///
327/// * `mat` - The matrix on which to apply column-wise binning
328/// * `n_bins` - Optional number of bins. If not provided, will default to
329///   `sqrt(nrow)`
330///
331/// ### Returns
332///
333/// The matrix with the columns being binned into equal distances.
334pub fn bin_matrix_cols<T>(
335    mat: &MatRef<T>,
336    n_bins: Option<usize>,
337    strategy: BinningStrategy,
338) -> Mat<usize>
339where
340    T: BixverseFloat,
341{
342    let (n_rows, n_cols) = mat.shape();
343    let n_bins =
344        n_bins.unwrap_or_else(|| T::from_usize(n_rows).unwrap().sqrt().to_usize().unwrap());
345    let binned_vals: Vec<Vec<usize>> = mat
346        .par_col_iter()
347        .map(|col| match strategy {
348            BinningStrategy::EqualWidth => bin_equal_width(&col, n_bins),
349            BinningStrategy::EqualFrequency => bin_equal_frequency(&col, n_bins),
350        })
351        .collect();
352    Mat::from_fn(n_rows, n_cols, |i, j| binned_vals[j][i])
353}
354
355///////////
356// Tests //
357///////////
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use faer::mat;
363
364    fn assert_approx_eq(a: f64, b: f64) {
365        assert!((a - b).abs() < 1e-10, "{} != {}", a, b);
366    }
367
368    /// Two equally frequent symbols hit the maximum entropy for two bins, in nats.
369    #[test]
370    fn test_calculate_entropy_uniform() {
371        let data = mat![[0], [1], [0], [1]]; // usize matrix
372        let col = data.col(0);
373
374        // Explicitly set 2 bins
375        let ent: f64 = calculate_entropy(col, Some(2));
376        assert_approx_eq(ent, 2.0_f64.ln());
377    }
378
379    /// A single occupied bin has zero entropy, and the empty bins must not contribute NaN.
380    #[test]
381    fn test_calculate_entropy_zero() {
382        let data = mat![[0], [0], [0], [0]];
383        let col = data.col(0);
384
385        let ent: f64 = calculate_entropy(col, Some(1));
386        assert_approx_eq(ent, 0.0);
387    }
388
389    /// Joint entropy equals H(X) for identical columns and adds up for independent ones.
390    #[test]
391    fn test_calculate_joint_entropy() {
392        let data = mat![[0, 0], [1, 1], [0, 0], [1, 1]]; // usize matrix
393
394        let je: f64 = calculate_joint_entropy(data.col(0), data.col(1), Some(2));
395        assert_approx_eq(je, 2.0_f64.ln());
396
397        // Independent variables
398        let data_indep = mat![[0, 0], [0, 1], [1, 0], [1, 1]];
399        let je_indep: f64 = calculate_joint_entropy(data_indep.col(0), data_indep.col(1), Some(2));
400        assert_approx_eq(je_indep, 4.0_f64.ln());
401    }
402
403    /// MI saturates at H(X) for identical columns and drops to zero for independent ones.
404    #[test]
405    fn test_calculate_mi() {
406        let data = mat![[0, 0], [1, 1]];
407        let mi: f64 = calculate_mi(data.col(0), data.col(1), Some(2));
408        assert_approx_eq(mi, 2.0_f64.ln());
409
410        // Independent -> MI should be 0
411        let data_indep = mat![[0, 0], [0, 1], [1, 0], [1, 1]];
412        let mi_indep: f64 = calculate_mi(data_indep.col(0), data_indep.col(1), Some(2));
413        assert_approx_eq(mi_indep, 0.0);
414    }
415
416    /// Equal-width binning clamps the top edge back into the last bin rather than overflowing it.
417    #[test]
418    fn test_bin_equal_width() {
419        let mat = mat![[0.0], [5.0], [10.0]];
420
421        let binned = bin_matrix_cols(&mat.as_ref(), Some(3), BinningStrategy::EqualWidth);
422
423        assert_eq!(binned.nrows(), 3);
424        assert_eq!(binned.get(0, 0), &0);
425        assert_eq!(binned.get(1, 0), &1);
426        assert_eq!(binned.get(2, 0), &2);
427    }
428
429    /// A constant column has zero range, so the bin width must not become a division by zero.
430    #[test]
431    fn test_bin_equal_width_constant_column() {
432        // Edge case: Range is 0
433        let mat = mat![[1.0], [1.0], [1.0]];
434        let binned = bin_matrix_cols(&mat.as_ref(), Some(3), BinningStrategy::EqualWidth);
435
436        // Should default to bin 0 for all
437        assert_eq!(binned.get(0, 0), &0);
438        assert_eq!(binned.get(1, 0), &0);
439        assert_eq!(binned.get(2, 0), &0);
440    }
441
442    /// Equal-frequency binning splits on rank yet writes the labels back in input order.
443    #[test]
444    fn test_bin_equal_frequency() {
445        let mat = mat![[1.0], [9.0], [2.0], [8.0]];
446
447        let binned = bin_matrix_cols(&mat.as_ref(), Some(2), BinningStrategy::EqualFrequency);
448
449        // 1.0 and 2.0 should be in bin 0
450        // 8.0 and 9.0 should be in bin 1
451        // Indices in input: 0->1.0, 1->9.0, 2->2.0, 3->8.0
452        assert_eq!(binned.get(0, 0), &0); // 1.0
453        assert_eq!(binned.get(1, 0), &1); // 9.0
454        assert_eq!(binned.get(2, 0), &0); // 2.0
455        assert_eq!(binned.get(3, 0), &1); // 8.0
456    }
457
458    /// The R-facing strategy strings still map onto the enum variants they name.
459    #[test]
460    fn test_parse_bin_strategy() {
461        assert!(matches!(
462            parse_bin_strategy_type("equal_width"),
463            Some(BinningStrategy::EqualWidth)
464        ));
465        assert!(matches!(
466            parse_bin_strategy_type("equal_freq"),
467            Some(BinningStrategy::EqualFrequency)
468        ));
469        assert!(parse_bin_strategy_type("random").is_none());
470    }
471}