use crate::transform::MissingDataPolicy;
use super::quantile::quantile;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Bin {
pub range: (f64, f64),
pub count: usize,
}
pub fn bin_by_count(samples: &[f64], bin_count: usize) -> Vec<Bin> {
let bin_count = bin_count.max(1);
if samples.is_empty() {
return Vec::new();
}
let (data_min, data_max) = samples
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), &v| (mn.min(v), mx.max(v)));
if !data_min.is_finite() || !data_max.is_finite() {
return Vec::new();
}
let (domain_min, domain_max) =
if (data_max - data_min).abs() < f64::EPSILON { (data_min, data_min + 1.0) } else { (data_min, data_max) };
let width = (domain_max - domain_min) / bin_count as f64;
let mut bins: Vec<Bin> = (0..bin_count)
.map(|i| Bin { range: (domain_min + i as f64 * width, domain_min + (i + 1) as f64 * width), count: 0 })
.collect();
for &v in samples {
let idx = (((v - domain_min) / width) as usize).min(bin_count - 1);
bins[idx].count += 1;
}
bins
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BinPolicy {
Manual(usize),
Sturges,
FreedmanDiaconis,
Scott,
}
pub fn resolve_bin_count(policy: BinPolicy, samples: &[f64]) -> usize {
let n = samples.len();
match policy {
BinPolicy::Manual(count) => count.max(1),
_ if n < 2 => 1,
BinPolicy::Sturges => (((n as f64).log2().ceil()) as usize + 1).max(1),
BinPolicy::FreedmanDiaconis => {
let (Ok(q1), Ok(q3)) = (quantile(samples, 0.25, MissingDataPolicy::Skip), quantile(samples, 0.75, MissingDataPolicy::Skip)) else {
return 1;
};
let iqr = q3 - q1;
let (data_min, data_max) = samples.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), &v| (mn.min(v), mx.max(v)));
let range = data_max - data_min;
if iqr <= 0.0 || range <= 0.0 {
return 1;
}
let bin_width = 2.0 * iqr / (n as f64).cbrt();
if bin_width <= 0.0 {
return 1;
}
(range / bin_width).ceil().max(1.0) as usize
}
BinPolicy::Scott => {
let mean = samples.iter().sum::<f64>() / n as f64;
let variance = samples.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n as f64;
let stddev = variance.sqrt();
let (data_min, data_max) = samples.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), &v| (mn.min(v), mx.max(v)));
let range = data_max - data_min;
if stddev <= 0.0 || range <= 0.0 {
return 1;
}
let bin_width = 3.49 * stddev / (n as f64).cbrt();
if bin_width <= 0.0 {
return 1;
}
(range / bin_width).ceil().max(1.0) as usize
}
}
}
pub fn bin(values: &[f64], policy: BinPolicy) -> Vec<Bin> {
bin_by_count(values, resolve_bin_count(policy, values))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_samples_produce_no_bins() {
assert!(bin_by_count(&[], 10).is_empty());
}
#[test]
fn single_value_lands_in_one_bin_with_full_count() {
let bins = bin_by_count(&[5.0], 4);
assert_eq!(bins.len(), 4);
let total: usize = bins.iter().map(|b| b.count).sum();
assert_eq!(total, 1);
assert_eq!(bins[0].count, 1);
}
#[test]
fn all_equal_samples_land_in_one_bin_with_full_count() {
let samples = vec![3.0, 3.0, 3.0, 3.0];
let bins = bin_by_count(&samples, 5);
assert_eq!(bins.len(), 5);
let total: usize = bins.iter().map(|b| b.count).sum();
assert_eq!(total, samples.len());
assert_eq!(bins[0].count, samples.len());
}
#[test]
fn every_sample_is_counted_exactly_once() {
let samples: Vec<f64> = (0..100).map(|i| i as f64 * 0.37).collect();
let bins = bin_by_count(&samples, 10);
let total: usize = bins.iter().map(|b| b.count).sum();
assert_eq!(total, samples.len());
}
#[test]
fn max_value_sample_lands_in_the_last_bin_not_out_of_range() {
let bins = bin_by_count(&[0.0, 4.0], 4);
assert_eq!(bins.len(), 4);
assert_eq!(bins[0].count, 1);
assert_eq!(bins[3].count, 1);
assert_eq!(bins[1].count, 0);
assert_eq!(bins[2].count, 0);
}
#[test]
fn bin_count_floors_at_one() {
let bins = bin_by_count(&[1.0, 2.0, 3.0], 0);
assert_eq!(bins.len(), 1);
assert_eq!(bins[0].count, 3);
}
#[test]
fn a_nan_sample_lands_in_bin_zero_the_documented_pre_existing_quirk() {
let bins = bin_by_count(&[10.0, 20.0, f64::NAN], 4);
let total: usize = bins.iter().map(|b| b.count).sum();
assert_eq!(total, 3, "the NaN sample must still be COUNTED somewhere, not silently dropped");
assert_eq!(bins[0].count, 2, "the NaN sample lands in bin 0 alongside the genuine bin-0 sample (10.0)");
}
#[test]
fn manual_policy_always_returns_the_caller_supplied_count_regardless_of_data() {
let samples = vec![1.0, 2.0, 3.0, 4.0, 5.0, 100.0];
assert_eq!(resolve_bin_count(BinPolicy::Manual(7), &samples), 7);
assert_eq!(resolve_bin_count(BinPolicy::Manual(0), &samples), 1, "Manual must floor at 1, matching bin_by_count's own floor");
}
#[test]
fn sturges_matches_the_hand_computed_golden_for_a_known_dataset() {
let samples = vec![1.0, 2.0, 3.0, 4.0];
assert_eq!(resolve_bin_count(BinPolicy::Sturges, &samples), 3);
}
#[test]
fn freedman_diaconis_matches_the_hand_computed_golden_for_a_known_dataset() {
let samples = vec![1.0, 2.0, 3.0, 4.0];
assert_eq!(resolve_bin_count(BinPolicy::FreedmanDiaconis, &samples), 2);
}
#[test]
fn scott_matches_the_hand_computed_golden_for_a_known_dataset() {
let samples = vec![1.0, 2.0, 3.0, 4.0];
assert_eq!(resolve_bin_count(BinPolicy::Scott, &samples), 2);
}
#[test]
fn a_larger_roughly_uniform_dataset_gets_a_wider_sturges_bin_count() {
let samples: Vec<f64> = (0..100).map(|i| i as f64).collect();
assert_eq!(resolve_bin_count(BinPolicy::Sturges, &samples), 8);
}
#[test]
fn every_automatic_rule_falls_back_to_one_bin_for_degenerate_all_equal_data() {
let samples = vec![7.0; 20];
for policy in [BinPolicy::Sturges, BinPolicy::FreedmanDiaconis, BinPolicy::Scott] {
assert_eq!(resolve_bin_count(policy, &samples), if policy == BinPolicy::Sturges { 6 } else { 1 });
}
}
#[test]
fn every_automatic_rule_falls_back_to_one_bin_for_fewer_than_two_samples() {
for policy in [BinPolicy::Sturges, BinPolicy::FreedmanDiaconis, BinPolicy::Scott] {
assert_eq!(resolve_bin_count(policy, &[]), 1);
assert_eq!(resolve_bin_count(policy, &[42.0]), 1);
}
}
#[test]
fn bin_composes_resolve_bin_count_then_bin_by_count_identically_to_calling_both_by_hand() {
let samples: Vec<f64> = (0..200).map(|i| ((i * 37) % 97) as f64 * 0.5).collect();
for policy in [BinPolicy::Sturges, BinPolicy::FreedmanDiaconis, BinPolicy::Scott, BinPolicy::Manual(12)] {
let via_one_call = bin(&samples, policy);
let via_two_calls = bin_by_count(&samples, resolve_bin_count(policy, &samples));
assert_eq!(via_one_call, via_two_calls);
}
}
#[test]
fn bin_on_empty_input_is_empty_for_every_policy() {
for policy in [BinPolicy::Manual(5), BinPolicy::Sturges, BinPolicy::FreedmanDiaconis, BinPolicy::Scott] {
assert!(bin(&[], policy).is_empty());
}
}
#[test]
fn manual_policy_via_bin_matches_the_pre_existing_bin_by_count_call_shape() {
let samples = vec![1.0, 5.0, 9.0, 20.0, 42.0];
assert_eq!(bin(&samples, BinPolicy::Manual(4)), bin_by_count(&samples, 4));
}
}