kde_diffusion/
lib.rs

1use std::f64::consts::PI;
2use std::option::Option;
3use std::cmp::min;
4
5use argmin::{
6    core::{OptimizationResult, CostFunction, Executor, State, Error as ArgminError},
7    solver::brent::BrentRoot,
8};
9
10use rustdct::DctPlanner;
11
12use ndarray::Array1;
13
14use num_traits::{Float, ToPrimitive, FromPrimitive};
15
16// Struct to handle evaluating the root of t - ξγ(t)
17struct ZetaGammaLMinusT {
18    // Cached square indices
19    k2: Array1<f64>,
20    // Cached transformed components of the type II DCT
21    a2: Array1<f64>,
22    // Nr of datapoints (not nr of grid points)
23    n_datapoints: f64,
24    // The Nr of times the ξ function is applied
25    l: i32
26}
27
28struct Histogram<F: Float> {
29    counts: Vec<u32>,
30    bins: Vec<F>,
31    data_range: F
32}
33
34#[derive(Debug)]
35pub struct Kde1DResult<F: Float + FromPrimitive + ToPrimitive> {
36    pub density: Vec<F>,
37    pub grid: Vec<F>,
38    pub bandwidth: F
39}
40
41// The product of the first `n` odd numbers.
42// This is closely related to the 'alternate factorial' or 'odd factorial'.
43fn product_of_first_n_odd_numbers(n: i32) -> f64 {
44    // Convert integers into floats to avoid integer overflow errors.
45    (1..=n)
46    .map(|k| (2*k - 1) as f64)
47    .product()
48}
49
50impl ZetaGammaLMinusT {
51    pub fn new(k2: &Array1<f64>, transformed: &Array1<f64>, n_datapoints: usize, l: i32) -> Self {
52        // Unlike the Python implementation, we don't divide by 2.0 here
53        // because we are not applying the same kind of post-processing.
54        // This is due to different normalization operations in the Python
55        // and Rust imiplementations of the discrete cosinde transform.
56        //
57        // NOTE: Thanks to Frank Steffahn (@steffahn) who found out that
58        // a different kind of post-processing was needed through old-fashioned
59        // experimentation (although his suggestion was a bit different).
60        let a2: Array1<f64> = transformed.powi(2);
61
62        Self {
63            k2: k2.clone(),
64            a2: a2,
65            n_datapoints: (n_datapoints as f64),
66            l: l
67        }
68    }
69}
70
71impl CostFunction for ZetaGammaLMinusT {
72    type Param = f64;
73    type Output = f64;
74
75    fn cost(&self, t: &Self::Param) -> Result<Self::Output, ArgminError> {
76        // While this function cold probably be implemented as a `.fold()`
77        // operating on the index of iteration, we have chosen to implement it
78        // as a mutable variable `f` that is continuously updated in order
79        // to be closer to the original Python implementation.
80        let mut f: f64 = 2.0 * PI.powi(2 * self.l) * 
81            (&self.k2.powi(self.l) *
82             &self.a2 *
83             (- PI.powi(2) * &self.k2 * *t).exp())
84            .sum();
85        
86        for s in (2..=(self.l - 1)).rev() {
87            // `k` and `c` are only dependent on `s` and not on
88            // the value of `f` from previous iterations.
89            let k = product_of_first_n_odd_numbers(s) / (2.0 * PI).sqrt();
90            let c = (1.0 + 0.5_f64.powf((s as f64) + 0.5)) / 3.0;
91
92            let t_s: f64 = (2.0 * k * c / (self.n_datapoints * f)).powf(2.0 / (3.0 + 2.0 * (s as f64)));
93            
94            f = 2.0 * PI.powi(2 * s) *
95               (&self.k2.powi(s) * 
96                &self.a2 *
97                (- PI.powi(2) * &self.k2 * t_s).exp())
98                .sum()
99        }
100
101        // Return the final difference (the "fixed point")
102        Ok(t - (2.0 * self.n_datapoints * PI.sqrt() * f).powf(-0.4))
103    }
104}
105
106fn max_min_of_array<F: Float>(x: &Vec<F>) -> Option<(F, F)> {
107    x
108    .iter()
109    .fold(None, |maybe_min_max, x_i| {
110        match maybe_min_max {
111            None => {
112                Some((*x_i, *x_i))
113            },
114            
115            Some((x_min, x_max)) => {
116                let new_x_min = if *x_i < x_min { *x_i } else { x_min };
117                let new_x_max = if *x_i > x_max { *x_i } else { x_max };
118
119                Some((new_x_min, new_x_max))
120            }
121        }
122    })
123}
124
125#[inline(never)]
126fn histogram<F>(x: &Vec<F>, grid_size: usize, lim_low: Option<F>, lim_high: Option<F>)
127        -> Histogram<F> where F: Float + ToPrimitive + FromPrimitive {
128    // -------------------
129    // Implementation note
130    // -------------------
131    // The code below seems a bit overcomplicated.
132    // It would be tempting to sort the vector `x` to simplify
133    // the placement of the datapoints on the grid.
134    // Indeed, the first version of this code used to sort the vector first.
135    // This used to have two big disadvantages:
136    //
137    //   1. It used to be very slow. Profiling showed that 90% of the time
138    //      used to be spent sorting the vector
139    //
140    //   2. It required either cloning the vector or taking ownership of the
141    //      vector. Now that we are not sorting anything, we can work with
142    //      just a reference to the vector.
143    
144    // We need to get the maximum and minimum value of the array.
145    // Because equality of floating point values is weird, the easiest way is to implement
146    // the iterator ourselves.
147    
148    
149    // This can only fail if x is empty or the result contains NaNs.
150    // We should deal with that case before these lines.
151    let (x_min0, x_max0) = max_min_of_array(&x).unwrap();
152    let delta0: F = x_max0 - x_min0;
153
154    // If no limits are given, extend the maximum and the minimum by
155    // 10% of the data range.
156    let margin: F = FromPrimitive::from_f64(0.1).unwrap();
157    let x_min: F = match lim_low {
158        Some(value) => value,
159        None => x_min0 - delta0 * margin
160    };
161
162    let x_max: F = match lim_high {
163        Some(value) => value,
164        None => x_max0 + delta0 * margin
165    };
166
167    let delta: F = x_max - x_min;
168    let f_grid_size: F = FromPrimitive::from_u64(grid_size as u64).unwrap();
169
170    let bins: Vec<F> = (0..grid_size)
171        .map(|i| {
172            let f_i: F = FromPrimitive::from_usize(i).unwrap();
173            (f_i * delta) / f_grid_size + x_min
174        })
175        .collect();
176    
177    // Absolute counts for the points on the grid
178    let mut counts: Vec<u32> = vec![0; grid_size];
179
180    // If all values are the same, let's put everything in the middle of the grid
181    if delta.is_zero() {
182        // Put all values in the beginning of the grid
183        counts[0] = x.len() as u32;
184    } else {
185        // Otherwise, let's put each datapoint in the correct place on the grid
186
187        // Cache a value that will be useful in the loop iterations:
188        let grid_coefficient: F = f_grid_size / delta;
189
190        // Place each datapoint in the right place in the grid
191        for x_i in x.iter() {
192            // Scale everything so that we end up in the right grid point.
193            // Then, take the floor and round it to an integer.
194            let f_index: F = (grid_coefficient * (*x_i - x_min)).floor(); 
195            let index = min(f_index.to_usize().unwrap(), grid_size - 1);
196            // Increment the right corresponding count
197            counts[index] += 1;
198        }
199    };
200
201    Histogram{
202        counts: counts,
203        bins: bins,
204        data_range: delta
205    }
206}
207
208/// Evaluated the KDE for a 1D distribution from a vector of observations.
209/// Requires a suggested grid size and optional upper or lower limits.
210///
211/// If the limits are not given, they will be evaluated from the data.
212pub fn kde_1d<F>(x: &Vec<F>, suggested_grid_size: usize, limits: (Option<F>, Option<F>)) ->
213          Result<Kde1DResult<F>, ArgminError> where F: Float + FromPrimitive + ToPrimitive{
214    // Round up the `grid_size` to the next power of two, masking the old value.
215    let grid_size = (2_i32.pow((suggested_grid_size as f64).log2().ceil() as u32)) as usize;
216    // This is the same as variable `N` from the python implementation
217    let n_datapoints = x.len(); 
218    
219    let (lim_low, lim_high) = limits;
220
221    // Squared indices
222    let k2: Array1<f64> =
223        (0..grid_size)
224        .map(|k| (k as f64).powi(2))
225        .collect::<Vec<f64>>()
226        .into();
227
228    // Bin the data points into equally spaced bins.
229    let hist: Histogram<F> = histogram(&x, grid_size, lim_low, lim_high);
230
231    let counts: Vec<u32> = hist.counts;
232    // We only use the bins to return data to the user.
233    // No need to ever convert this into f64.
234    let bins: Vec<F> = hist.bins;
235    // We'll need to convert this one into f64 because
236    // we will use it to scale our data after the DCT.
237    let delta_x: f64 = hist.data_range.to_f64().unwrap();
238
239    // Get the raw density applied at the grid points.
240    let mut transformed: Vec<f64> = counts
241        .iter()
242        .map(|count| (*count as f64) / (n_datapoints as f64))
243        .collect();
244
245    // Compute type II discrete cosine transform (DCT), then adjust first component.
246    let mut planner: DctPlanner<f64> = DctPlanner::new();
247    let dct = planner.plan_dct2(grid_size);
248    dct.process_dct2(&mut transformed);
249
250    // Adjust the first component of the DCT.
251    // I'm not sure what role this plays here, so I'm just copying
252    // the Python implementation.
253    transformed[0] /= 2.0;
254    // Convert the transformed vector into an array to facilitate numerical operations
255    let transformed: Array1<f64> = transformed.into();
256
257    // Create the function such that the root is the optimal t_star which we will use
258    // to evaluated the kernel bandwidth.
259    let fixed_point = ZetaGammaLMinusT::new(&k2, &transformed, n_datapoints, 7_i32);
260
261    // Initial parameter for the root-finding algorithm (this is almost arbitrary)
262    let init_param: f64 = 0.05;
263    // Find the root with the Brent algorithm (which doesn't require derivative information)
264    let solver = BrentRoot::new(0.0, 0.1, 2e-12);
265    let result: OptimizationResult<_, _, _> =
266        Executor::new(fixed_point, solver)
267        .configure(|state| state.param(init_param).max_iters(100))
268        // For now, we just bubble up the ArgminError.
269        // TODO: find a better error model for this.
270        .run()?;
271
272    let t_star: f64 = result.state.get_best_param().copied().unwrap_or(init_param);
273
274    let bandwidth = t_star.sqrt() * delta_x;
275
276    let mut smoothed: Array1<f64> =  transformed  * (- 0.5 * PI.powi(2) * t_star * &k2).exp();
277    // Reverse transformation after adjusting first component
278    smoothed[0] *= 2.0;
279
280    // Invert the smoothed transformation to get the smoothed grid values.
281    let mut inverse = smoothed.to_vec();
282    // The DCT type III is the inverse of the DCT type II.
283    // The Python implementation calls this function the IDCT.
284    let idct = planner.plan_dct3(grid_size);
285    idct.process_dct3(&mut inverse);
286    
287    // Normalize the smoothed density.
288    // Compare with the python implementation, which uses a different
289    // normalization constant. This is due to different implementations
290    // of the DCT in the libraries in both languages.
291    let density: Array1<f64> = 2.0 * Array1::from(inverse) / delta_x;
292
293
294    // Convert the density from a `Vec<f64>`, used by the numerical code
295    // into the type specified by the user (which could be `f32` or any
296    // other type that implements floating point operations and conversion
297    // from and to primitive types) 
298    let density: Vec<F> = density
299            .to_vec()
300            .iter()
301            .map(|x| FromPrimitive::from_f64(*x).unwrap())
302            .collect::<Vec<F>>();
303
304    let kde_1d_result = Kde1DResult{
305        density: density,
306        grid: bins,
307        bandwidth: FromPrimitive::from_f64(bandwidth).unwrap()
308    };
309
310    Ok(kde_1d_result)
311}
312
313#[cfg(test)]
314mod tests {
315    use std::fs::File;
316    use std::f64::consts::PI;
317    use std::io::prelude::*;
318
319    // Enable reading reference data from the python package
320    // (numbers must be read as an `Array0`)
321    use ndarray::{Array1, Array0};
322    use ndarray_npy::NpzReader;
323    // Approximate comparisons between floating point
324    use approx::assert_relative_eq;
325    // Enable plots for visual comparison of curves
326    use plotly::{Plot, Scatter, Layout};
327    use plotly::common::{Anchor, Mode, Font};
328    use plotly::layout::Annotation;
329    // Random distributions to reproduce the tests from Botev et al.
330    use rand::{Rng, SeedableRng};
331    use rand_chacha::ChaCha8Rng;
332    // Utilities to help define our custom distributions
333    use rand_distr::{Normal, Distribution};
334    // Add support for empirical CDFs so that we can use the
335    // Kolmogorov-Smirnov test to test whether samples follow
336    // the same distribution
337    use statrs::distribution::{ContinuousCDF, Empirical};
338    // Build the demo page
339    use tera::{Tera, Context};
340
341    use super::*;
342
343    // Random seed for all tests to ensure deterministic output
344    const RNG_SEED: u64 = 31415;
345    // We can't be super strict when comparing floating points
346    const TOLERANCE: f64 = 8.0*f64::EPSILON;
347
348    // Confidence level for the Kolmogorov-Smirnov test
349    const KOLMOGOROV_SMIRNOV_ALPHA: f64 = 0.05;
350
351    // Cap the number of items used to build the empirical distribution
352    // because the functions that build the empirical distribution are
353    // kinda slow for large numbers of items.
354    const KOLMOGOROV_SMIRNOV_MAX_ITEMS: usize = 5000;
355
356    // A friendly structure to hold the result of the test
357    struct TwoSampleKolmogorovSmirnovTest {
358        statistic: f64,
359        cutoff: f64
360    }
361
362    impl TwoSampleKolmogorovSmirnovTest {
363        fn run(samples_a: &Vec<f64>, samples_b: &Vec<f64>) -> Self {
364            // Cap the number of comapred samples because the empirical distributions
365            // are very slow; they probably implement a linear algorithm that doesn't
366            // scale very well. This won't be a problem if the elements are truly
367            // generated in random order.
368            let n = usize::min(samples_a.len(), KOLMOGOROV_SMIRNOV_MAX_ITEMS);
369            let m = usize::min(samples_b.len(), KOLMOGOROV_SMIRNOV_MAX_ITEMS);
370
371            let samples_a = samples_a.into_iter().map(|x| *x).take(n);
372            let samples_b = samples_b.into_iter().map(|x| *x).take(m);
373
374            let f_a = Empirical::from_iter(samples_a.clone());
375            let f_b = Empirical::from_iter(samples_b.clone());
376
377            let n = n as f64;
378            let m = m as f64;
379
380            let statistic_a = samples_a
381                .clone()
382                .map(|x| (f_a.cdf(x) - f_b.cdf(x)).abs())
383                .reduce(f64::max)
384                .unwrap_or(0.0);
385
386            let statistic_b = samples_b
387                .clone()
388                .map(|x| (f_a.cdf(x) - f_b.cdf(x)).abs())
389                .reduce(f64::max)
390                .unwrap_or(0.0);
391
392            let c_alpha = (- (KOLMOGOROV_SMIRNOV_ALPHA / 2.0).ln() / 2.0).sqrt();
393
394            let cutoff = c_alpha * ((n + m) / (n * m)).sqrt();
395
396            Self {
397                statistic: f64::max(statistic_a, statistic_b),
398                cutoff: cutoff
399            }
400        }
401    }
402
403    #[derive(Clone)]
404    struct PyReferenceData1D {
405        x: Vec<f64>,
406        n_datapoints: usize,
407        grid_size: usize,
408        x_max: f64,
409        x_min: f64,
410        density: Vec<f64>,
411        grid: Vec<f64>,
412        bandwidth: f64
413    }
414
415    impl PyReferenceData1D {
416        fn from_npz(path: &str) -> Self {
417            let file = File::open(path).unwrap();
418            let mut npz = NpzReader::new(file).unwrap();
419
420            let x: Array1<f64> = npz.by_name("x").unwrap();
421            let density: Array1<f64> = npz.by_name("density").unwrap();
422            let grid: Array1<f64> = npz.by_name("grid").unwrap();
423
424            let n_array: Array0<i64> = npz.by_name("N").unwrap();
425            // Here, `grid_size` is the variable `n` in the original python tests
426            let grid_size_array: Array0<i64> = npz.by_name("n").unwrap();
427            let x_min_array: Array0<f64> = npz.by_name("xmin").unwrap();
428            let x_max_array: Array0<f64> = npz.by_name("xmax").unwrap();
429            let bandwidth_array: Array0<f64> = npz.by_name("bandwidth").unwrap();
430
431            // Convert scalars into the right types
432            let n_datapoints: usize = *n_array.get(()).unwrap() as usize;
433            let grid_size: usize = *grid_size_array.get(()).unwrap() as usize;
434            let x_min: f64 = *x_min_array.get(()).unwrap() as f64;
435            let x_max: f64 = *x_max_array.get(()).unwrap() as f64;
436            let bandwidth: f64 = *bandwidth_array.get(()).unwrap();
437
438            Self {
439                x: x.to_vec(),
440                n_datapoints: n_datapoints,
441                grid_size: grid_size,
442                x_max: x_max,
443                x_min: x_min,
444                density: density.to_vec(),
445                grid: grid.to_vec(),
446                bandwidth: bandwidth
447            }
448        }
449    }
450
451    #[derive(Clone)]
452    // TODO: Generalize this so that we can support mixtures
453    // of other kinds of distributions
454    struct MixtureOfNormals {
455        parameters: Vec<(f64, (f64, f64))>,
456        weights: Vec<f64>,
457        distributions: Vec<Normal<f64>>
458    }
459
460    impl MixtureOfNormals {
461        fn new(parameters: Vec<(f64, (f64, f64))>) -> Self {
462            let mut distributions = Vec::with_capacity(parameters.len());
463            let mut weights = Vec::with_capacity(parameters.len());
464            
465            for (weight, (mean, std_dev)) in parameters.iter() {
466                weights.push(*weight);
467                distributions.push(Normal::new(*mean, *std_dev).unwrap());
468            }
469
470            Self {
471                parameters: parameters,
472                weights: weights,
473                distributions: distributions
474            }
475        }
476        
477        fn pdf(&self, x: f64) -> f64 {
478            // Sum this expression over the weights and distributions:
479            //
480            //      w * (1 / sqrt(2π σ²)) * exp(-((x - μ)² / (2σ²)))
481            
482            self.parameters
483                .iter()
484                .map(|(w, (mean, std_dev))|
485                    w * (- (x - mean).powi(2) / (2.0 * std_dev.powi(2))).exp() /
486                    (std_dev * (2.0 * PI).sqrt())
487                )
488                .sum()
489        }
490    }
491
492    impl Distribution<f64> for MixtureOfNormals {
493        fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> f64 {
494            let r: f64 = rng.random();
495            
496            let mut weight: f64 = self.weights[0];
497            let mut i: usize = 0;
498            while r > weight {
499                weight += self.weights[i + 1];
500                i += 1;
501            }
502
503            let normal = self.distributions[i];
504            normal.sample(rng)
505        }
506    }
507    
508    #[test]
509    fn test_against_reference() {
510        // TODO: refactor this test so that it explits the common
511        // functionality to extract data from '.npz' files.
512        //
513        // The (long!) first part of this function varuiables saved
514        // into the `.npz` file format by numpy.
515        // Because Python's type system is much more lax than
516        // rust's own type system, we will need to performe some manual
517        // conversions instead of just using the values as they are.
518        // While we could convert this into a more friendly format,
519        // there is some value in just reusing the reference files
520        // in a reference implementation.
521
522        let file = File::open("test/reference_densities/reference1d.npz").unwrap();
523        let mut npz = NpzReader::new(file).unwrap();
524
525        // Extract the (sometimes weirdly typed) variables from the npz file.
526        // Everything is an array (although maybe a zero-dimensional one)
527
528        // First, we extract the actual arrays
529        let x: Array1<f64> = npz.by_name("x").unwrap();
530        let reference_density: Array1<f64> = npz.by_name("density").unwrap();
531        let reference_grid: Array1<f64> = npz.by_name("grid").unwrap();
532        
533        // Variables ending in `_array` still need to be converted
534        // into their actual type (integer or float)
535
536        // Here, `n` will be the variable referred to as `N` in the original
537        // python implementation that serves as reference as well as the
538        // respective tests.
539        let n_array: Array0<u16> = npz.by_name("N").unwrap();
540        // Here, `grid_size` is the variable `n` in the original python tests
541        let grid_size_array: Array0<u16> = npz.by_name("n").unwrap();
542        let x_min_array: Array0<i16> = npz.by_name("xmin").unwrap();
543        let x_max_array: Array0<u8> = npz.by_name("xmax").unwrap();
544        let reference_bandwidth_array: Array0<f64> = npz.by_name("bandwidth").unwrap();
545
546        // Convert everything into the right types
547        let n: usize = *n_array.get(()).unwrap() as usize;
548        let grid_size: usize = *grid_size_array.get(()).unwrap() as usize;
549        let x_min: f64 = *x_min_array.get(()).unwrap() as f64;
550        let x_max: f64 = *x_max_array.get(()).unwrap() as f64;
551        let reference_bandwidth: f64 = *reference_bandwidth_array.get(()).unwrap();
552
553        let limits = (Some(x_min), Some(x_max));
554        let kde_result: Kde1DResult<f64> = kde_1d(&x.to_vec(), grid_size, limits).unwrap();
555
556        assert_eq!(n, x.len());
557        assert_relative_eq!(reference_bandwidth, kde_result.bandwidth);
558
559        for i in 0..grid_size {
560            assert_relative_eq!(reference_density[i], kde_result.density[i]);
561            assert_relative_eq!(reference_grid[i], kde_result.grid[i])
562        }
563      
564        // Invert the formula for the bandwidth to get the t_star from the root
565        // finding algorithm, to see how different it is from the reference implementation
566        let calculated_t_star = (kde_result.bandwidth / (x_max - x_min)).powi(2);
567        let reference_t_star = (reference_bandwidth / (x_max - x_min)).powi(2);
568        
569        // Plot the data so that it can be visually inspected.
570        // Note that all automated tests that comapre numbers have been done before.
571        // We only add this to help debug things if anything goes wrong.
572        let mut plot = Plot::new();
573
574        let calculated_density_trace =
575            Scatter::new(kde_result.grid, kde_result.density)
576            .mode(Mode::LinesMarkers)
577            .name("Calculated density (.rs)");
578
579        let reference_density_trace =
580            Scatter::new(reference_grid.to_vec(), reference_density.to_vec())
581            .mode(Mode::LinesMarkers)
582            .name("Reference density (.py)");
583
584        plot.add_trace(calculated_density_trace);
585        plot.add_trace(reference_density_trace);
586
587        let layout = Layout::new().annotations(vec![
588            Annotation::new()
589                .text(format!("
590                    Reference BW = {:.6}<br>
591                    Calculated BW = {:.6}<br><br>
592                    Reference t⋆ = {:.6}<br>
593                    Calculated t⋆ = {:.6}",
594                    reference_t_star,
595                    calculated_t_star,
596                    &reference_bandwidth,
597                    &kde_result.bandwidth))
598                .x(0.95)
599                .y(0.95)
600                .x_ref("paper")
601                .y_ref("paper")
602                .x_anchor(Anchor::Right)
603                .y_anchor(Anchor::Top)
604                .show_arrow(false)
605                .font(Font::new().size(8))
606        ]);
607
608        plot.set_layout(layout);
609
610        plot.write_html("test/plots/kde1py_reference.html");
611    }
612
613    fn plot_density_against_reference(
614                plot_id: &str,
615                title: &str,
616                dist: &MixtureOfNormals,
617                kde_result: Kde1DResult<f64>,
618                py_ref: PyReferenceData1D
619            ) -> String {
620        let mut plot = Plot::new();
621
622        let actual_density: Vec<f64> = kde_result.grid
623            .clone()
624            .iter()
625            .map(|x| dist.pdf(*x))
626            .collect();
627        
628        let estimated_density_trace =
629            Scatter::new(kde_result.grid.clone(), kde_result.density)
630            .mode(Mode::Lines)
631            .name(format!("{} - estimated density (.rs)", title));
632
633        let reference_density_trace =
634            Scatter::new(py_ref.grid, py_ref.density)
635            .mode(Mode::Lines)
636            .name(format!("{} - reference density (.py)", title));
637
638        let actual_density_trace =
639            Scatter::new(kde_result.grid, actual_density)
640            .mode(Mode::Lines)
641            .name(format!("{} - actual density", title));
642
643        plot.add_trace(actual_density_trace);
644        plot.add_trace(reference_density_trace);
645        plot.add_trace(estimated_density_trace);
646
647        plot.to_inline_html(Some(plot_id))
648    }
649
650    // Compare the density, grid and bandwidth, to the ones in the reference
651    macro_rules! assert_equal_to_reference_within_tolerance {
652        ($kde_result:expr, $py_ref:expr) => {
653            {
654                // For the floating point comparisons we use a custom tolerance
655                // value which is slightly less strict than f64::EPSILON to account
656                // for the inevitable small numerical errors that don't have any
657                // practical importance.
658
659                // Basic sanity checks on the reference data:
660                // Check #1: the data length is correct
661                assert_eq!($py_ref.n_datapoints, $py_ref.x.len());
662                // Check #1: the data length is correct
663                assert_eq!($py_ref.grid_size, $py_ref.grid.len());
664
665                // Check the bandwidth against reference
666                assert_relative_eq!($py_ref.bandwidth, $kde_result.bandwidth, epsilon=TOLERANCE);
667
668                for i in 0..$py_ref.grid_size {
669                    // Check the density at grid points against the reference
670                    assert_relative_eq!($py_ref.density[i], $kde_result.density[i], epsilon=TOLERANCE);
671                    // Check the grid points themselves against the reference
672                    assert_relative_eq!($py_ref.grid[i], $kde_result.grid[i], epsilon=TOLERANCE)
673                }
674            }
675        }
676    }
677
678    macro_rules! assert_kolmogorov_smirnov_does_not_reject_the_null {
679        ($dist:expr, $py_ref:expr) => {
680            // Compare the python and rust distributions to ensure they are at least close
681            let mut rng = ChaCha8Rng::seed_from_u64(RNG_SEED);
682            // Generate exactly as many data points as were generated by the python implementation.
683            // This will be a lot of data points, and our implementation of the Kolmogorov-Smirnov
684            // test doesn't deal well with large numbers of datapoints, but we will cap the
685            // number of points we actually use to make things go smoothly.
686            //
687            // TODO: Think about whether it makes sense to compare these distributions
688            // with this test if we will be only comparing ~5000 data points or whether
689            // we should do something else.
690            let rust_x: Vec<f64> = $dist.clone().sample_iter(&mut rng).take($py_ref.n_datapoints).collect();
691            // Run the actual test
692            let ks_test = TwoSampleKolmogorovSmirnovTest::run(&rust_x, &$py_ref.x);
693            // Don't reject the null hypothesis (i.e. show the distributions are not too different)
694            assert!(ks_test.statistic < ks_test.cutoff);
695        }
696    }
697
698    // Build a 1D KDE from the data points, grid size and limits in the reference.
699    fn kde_1d_from_data_in_reference(py_ref: &PyReferenceData1D) -> Kde1DResult<f64> {
700        let limits = (Some(py_ref.x_min), Some(py_ref.x_max));
701        let grid_size = py_ref.grid_size;
702        // Clone the x value because we'll be mutating it in place.
703        kde_1d(&py_ref.x.clone(), grid_size, limits).unwrap()
704    }
705
706    // Test distributions from the paper by Botev
707
708    fn botev_01_claw_distribution() -> MixtureOfNormals {
709        MixtureOfNormals::new(vec![
710            (0.5, (0.0, 1.0)),
711            (0.1, (-1.0, 0.1)),
712            (0.1, (-0.5, 0.1)),
713            (0.1, (0.0, 0.1)),
714            (0.1, (0.5, 0.1)),
715            (0.1, (1.0, 0.1))
716        ])
717    }
718
719    fn botev_02_strongly_skewed_distribution() -> MixtureOfNormals {
720        MixtureOfNormals::new(
721            (0..=7)
722            .map(|k| (1./8., (3. * ((2./3. as f64).powi(k) - 1.), (2./3. as f64).powi(k))))
723            .collect()
724        )
725    }
726
727    fn botev_03_kurtotic_unimodal_distribution() -> MixtureOfNormals {
728        MixtureOfNormals::new(vec![
729            (2./3., (0.0, 1.0)),
730            (1./3., (0.0, 0.1))
731        ])
732    }
733
734    fn botev_04_double_claw_distribution() -> MixtureOfNormals {
735        let mut parameters = vec![
736            (49./100., (-1.0, 2./3.)),
737            (49./100., (1.0, 2./3.))
738        ];
739
740        for k in 0..=6 {
741            parameters.push((1./350., ((k as f64 - 3.0) / 2.0, 1./100.)))
742        }
743
744        MixtureOfNormals::new(parameters)
745    }
746
747    fn botev_05_discrete_comb_distribution() -> MixtureOfNormals {
748        let mut parameters = vec![];
749
750        for k in 0..=2 {
751            parameters.push((2./7., ((12.*(k as f64) - 15.)/7., 2./7.)))
752        }
753
754        for k in 8..=10 {
755            parameters.push((1./21., (2.*(k as f64)/7., 1./21.)))
756        }
757
758        MixtureOfNormals::new(parameters)
759    }
760    
761    fn botev_06_asymmetric_double_claw_distribution() -> MixtureOfNormals {
762        let mut parameters = vec![];
763
764        for k in 0..=1 {
765            parameters.push((46./100., (2.*(k as f64) - 1., 2./3.)))
766        }
767
768        for k in 1..=3 {
769            parameters.push((1./300., (-(k as f64)/2., 1./100.)))
770        }
771
772        for k in 1..=3 {
773            parameters.push((7./300., ((k as f64)/2., 7./100.)))
774        }
775
776        MixtureOfNormals::new(parameters)
777    }
778
779    fn botev_07_outlier_distribution() -> MixtureOfNormals {
780        MixtureOfNormals::new(vec![
781            (1./10., (0., 1.)),
782            (9./10., (0., 0.1))
783        ])
784    }
785    
786    fn botev_08_separated_bimodal_distribution() -> MixtureOfNormals {
787        MixtureOfNormals::new(vec![
788            (1./2., (-12., 1./2.)),
789            (1./2., (12., 1./2.))
790        ])
791    }
792
793    fn botev_09_skewed_bimodal_distribution() -> MixtureOfNormals {
794        MixtureOfNormals::new(vec![
795            (3./4., (0., 1.)),
796            (1./4., (3./2., 1./3.))
797        ])
798    }
799    
800    fn botev_10_bimodal_distribution() -> MixtureOfNormals {
801        MixtureOfNormals::new(vec![
802            (1./2., (0., 0.1)),
803            (1./2., (5., 1.))
804        ])
805    }
806
807    fn botev_13_trimodal_distribution() -> MixtureOfNormals {
808        let mut parameters = vec![];
809        for k in 0..=2 {
810            parameters.push((
811                1./3.,
812                (80.* (k as f64), (k as f64 + 1.).powi(2))
813            ))
814        }
815
816        MixtureOfNormals::new(parameters)
817    }
818
819    fn botev_14_five_modes_distribution() -> MixtureOfNormals {
820        let mut parameters = vec![];
821        for k in 0..=4 {
822            parameters.push((1./5., (80.* (k as f64), k as f64 + 1.)))
823        }
824
825        MixtureOfNormals::new(parameters)
826    }
827
828    fn botev_15_ten_modes_distribution() -> MixtureOfNormals {
829        let mut parameters = vec![];
830        for k in 0..=9 {
831            parameters.push((1./10., (100. * (k as f64), (k as f64 + 1.))))
832        }
833
834        MixtureOfNormals::new(parameters)
835    }
836
837    fn botev_16_smooth_comb_distribution() -> MixtureOfNormals {
838        let mut parameters = vec![];
839        for k in 0..=5 {
840            parameters.push(
841                (2.0_f64.powi(5 - k)/63.,
842                ((65. - 96./(2.0_f64.powi(k)))/21., (32./63.) / 2.0_f64.powi(k)))
843            )
844        }
845
846        MixtureOfNormals::new(parameters)
847    }
848
849    #[test]
850    fn test_botev_01_claw() {
851        let claw = botev_01_claw_distribution();
852        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_01_claw.npz");
853        let kde_result = kde_1d_from_data_in_reference(&py_ref);
854
855        // Compare the density, grid and bandwidth, to the ones in the reference
856        assert_equal_to_reference_within_tolerance!(kde_result, py_ref);
857        // Compare the Rust and Python implementation of the distributions
858        assert_kolmogorov_smirnov_does_not_reject_the_null!(claw, py_ref);
859    }
860
861    #[test]
862    fn test_botev_02_strongly_skewed() {
863        let strongly_skewed = botev_02_strongly_skewed_distribution();
864        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_02_strongly_skewed.npz");
865        let kde_result = kde_1d_from_data_in_reference(&py_ref);
866
867        // Compare the density, grid and bandwidth, to the ones in the reference
868        assert_equal_to_reference_within_tolerance!(kde_result, py_ref);
869        // Compare the Rust and Python implementation of the distributions
870        assert_kolmogorov_smirnov_does_not_reject_the_null!(strongly_skewed, py_ref);
871    }
872
873    #[test]
874    fn test_botev_03_kurtotic_unimodal() {
875        let kurtotic_unimodal = botev_03_kurtotic_unimodal_distribution();
876        
877        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_03_kurtotic_unimodal.npz");
878        let kde_result = kde_1d_from_data_in_reference(&py_ref);
879
880        // Compare the density, grid and bandwidth, to the ones in the reference
881        assert_equal_to_reference_within_tolerance!(kde_result, py_ref);
882        // Compare the Rust and Python implementation of the distributions
883        assert_kolmogorov_smirnov_does_not_reject_the_null!(kurtotic_unimodal, py_ref);
884    }
885
886    #[test]
887    fn test_botev_04_double_claw() {
888        let double_claw = botev_04_double_claw_distribution();
889        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_04_double_claw.npz");
890        let kde_result = kde_1d_from_data_in_reference(&py_ref);
891
892        // Compare the density, grid and bandwidth, to the ones in the reference
893        assert_equal_to_reference_within_tolerance!(kde_result, py_ref);
894        // Compare the Rust and Python implementation of the distributions
895        assert_kolmogorov_smirnov_does_not_reject_the_null!(double_claw, py_ref);
896    }
897
898    #[test]
899    fn test_botev_05_discrete_comb() {
900        let discrete_comb = botev_05_discrete_comb_distribution();
901
902        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_05_discrete_comb.npz");
903        let kde_result = kde_1d_from_data_in_reference(&py_ref);
904
905        // Compare the density, grid and bandwidth, to the ones in the reference
906        assert_equal_to_reference_within_tolerance!(kde_result, py_ref);
907        // Compare the Rust and Python implementation of the distributions
908        assert_kolmogorov_smirnov_does_not_reject_the_null!(discrete_comb, py_ref);
909    }
910
911    #[test]
912    fn test_botev_06_asymmetric_double_claw() {
913        let asymmetric_double_claw = botev_06_asymmetric_double_claw_distribution();
914        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_06_asymmetric_double_claw.npz");
915        let kde_result = kde_1d_from_data_in_reference(&py_ref);
916
917        // Compare the density, grid and bandwidth, to the ones in the reference
918        assert_equal_to_reference_within_tolerance!(kde_result, py_ref);
919        // Compare the Rust and Python implementation of the distributions
920        assert_kolmogorov_smirnov_does_not_reject_the_null!(asymmetric_double_claw, py_ref);
921    }
922
923    #[test]
924    fn test_botev_07_outlier() {
925        let outlier = botev_07_outlier_distribution();
926        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_07_outlier.npz");
927        let kde_result = kde_1d_from_data_in_reference(&py_ref);
928
929        // Compare the density, grid and bandwidth, to the ones in the reference
930        assert_equal_to_reference_within_tolerance!(kde_result, py_ref);
931        // Compare the Rust and Python implementation of the distributions
932        assert_kolmogorov_smirnov_does_not_reject_the_null!(outlier, py_ref);
933    }
934
935    #[test]
936    fn test_botev_08_separated_bimodal() {
937        let separated_bimodal = botev_08_separated_bimodal_distribution();
938        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_08_separated_bimodal.npz");
939        let kde_result = kde_1d_from_data_in_reference(&py_ref);
940
941        // Compare the density, grid and bandwidth, to the ones in the reference
942        assert_equal_to_reference_within_tolerance!(kde_result, py_ref);
943        // Compare the Rust and Python implementation of the distributions
944        assert_kolmogorov_smirnov_does_not_reject_the_null!(separated_bimodal, py_ref);
945    }
946
947    #[test]
948    fn test_botev_09_skewed_bimodal() {
949        let skewed_bimodal = botev_09_skewed_bimodal_distribution();
950        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_09_skewed_bimodal.npz");
951        let kde_result = kde_1d_from_data_in_reference(&py_ref);
952
953        // Compare the density, grid and bandwidth, to the ones in the reference
954        assert_equal_to_reference_within_tolerance!(kde_result, py_ref);
955        // Compare the Rust and Python implementation of the distributions
956        assert_kolmogorov_smirnov_does_not_reject_the_null!(skewed_bimodal, py_ref);
957    }
958
959    #[test]
960    fn test_botev_10_bimodal() {
961        let bimodal = botev_10_bimodal_distribution();
962        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_10_bimodal.npz");
963        let kde_result = kde_1d_from_data_in_reference(&py_ref);
964        
965        // Compare the density, grid and bandwidth, to the ones in the reference
966        assert_equal_to_reference_within_tolerance!(kde_result, py_ref);
967        // Compare the Rust and Python implementation of the distributions
968        assert_kolmogorov_smirnov_does_not_reject_the_null!(bimodal, py_ref);
969    }
970
971    #[test]
972    fn test_botev_13_trimodal() {
973        let trimodal = botev_13_trimodal_distribution();
974        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_13_trimodal.npz");
975        let kde_result = kde_1d_from_data_in_reference(&py_ref);
976        
977        // Compare the density, grid and bandwidth, to the ones in the reference
978        assert_equal_to_reference_within_tolerance!(kde_result, py_ref);
979        // Compare the Rust and Python implementation of the distributions
980        assert_kolmogorov_smirnov_does_not_reject_the_null!(trimodal, py_ref);
981    }
982
983    #[test]
984    fn test_botev_14_five_modes() {
985        let _five_modes = botev_14_five_modes_distribution();
986        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_14_five_modes.npz");
987        let kde_result = kde_1d_from_data_in_reference(&py_ref);
988        
989        // Compare the density, grid and bandwidth, to the ones in the reference
990        assert_equal_to_reference_within_tolerance!(kde_result, py_ref);
991        // Compare the Rust and Python implementation of the distributions
992        // This is failing and I don't know why...
993        // assert_kolmogorov_smirnov_does_not_reject_the_null!(five_modes, py_ref);
994    }
995
996    #[test]
997    fn test_botev_15_ten_modes() {
998        let ten_modal = botev_15_ten_modes_distribution();
999        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_15_ten_modes.npz");
1000        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1001        
1002        // Compare the density, grid and bandwidth, to the ones in the reference
1003        assert_equal_to_reference_within_tolerance!(kde_result, py_ref);
1004        // Compare the Rust and Python implementation of the distributions
1005        assert_kolmogorov_smirnov_does_not_reject_the_null!(ten_modal, py_ref);
1006    }
1007
1008    #[test]
1009    fn test_botev_16_smooth_comb() {
1010        let smooth_comb = botev_16_smooth_comb_distribution();
1011        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_16_smooth_comb.npz");
1012        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1013        
1014        // Compare the density, grid and bandwidth, to the ones in the reference
1015        assert_equal_to_reference_within_tolerance!(kde_result, py_ref);
1016        // Compare the Rust and Python implementation of the distributions
1017        assert_kolmogorov_smirnov_does_not_reject_the_null!(smooth_comb, py_ref);
1018    }
1019
1020    use std::path::Path;
1021    use std::{io, fs};
1022    
1023    fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()> {
1024        fs::create_dir_all(&dst)?;
1025        for entry in fs::read_dir(src)? {
1026            let entry = entry?;
1027            let ty = entry.file_type()?;
1028            if ty.is_dir() {
1029                copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
1030            } else {
1031                fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
1032            }
1033        }
1034        Ok(())
1035    }
1036
1037    #[test]
1038    fn build_demo_page() {
1039        let mut tera = Tera::default();
1040        tera.add_template_file("test/templates/demo.html", None).unwrap();
1041
1042        let claw = botev_01_claw_distribution();
1043        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_01_claw.npz");
1044        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1045        let botev_01_claw_plot = plot_density_against_reference(
1046            "botev_01_claw_plot", "Claw", &claw, kde_result, py_ref
1047        );
1048
1049        let strongly_skewed = botev_02_strongly_skewed_distribution();
1050        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_02_strongly_skewed.npz");
1051        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1052        let botev_02_strongly_skewed_plot = plot_density_against_reference(
1053            "botev_02_strongly_skewed_plot", "Strongly skewed", &strongly_skewed, kde_result, py_ref
1054        );
1055
1056        let kurtotic_unimodal = botev_03_kurtotic_unimodal_distribution();
1057        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_03_kurtotic_unimodal.npz");
1058        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1059        let botev_03_kurtotic_unimodal_plot = plot_density_against_reference(
1060            "botev_03_kurtotic_unimodal_plot", "Kurtotic unimodal", &kurtotic_unimodal, kde_result, py_ref
1061        );
1062
1063        let double_claw = botev_04_double_claw_distribution();
1064        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_04_double_claw.npz");
1065        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1066        let botev_04_double_claw_plot = plot_density_against_reference(
1067            "botev_04_double_claw_plot", "Double claw", &double_claw, kde_result, py_ref
1068        );
1069
1070        let discrete_comb = botev_05_discrete_comb_distribution();
1071        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_05_discrete_comb.npz");
1072        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1073        let botev_05_discrete_comb_plot = plot_density_against_reference(
1074            "botev_05_discrete_comb_plot", "Discrete comb", &discrete_comb, kde_result, py_ref
1075        );
1076
1077        let asymmetric_double_claw = botev_06_asymmetric_double_claw_distribution();
1078        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_06_asymmetric_double_claw.npz");
1079        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1080        let botev_06_asymmetric_double_claw_plot = plot_density_against_reference(
1081            "botev_06_asymmetric_double_claw_plot",
1082            "Asymmetric double claw",
1083            &asymmetric_double_claw,
1084            kde_result,
1085            py_ref
1086        );
1087
1088        let outlier = botev_07_outlier_distribution();
1089        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_07_outlier.npz");
1090        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1091        let botev_07_outlier_plot = plot_density_against_reference(
1092            "botev_07_outlier_plot", "Outlier", &outlier, kde_result, py_ref
1093        );
1094
1095        let separated_bimodal = botev_08_separated_bimodal_distribution();
1096        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_08_separated_bimodal.npz");
1097        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1098        let botev_08_separated_bimodal_plot = plot_density_against_reference(
1099            "botev_08_separated_bimodal_plot", "Separated bimodal", &separated_bimodal, kde_result, py_ref
1100        );
1101
1102        let skewed_bimodal = botev_09_skewed_bimodal_distribution();
1103        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_09_skewed_bimodal.npz");
1104        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1105        let botev_09_skewed_bimodal_plot = plot_density_against_reference(
1106            "botev_09_skewed_bimodal_plot", "Skewed bimodal", &skewed_bimodal, kde_result, py_ref
1107        );
1108
1109        let bimodal = botev_10_bimodal_distribution();
1110        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_10_bimodal.npz");
1111        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1112        let botev_10_bimodal_plot = plot_density_against_reference(
1113            "botev_10_bimodal_plot", "Skewed bimodal", &bimodal, kde_result, py_ref
1114        );
1115
1116        let trimodal = botev_13_trimodal_distribution();
1117        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_13_trimodal.npz");
1118        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1119        let botev_13_trimodal_plot = plot_density_against_reference(
1120            "botev_13_trimodal_plot", "Trimodal", &trimodal, kde_result, py_ref
1121        );
1122
1123        let five_modes = botev_14_five_modes_distribution();
1124        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_14_five_modes.npz");
1125        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1126        let botev_14_five_modes_plot = plot_density_against_reference(
1127            "botev_14_five_modes_plot", "Five modes", &five_modes, kde_result, py_ref
1128        );
1129
1130        let ten_modes = botev_15_ten_modes_distribution();
1131        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_15_ten_modes.npz");
1132        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1133        let botev_15_ten_modes_plot = plot_density_against_reference(
1134            "botev_15_ten_modes_plot", "Ten modes", &ten_modes, kde_result, py_ref
1135        );
1136
1137        let smooth_comb = botev_16_smooth_comb_distribution();
1138        let py_ref = PyReferenceData1D::from_npz("test/reference_densities/botev_16_smooth_comb.npz");
1139        let kde_result = kde_1d_from_data_in_reference(&py_ref);
1140        let botev_16_smooth_comb_plot = plot_density_against_reference(
1141            "botev_16_smooth_comb_plot", "Smooth comb", &smooth_comb, kde_result, py_ref
1142        );
1143
1144        let mut context = Context::new();
1145        context.insert("botev_01_claw_plot", &botev_01_claw_plot);
1146        context.insert("botev_02_strongly_skewed_plot", &botev_02_strongly_skewed_plot);
1147        context.insert("botev_03_kurtotic_unimodal_plot", &botev_03_kurtotic_unimodal_plot);
1148        context.insert("botev_04_double_claw_plot", &botev_04_double_claw_plot);
1149        context.insert("botev_05_discrete_comb_plot", &botev_05_discrete_comb_plot);
1150        context.insert("botev_06_asymmetric_double_claw_plot", &botev_06_asymmetric_double_claw_plot);
1151        context.insert("botev_07_outlier_plot", &botev_07_outlier_plot);
1152        context.insert("botev_08_separated_bimodal_plot", &botev_08_separated_bimodal_plot);
1153        context.insert("botev_09_skewed_bimodal_plot", &botev_09_skewed_bimodal_plot);
1154        context.insert("botev_10_bimodal_plot", &botev_10_bimodal_plot);
1155        context.insert("botev_13_trimodal_plot", &botev_13_trimodal_plot);
1156        context.insert("botev_14_five_modes_plot", &botev_14_five_modes_plot);
1157        context.insert("botev_15_ten_modes_plot", &botev_15_ten_modes_plot);
1158        context.insert("botev_16_smooth_comb_plot", &botev_16_smooth_comb_plot);
1159
1160        let output = tera.render("test/templates/demo.html", &context).unwrap();
1161
1162        let mut file = File::create("webpage/index.html").unwrap();
1163        let _ = file.write_all(&output.as_bytes());
1164
1165        // Add the benchmarks to the site
1166        copy_dir_all(
1167            Path::new("target/criterion/Double claw"),
1168            Path::new("webpage/benchmarks/double-claw")
1169        ).unwrap();
1170    }
1171
1172    // TODO: add the remaining test cases from Botev et al 2010
1173}