use crate::aes::Aesthetic;
use crate::data::{DataFrame, Value};
use crate::scale::ScaleSet;
use super::Stat;
pub struct StatDensity {
pub n_points: usize,
}
impl Default for StatDensity {
fn default() -> Self {
StatDensity { n_points: 512 }
}
}
impl Stat for StatDensity {
fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
let x_col = match data.column("x") {
Some(c) => c,
None => return DataFrame::new(),
};
let values: Vec<f64> = x_col.iter().filter_map(|v| v.as_f64()).collect();
if values.len() < 2 {
return DataFrame::new();
}
let bandwidth = super::bw_nrd0(&values);
let x_min = values.iter().cloned().fold(f64::INFINITY, f64::min) - 3.0 * bandwidth;
let x_max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max) + 3.0 * bandwidth;
let step = (x_max - x_min) / (self.n_points - 1) as f64;
let dens = if values.len() >= BINNED_MIN_N {
binned_kde(&values, bandwidth, x_min, step, self.n_points)
} else {
None
}
.unwrap_or_else(|| exact_kde(&values, bandwidth, x_min, step, self.n_points));
let x_vals: Vec<Value> = (0..self.n_points)
.map(|i| Value::Float(x_min + i as f64 * step))
.collect();
let y_vals: Vec<Value> = dens.into_iter().map(Value::Float).collect();
let mut result = DataFrame::new();
result.add_column("x".to_string(), x_vals);
result.add_column("y".to_string(), y_vals);
for col_name in &["color", "fill", "group"] {
if let Some(col) = data.column(col_name) {
if let Some(first) = col.first() {
result.add_column(col_name.to_string(), vec![first.clone(); self.n_points]);
}
}
}
result
}
fn required_aes(&self) -> Vec<Aesthetic> {
vec![Aesthetic::X]
}
fn name(&self) -> &str {
"density"
}
}
const BINNED_MIN_N: usize = 2048;
fn exact_kde(values: &[f64], bw: f64, x_min: f64, step: f64, n_points: usize) -> Vec<f64> {
let n = values.len() as f64;
(0..n_points)
.map(|i| {
let x = x_min + i as f64 * step;
values
.iter()
.map(|xi| gaussian_kernel((x - xi) / bw))
.sum::<f64>()
/ (n * bw)
})
.collect()
}
fn binned_kde(values: &[f64], bw: f64, x_min: f64, step: f64, n_points: usize) -> Option<Vec<f64>> {
const MAX_FINE: usize = 1 << 20;
if !(bw.is_finite() && bw > 0.0 && step.is_finite() && step > 0.0 && n_points >= 2) {
return None;
}
if values.iter().any(|v| !v.is_finite()) {
return None;
}
let r = (step * 50.0 / bw).ceil().max(1.0);
let m = (n_points - 1) as f64 * r + 1.0;
if !m.is_finite() || m > MAX_FINE as f64 {
return None;
}
let (r, m) = (r as usize, m as usize);
let delta = step / r as f64;
let mut w = vec![0.0f64; m];
for &x in values {
let pos = ((x - x_min) / delta).clamp(0.0, (m - 1) as f64);
let j = (pos.floor() as usize).min(m - 1);
let f = pos - j as f64;
w[j] += 1.0 - f;
if f > 0.0 && j + 1 < m {
w[j + 1] += f;
}
}
let half = ((8.0 * bw / delta).ceil() as usize).min(m);
let table: Vec<f64> = (0..=half)
.map(|o| gaussian_kernel(o as f64 * delta / bw))
.collect();
let norm = values.len() as f64 * bw;
Some(
(0..n_points)
.map(|i| {
let c = i * r;
let lo = c.saturating_sub(half);
let hi = (c + half).min(m - 1);
let mut acc = 0.0;
for (j, wj) in w.iter().enumerate().take(hi + 1).skip(lo) {
if *wj != 0.0 {
acc += wj * table[j.abs_diff(c)];
}
}
acc / norm
})
.collect(),
)
}
fn gaussian_kernel(x: f64) -> f64 {
(-(x * x) / 2.0).exp() / (2.0 * std::f64::consts::PI).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(values: &[f64]) -> DataFrame {
let mut df = DataFrame::new();
df.add_column(
"x".into(),
values.iter().map(|v| Value::Float(*v)).collect(),
);
df
}
fn sample(n: usize) -> Vec<f64> {
let mut rng = crate::rng::SplitMix64::new(99);
(0..n)
.map(|i| {
let u1 = rng.next_f64().max(1e-12);
let u2 = rng.next_f64();
let z = (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos();
if i % 3 == 0 {
10.0 + 0.5 * z
} else {
z * 2.0
}
})
.collect()
}
fn setup(values: &[f64]) -> (f64, f64, f64) {
let bw = crate::stat::bw_nrd0(values);
let lo = values.iter().cloned().fold(f64::INFINITY, f64::min) - 3.0 * bw;
let hi = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max) + 3.0 * bw;
(bw, lo, (hi - lo) / 511.0)
}
#[test]
fn binned_matches_exact_within_tight_tolerance() {
for n in [2048, 5000, 20_000] {
let v = sample(n);
let (bw, lo, step) = setup(&v);
let exact = exact_kde(&v, bw, lo, step, 512);
let binned = binned_kde(&v, bw, lo, step, 512).expect("binned path");
let peak = exact.iter().cloned().fold(0.0, f64::max);
let max_err = exact
.iter()
.zip(&binned)
.map(|(a, b)| (a - b).abs())
.fold(0.0, f64::max);
assert!(
max_err / peak < 5e-5,
"n={n}: relative error {}",
max_err / peak
);
let area: f64 = binned.iter().sum::<f64>() * step;
assert!((area - 1.0).abs() < 1e-3, "area {area}");
}
}
#[test]
fn large_input_uses_binned_path_and_stays_close() {
let v = sample(10_000);
let out = StatDensity::default().compute_group(&frame(&v), &ScaleSet::new());
let ys: Vec<f64> = out
.column("y")
.unwrap()
.iter()
.map(|v| v.as_f64().unwrap())
.collect();
let (bw, lo, step) = setup(&v);
let exact = exact_kde(&v, bw, lo, step, 512);
let peak = exact.iter().cloned().fold(0.0, f64::max);
for (a, b) in exact.iter().zip(&ys) {
assert!((a - b).abs() / peak < 1e-4);
}
}
#[test]
fn binned_declines_unsuitable_inputs() {
assert!(binned_kde(&[1.0, f64::NAN], 0.5, 0.0, 0.01, 512).is_none());
assert!(binned_kde(&[1.0, 2.0], 0.0, 0.0, 0.01, 512).is_none());
assert!(binned_kde(&[0.0, 1e9], 1e-6, 0.0, 1e9 / 511.0, 512).is_none());
}
}