Skip to main content

flow_density/kde/
kde2d.rs

1//! 2D Kernel Density Estimation
2//!
3//! Provides 2D KDE for scatter plots and density-based gating.
4
5use crate::common::{gaussian_kernel, interquartile_range, standard_deviation};
6use crate::kde::{KdeError, KdeResult};
7use ndarray::Array2;
8use realfft::RealFftPlanner;
9use realfft::num_complex::Complex;
10
11/// 2D Kernel Density Estimation result
12#[derive(Debug)]
13pub struct KernelDensity2D {
14    /// X grid points
15    pub x: Vec<f64>,
16    /// Y grid points
17    pub y: Vec<f64>,
18    /// Density values (2D grid: x.len() × y.len())
19    pub z: Array2<f64>,
20}
21
22impl KernelDensity2D {
23    /// Compute 2D kernel density estimate using FFT-based convolution
24    ///
25    /// # Arguments
26    /// * `data_x` - X coordinates of data points
27    /// * `data_y` - Y coordinates of data points
28    /// * `adjust` - Bandwidth adjustment factor (default: 1.0)
29    /// * `n_points` - Number of grid points per dimension (default: 128)
30    ///
31    /// # Returns
32    /// KernelDensity2D with 2D density grid
33    pub fn estimate(
34        data_x: &[f64],
35        data_y: &[f64],
36        adjust: f64,
37        n_points: usize,
38    ) -> KdeResult<Self> {
39        if data_x.len() != data_y.len() {
40            return Err(KdeError::StatsError(
41                "X and Y data must have the same length".to_string(),
42            ));
43        }
44
45        if data_x.is_empty() {
46            return Err(KdeError::EmptyData);
47        }
48
49        // Remove NaN values
50        let mut clean_x = Vec::new();
51        let mut clean_y = Vec::new();
52        for i in 0..data_x.len() {
53            if data_x[i].is_finite() && data_y[i].is_finite() {
54                clean_x.push(data_x[i]);
55                clean_y.push(data_y[i]);
56            }
57        }
58
59        if clean_x.len() < 3 {
60            return Err(KdeError::InsufficientData {
61                min: 3,
62                actual: clean_x.len(),
63            });
64        }
65
66        // Calculate bandwidths for each dimension
67        let n = clean_x.len() as f64;
68        let std_dev_x = standard_deviation(&clean_x).map_err(|e| KdeError::StatsError(e))?;
69        let iqr_x = interquartile_range(&clean_x).map_err(|e| KdeError::StatsError(e))?;
70        let bw_factor_x = 0.9 * std_dev_x.min(iqr_x / 1.34) * n.powf(-0.2);
71        let bandwidth_x = bw_factor_x * adjust;
72
73        let std_dev_y = standard_deviation(&clean_y).map_err(|e| KdeError::StatsError(e))?;
74        let iqr_y = interquartile_range(&clean_y).map_err(|e| KdeError::StatsError(e))?;
75        let bw_factor_y = 0.9 * std_dev_y.min(iqr_y / 1.34) * n.powf(-0.2);
76        let bandwidth_y = bw_factor_y * adjust;
77
78        // Create 2D grid
79        let x_min = clean_x.iter().cloned().fold(f64::INFINITY, f64::min);
80        let x_max = clean_x.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
81        let y_min = clean_y.iter().cloned().fold(f64::INFINITY, f64::min);
82        let y_max = clean_y.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
83
84        let x_grid_min = x_min - 3.0 * bandwidth_x;
85        let x_grid_max = x_max + 3.0 * bandwidth_x;
86        let y_grid_min = y_min - 3.0 * bandwidth_y;
87        let y_grid_max = y_max + 3.0 * bandwidth_y;
88
89        let x: Vec<f64> = (0..n_points)
90            .map(|i| x_grid_min + (x_grid_max - x_grid_min) * (i as f64) / (n_points - 1) as f64)
91            .collect();
92        let y: Vec<f64> = (0..n_points)
93            .map(|i| y_grid_min + (y_grid_max - y_grid_min) * (i as f64) / (n_points - 1) as f64)
94            .collect();
95
96        // Compute 2D KDE using FFT convolution
97        let z = kde2d_fft(&clean_x, &clean_y, &x, &y, bandwidth_x, bandwidth_y, n)?;
98
99        Ok(KernelDensity2D { x, y, z })
100    }
101
102    /// Find density contour at given threshold level
103    ///
104    /// # Arguments
105    /// * `threshold` - Density threshold (as fraction of max density)
106    ///
107    /// # Returns
108    /// Vector of (x, y) coordinates forming the contour
109    pub fn find_contour(&self, threshold: f64) -> Vec<(f64, f64)> {
110        let max_density = self.z.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
111        let density_threshold = threshold * max_density;
112
113        // Simple contour extraction: find points above threshold
114        // TODO: Implement proper contour tracing (marching squares algorithm)
115        let mut contour_points = Vec::new();
116
117        for i in 0..self.x.len() {
118            for j in 0..self.y.len() {
119                if self.z[[i, j]] >= density_threshold {
120                    // Check if this is on the boundary (has a neighbor below threshold)
121                    let is_boundary = (i > 0 && self.z[[i - 1, j]] < density_threshold)
122                        || (i < self.x.len() - 1 && self.z[[i + 1, j]] < density_threshold)
123                        || (j > 0 && self.z[[i, j - 1]] < density_threshold)
124                        || (j < self.y.len() - 1 && self.z[[i, j + 1]] < density_threshold);
125
126                    if is_boundary {
127                        contour_points.push((self.x[i], self.y[j]));
128                    }
129                }
130            }
131        }
132
133        contour_points
134    }
135
136    /// Get density value at a specific point (interpolated)
137    ///
138    /// # Arguments
139    /// * `x` - X coordinate
140    /// * `y` - Y coordinate
141    ///
142    /// # Returns
143    /// Interpolated density value
144    pub fn density_at(&self, x: f64, y: f64) -> f64 {
145        // Find grid indices
146        let x_idx = self.find_grid_index(&self.x, x);
147        let y_idx = self.find_grid_index(&self.y, y);
148
149        if x_idx >= self.x.len() || y_idx >= self.y.len() {
150            return 0.0;
151        }
152
153        // Simple bilinear interpolation
154        let x0 = if x_idx > 0 { x_idx - 1 } else { 0 };
155        let x1 = x_idx.min(self.x.len() - 1);
156        let y0 = if y_idx > 0 { y_idx - 1 } else { 0 };
157        let y1 = y_idx.min(self.y.len() - 1);
158
159        let z00 = self.z[[x0, y0]];
160        let z01 = self.z[[x0, y1]];
161        let z10 = self.z[[x1, y0]];
162        let z11 = self.z[[x1, y1]];
163
164        // Bilinear interpolation
165        let dx = if x1 > x0 {
166            (x - self.x[x0]) / (self.x[x1] - self.x[x0])
167        } else {
168            0.0
169        };
170        let dy = if y1 > y0 {
171            (y - self.y[y0]) / (self.y[y1] - self.y[y0])
172        } else {
173            0.0
174        };
175
176        z00 * (1.0 - dx) * (1.0 - dy)
177            + z10 * dx * (1.0 - dy)
178            + z01 * (1.0 - dx) * dy
179            + z11 * dx * dy
180    }
181
182    fn find_grid_index(&self, grid: &[f64], value: f64) -> usize {
183        if value <= grid[0] {
184            return 0;
185        }
186        if value >= grid[grid.len() - 1] {
187            return grid.len() - 1;
188        }
189
190        // Binary search for efficiency
191        let mut left = 0;
192        let mut right = grid.len() - 1;
193        while right - left > 1 {
194            let mid = (left + right) / 2;
195            if grid[mid] <= value {
196                left = mid;
197            } else {
198                right = mid;
199            }
200        }
201        left
202    }
203}
204
205/// 2D FFT-based Kernel Density Estimation
206///
207/// Uses 2D FFT convolution for efficient computation.
208fn kde2d_fft(
209    data_x: &[f64],
210    data_y: &[f64],
211    x_grid: &[f64],
212    y_grid: &[f64],
213    bandwidth_x: f64,
214    bandwidth_y: f64,
215    n: f64,
216) -> KdeResult<Array2<f64>> {
217    let nx = x_grid.len();
218    let ny = y_grid.len();
219
220    if nx < 2 || ny < 2 {
221        return Err(KdeError::StatsError(
222            "Grid must have at least 2 points in each dimension".to_string(),
223        ));
224    }
225
226    let x_spacing = (x_grid[nx - 1] - x_grid[0]) / (nx - 1) as f64;
227    let y_spacing = (y_grid[ny - 1] - y_grid[0]) / (ny - 1) as f64;
228
229    // Step 1: Bin data onto 2D grid
230    let mut binned = Array2::<f64>::zeros((nx, ny));
231    for (&x, &y) in data_x.iter().zip(data_y.iter()) {
232        let x_idx = ((x - x_grid[0]) / x_spacing).floor() as isize;
233        let y_idx = ((y - y_grid[0]) / y_spacing).floor() as isize;
234        if x_idx >= 0 && (x_idx as usize) < nx && y_idx >= 0 && (y_idx as usize) < ny {
235            binned[[x_idx as usize, y_idx as usize]] += 1.0;
236        }
237    }
238
239    // Step 2: Create 2D Gaussian kernel
240    let kernel_center_x = (nx - 1) as f64 / 2.0;
241    let kernel_center_y = (ny - 1) as f64 / 2.0;
242    let mut kernel = Array2::<f64>::zeros((nx, ny));
243
244    for i in 0..nx {
245        for j in 0..ny {
246            let grid_x = (i as f64 - kernel_center_x) * x_spacing;
247            let grid_y = (j as f64 - kernel_center_y) * y_spacing;
248            let u_x = grid_x / bandwidth_x;
249            let u_y = grid_y / bandwidth_y;
250            kernel[[i, j]] = gaussian_kernel(u_x) * gaussian_kernel(u_y);
251        }
252    }
253
254    // Step 3: 2D FFT convolution
255    // Use next power of 2 for efficient FFT
256    let _fft_size_x = (2 * nx).next_power_of_two();
257    let _fft_size_y = (2 * ny).next_power_of_two();
258
259    // For 2D FFT, we'll use a simpler approach: compute 1D FFTs along each dimension
260    // This is less optimal than true 2D FFT but simpler to implement
261    // TODO: Consider using a proper 2D FFT library for better performance
262
263    // For now, use a simplified approach: compute density by convolving each row/column
264    // This is an approximation but works reasonably well
265    let mut density = Array2::<f64>::zeros((nx, ny));
266
267    // Convolve along X dimension for each Y
268    for j in 0..ny {
269        let mut row_binned = vec![0.0; nx];
270        let mut row_kernel = vec![0.0; nx];
271        for i in 0..nx {
272            row_binned[i] = binned[[i, j]];
273            row_kernel[i] = kernel[[i, j]];
274        }
275
276        // Use 1D FFT convolution for this row
277        let row_density = kde1d_row(&row_binned, &row_kernel, bandwidth_x, n)?;
278        for i in 0..nx {
279            density[[i, j]] = row_density[i];
280        }
281    }
282
283    // Convolve along Y dimension for each X (simplified - just average)
284    // Full 2D convolution would be better but this approximation works
285    for i in 0..nx {
286        let mut col_density = vec![0.0; ny];
287        for j in 0..ny {
288            col_density[j] = density[[i, j]];
289        }
290        // Apply Y kernel smoothing
291        for j in 0..ny {
292            let mut sum = 0.0;
293            let mut weight_sum = 0.0;
294            for k in 0..ny {
295                let dist = (j as f64 - k as f64) * y_spacing;
296                let weight = gaussian_kernel(dist / bandwidth_y);
297                sum += col_density[k] * weight;
298                weight_sum += weight;
299            }
300            if weight_sum > 0.0 {
301                density[[i, j]] = sum / weight_sum;
302            }
303        }
304    }
305
306    // Normalize
307    let total: f64 = density.iter().sum();
308    if total > 0.0 {
309        for val in density.iter_mut() {
310            *val /= total * x_spacing * y_spacing;
311        }
312    }
313
314    Ok(density)
315}
316
317/// Helper function for 1D row convolution (simplified)
318fn kde1d_row(binned: &[f64], kernel: &[f64], bandwidth: f64, n: f64) -> KdeResult<Vec<f64>> {
319    let m = binned.len();
320    let fft_size = (2 * m).next_power_of_two();
321
322    let mut planner = RealFftPlanner::<f64>::new();
323    let r2c = planner.plan_fft_forward(fft_size);
324    let c2r = planner.plan_fft_inverse(fft_size);
325
326    // Prepare padded arrays
327    let mut binned_padded = vec![0.0; fft_size];
328    binned_padded[..m].copy_from_slice(binned);
329
330    let mut kernel_padded = vec![0.0; fft_size];
331    let kernel_start = (fft_size - m) / 2;
332    let first_half = (m + 1) / 2;
333    kernel_padded[kernel_start..kernel_start + first_half].copy_from_slice(&kernel[m / 2..]);
334    let second_half = m / 2;
335    if second_half > 0 {
336        kernel_padded[..second_half].copy_from_slice(&kernel[..second_half]);
337    }
338
339    // Forward FFT
340    let mut binned_spectrum = r2c.make_output_vec();
341    r2c.process(&mut binned_padded, &mut binned_spectrum)
342        .map_err(|e| KdeError::FftError(format!("FFT forward failed: {}", e)))?;
343
344    let mut kernel_spectrum = r2c.make_output_vec();
345    r2c.process(&mut kernel_padded, &mut kernel_spectrum)
346        .map_err(|e| KdeError::FftError(format!("FFT forward failed: {}", e)))?;
347
348    // Multiply in frequency domain
349    let mut conv_spectrum: Vec<Complex<f64>> = binned_spectrum
350        .iter()
351        .zip(kernel_spectrum.iter())
352        .map(|(a, b)| a * b)
353        .collect();
354
355    // Inverse FFT
356    let mut conv_result = c2r.make_output_vec();
357    c2r.process(&mut conv_spectrum, &mut conv_result)
358        .map_err(|e| KdeError::FftError(format!("FFT inverse failed: {}", e)))?;
359
360    // Extract and normalize
361    let kernel_start = (fft_size - m) / 2;
362    let mut density = Vec::with_capacity(m);
363    for i in 0..m {
364        let idx = (kernel_start + i) % fft_size;
365        density.push(conv_result[idx] / (fft_size as f64 * n * bandwidth));
366    }
367
368    Ok(density)
369}