Skip to content

fix(density): order-independent neighbourhood radius + reusable DensityEstimator#414

Open
agene0001 wants to merge 2 commits into
statrs-dev:mainfrom
agene0001:feat/density-estimator
Open

fix(density): order-independent neighbourhood radius + reusable DensityEstimator#414
agene0001 wants to merge 2 commits into
statrs-dev:mainfrom
agene0001:feat/density-estimator

Conversation

@agene0001

Copy link
Copy Markdown

Two things, one file family (src/density/):

1. A latent ordering bug. knn_pdf/kde_pdf read the neighbourhood radius as neighbors.last().unwrap(), relying on an ordering KdTree::within never documented. kdtree 0.7 happened to return into_sorted_vec(); 0.8 returns raw BinaryHeap order, so the last element is an arbitrary neighbour and both densities get scaled by the wrong ball volume. (nearest() still sorts in 0.8, which is why only the Some(bandwidth) path broke.) nearest_neighbors now returns NearestNeighbors { squared_distances, k, radius } with radius computed as an explicit maximum, documented as order-independent. Verified under both 0.7.0 and 0.8.1; the requirement moves to 0.8.1 and Cargo.lock.MSRV is regenerated with cargo hack check --rust-version.

The Monte Carlo tests couldn't reliably catch this, so it's pinned deterministically: 7 hand-placed samples, query at 0, squared radius 4 ⇒ exactly k = 5, radius = 2, knn_pdf == 5/28. Reading an arbitrary neighbour's distance gives 5/14.

2. DensityEstimator: build the tree once. nearest_neighbors rebuilt the k-d tree on every call — ~59% of a single knn_pdf at n = 1e5. The estimator owns the tree:

200-point grid free fns DensityEstimator
knn_pdf, n = 10⁴ 32.4 ms 4.4 ms 6.8x
knn_pdf, n = 10⁵ 305.8 ms 44.1 ms 6.9x
kde_pdf (20 pts), n = 10⁵ 35.1 ms 7.4 ms 4.8x

knn_pdf/kde_pdf remain as thin wrappers, and a test asserts the estimator is bit-identical to them over 40 grid points × 3 bandwidths. The tree dimension now comes from the samples rather than the query point, so a mismatched query is rejected instead of silently building a wrong-shaped tree. A density_grid_200 criterion group tracks the rebuild-vs-prepared comparison.

🤖 Generated with Claude Code

agene0001 and others added 2 commits July 26, 2026 10:02
…t element

`knn_pdf` and `kde_pdf` read the neighbourhood radius as
`neighbors.last().unwrap()`, relying on an ordering `KdTree::within` never
documented. It happened to hold in kdtree 0.7, which returned
`evaluated.into_sorted_vec()`; 0.8 returns raw `BinaryHeap` order, so the last
element is an arbitrary neighbour and both densities were scaled by the wrong
ball volume. `nearest()` still sorts in 0.8, which is why only the
`Some(bandwidth)` path was affected.

`nearest_neighbors` now returns a `NearestNeighbors { squared_distances, k,
radius }` whose `radius` is computed as an explicit maximum, and whose docs state
that the distance order is unspecified. Verified under both 0.7.0 and 0.8.1, and
the kdtree requirement is moved to 0.8.1 (Cargo.lock.MSRV regenerated with
`cargo hack check --rust-version`).

The Monte Carlo tests could not reliably catch this, so it is pinned by a
deterministic one: 7 hand-placed samples, query at 0, squared radius 4 gives
exactly k = 5, radius = 2, and `knn_pdf == 5/28`. Reading an arbitrary
neighbour's distance instead would give 5/14.

Adds `DensityEstimator`, which owns the k-d tree so it can be built once and
queried many times. `nearest_neighbors` used to rebuild the whole tree on every
call - about 59% of a single `knn_pdf` at n = 1e5:

    200-point grid, n = 10_000     32.4 ms -> 4.4 ms   (6.8x)
    200-point grid, n = 100_000   305.8 ms -> 44.1 ms  (6.9x)
    kde_pdf, 20 points, n = 1e5    35.1 ms -> 7.4 ms   (4.8x)

`knn_pdf`/`kde_pdf` are kept as thin wrappers, and a test asserts the prepared
estimator is bit-identical to them over 40 grid points and 3 bandwidths. The tree
dimension now comes from the samples rather than from a query point, so a
mismatched query is rejected instead of building a wrong-shaped tree.
`[`DensityEstimator`](crate::density::DensityEstimator)` resolves to the same
item as `[`DensityEstimator`]`, so the explicit target trips
`rustdoc::redundant_explicit_links` under `RUSTDOCFLAGS=-D warnings`.

Not a CI failure: no workflow runs `cargo doc`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant