Density Plot

A density plot estimates the probability density of a numeric dataset via Gaussian kernel density estimation (KDE) and renders it as a smooth continuous curve. It is the continuous alternative to a histogram — it shows the same shape without the arbitrary bin boundaries, and curves from multiple groups can be overlaid naturally.

Import path: kuva::plot::DensityPlot


Basic usage

Pass raw data values with .with_data(iter). The bandwidth is chosen automatically using Silverman's rule-of-thumb.

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;

let data = vec![2.1, 3.4, 3.7, 2.8, 4.2, 3.9, 3.1, 2.5, 4.0, 3.3,
                2.9, 3.6, 2.7, 3.8, 3.2, 4.1, 2.6, 3.5, 3.0, 4.3];

let density = DensityPlot::new()
    .with_data(data)
    .with_color("steelblue");

let plots = vec![Plot::Density(density)];
let layout = Layout::auto_from_plots(&plots)
    .with_title("Expression Distribution")
    .with_x_label("Expression")
    .with_y_label("Density");

let scene = render_multiple(plots, layout);
let svg = SvgBackend.render_scene(&scene);
std::fs::write("density.svg", svg).unwrap();
}
Basic density plot

The y-axis is a proper probability density: each curve integrates to approximately 1 over the displayed range.

Tail behaviour. By default, the KDE is evaluated from data_min − 3×bandwidth to data_max + 3×bandwidth so the Gaussian tails taper smoothly to zero beyond the data. The x-axis auto-scales to include those tails. This matches ggplot2's default cut = 3 behaviour and is statistically correct — the tails reflect that a distribution does not hard-stop at the outermost data point. If your data is physically bounded (see below) you should set explicit bounds to prevent the curve from extending into impossible values.


Filled area

.with_filled(true) shades the area under the curve. The fill uses the same color as the stroke at a low opacity (default 0.2).

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;
let density = DensityPlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_filled(true)
    .with_opacity(0.25);
}
Filled density plot

Multiple groups

Use one DensityPlot per group and collect them into a single Vec<Plot>. Set .with_palette() on the layout to auto-assign colors, or assign colors and legend labels manually.

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;
use kuva::backend::svg::SvgBackend;
use kuva::render::render::render_multiple;
use kuva::render::layout::Layout;
use kuva::render::plots::Plot;
use kuva::render::palette::Palette;

let pal = Palette::category10();

let plots = vec![
    Plot::Density(
        DensityPlot::new()
            .with_data(group_a)
            .with_color(pal[0])
            .with_filled(true)
            .with_legend("Control"),
    ),
    Plot::Density(
        DensityPlot::new()
            .with_data(group_b)
            .with_color(pal[1])
            .with_filled(true)
            .with_legend("Treatment A"),
    ),
    Plot::Density(
        DensityPlot::new()
            .with_data(group_c)
            .with_color(pal[2])
            .with_filled(true)
            .with_legend("Treatment B"),
    ),
];

let layout = Layout::auto_from_plots(&plots)
    .with_title("Expression by Group")
    .with_x_label("Expression")
    .with_y_label("Density");

let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
std::fs::write("density_groups.svg", svg).unwrap();
}
Density plot with multiple groups

Overlapping filled curves distinguish naturally by color. Increase .with_opacity() toward 0.4 if groups are well separated, or keep it low (0.150.2) when they overlap heavily.


Bounded data — identity scores, β-values, frequencies

For data that is physically constrained to a fixed interval — identity scores [0, 1], methylation β-values [0, 1], allele frequencies [0, 1], percentages [0, 100] — the default KDE will extend past those limits, producing a curve that bleeds into impossible negative values or past the upper ceiling.

KDE bleeding past boundaries KDE with boundary reflection
Default — tails bleed past 0 and 1 with_x_range(0, 1) — smooth taper at boundaries

Use .with_x_range(lo, hi) to enforce both limits simultaneously:

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;
let data = vec![0.1_f64; 10];
// Identity scores: scores cannot be negative or exceed 1.0
let density = DensityPlot::new()
    .with_data(data)
    .with_x_range(0.0, 1.0);
}

Boundary reflection. Simply truncating the evaluation range would cause the curve to cut off abruptly mid-peak wherever data is dense near a boundary. kuva instead uses the reflection method (the same approach as ggplot2 geom_density(bounds = ...) since 3.4.0): for each data point within 3×bandwidth of an active boundary a ghost point is mirrored across that boundary. The KDE is then evaluated only within [lo, hi] using the augmented dataset. The result tapers smoothly to zero at the boundary even when data is concentrated right at the edge.

One-sided bounds. If only one side is physically constrained, set just that bound:

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;
let data = vec![0.1_f64; 10];
// Scores cannot be negative but have no known upper cap
let density = DensityPlot::new()
    .with_data(data)
    .with_x_lo(0.0); // left boundary reflected; right tail still free

// Percentages cannot exceed 100 but have no known lower cap
let density = DensityPlot::new()
    .with_data(data)
    .with_x_hi(100.0); // right boundary reflected; left tail still free
}

When only one bound is set the other tail extends 3×bandwidth past the data extreme as normal. This means a curve with only x_lo = 0 set can still extend past 1.0 on the right if the data range allows — use with_x_range(0.0, 1.0) when both sides are constrained.

In the CLI, pass --x-min and --x-max independently — either flag alone is sufficient:

# Both sides bounded — identity scores
kuva density scores.tsv --value score --x-min 0 --x-max 1

# Left side only — counts that cannot be negative
kuva density counts.tsv --value count --x-min 0

KDE bandwidth

Bandwidth controls smoothing. Silverman's rule works well for roughly normal unimodal data. Set it manually with .with_bandwidth(h) when the automatic choice is too smooth (blends modes) or too rough (noisy).

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;
// Over-smoothed — modes blend together
let d = DensityPlot::new().with_data(data.clone()).with_bandwidth(2.0);

// Automatic — Silverman's rule (default, no call needed)
let d = DensityPlot::new().with_data(data.clone());

// Under-smoothed — noisy, jagged
let d = DensityPlot::new().with_data(data.clone()).with_bandwidth(0.1);
}
Narrow bandwidth Auto bandwidth Wide bandwidth
h = 0.1 (too narrow) Auto — Silverman h = 2.0 (too wide)

.with_kde_samples(n) controls how many points the curve is evaluated at (default 200). The default is smooth enough for most screen resolutions.


Dashed lines

.with_line_dash("4 2") applies an SVG stroke-dasharray. Useful when distinguishing groups in print or greyscale output.

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;
let d = DensityPlot::new()
    .with_data(data)
    .with_color("steelblue")
    .with_stroke_width(2.0)
    .with_line_dash("6 3");
}

Pre-computed curves

DensityPlot::from_curve(x, y) accepts a pre-smoothed curve directly — useful when the density was already computed in Python or R:

#![allow(unused)]
fn main() {
use kuva::plot::DensityPlot;

// x and y computed externally (e.g. scipy.stats.gaussian_kde)
let x = vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0];
let y = vec![0.05, 0.15, 0.40, 0.55, 0.40, 0.15, 0.05];

let density = DensityPlot::from_curve(x, y)
    .with_color("coral")
    .with_filled(true);
}

API reference

MethodDescription
DensityPlot::new()Create a density plot with defaults
DensityPlot::from_curve(x, y)Use a pre-computed curve; bypasses KDE
.with_data(iter)Set input values; accepts any Into<f64> numeric type
.with_color(s)Curve color (CSS color string)
.with_filled(bool)Fill the area under the curve (default false)
.with_opacity(f)Fill opacity when filled (default 0.2)
.with_bandwidth(h)KDE bandwidth; omit for Silverman's rule
.with_kde_samples(n)KDE evaluation points (default 200)
.with_stroke_width(px)Outline stroke width (default 1.5)
.with_line_dash(s)SVG stroke-dasharray, e.g. "4 2" for dashed
.with_legend(s)Attach a legend label
.with_x_range(lo, hi)Clamp KDE evaluation to [lo, hi] with boundary reflection at both ends
.with_x_lo(lo)Set lower bound only; boundary reflection at lo, right tail still free
.with_x_hi(hi)Set upper bound only; boundary reflection at hi, left tail still free