Use FastInterpolations.jl as the pdf interpolation backend#150
Conversation
Add a FastInterpolations.jl-backed interpolation path alongside the existing Interpolations.jl InterpKDE, which is unchanged. - FastInterpKDE (src/fast_interp.jl): quadratic by default, method= selectable, zero outside the grid via FillExtrap. - pdf(k, x) now evaluates through FastInterpolations: 1D and 2D single points use the one-shot interp() (no persistent interpolant); 2D grids build once. - InterpKDE and pdf(InterpKDE(k), ...) remain available; return types and type stability are unchanged (scalar/vector/matrix), checked with @inferred. - Tests cover both backends, type parity, and InterpKDE(k, opts...) forwarding.
|
The speedup looked great and I got curious where it comes from, so I poked at it a bit. Two things came out of it — one I think is worth addressing before merge. The two backends compute different interpolants
using KernelDensity
k = UnivariateKDE(0.0:1.0:4.0, [0.0, 1.0, 2.0, 1.0, 0.0]) # symmetric about x=2
ik, fik = InterpKDE(k), FastInterpKDE(k)
[pdf(ik, q) for q in (0.5,1.5,2.5,3.5)], [pdf(fik, q) for q in (0.5,1.5,2.5,3.5)]Since this PR makes Where the speedup comes fromI extended your benchmark with a "build the interpolant once, then reuse it" variant: The default benchmark script (extends the PR's)using KernelDensity, BenchmarkTools, Random
Random.seed!(1234)
X, Y = randn(2000), randn(2000)
k, k2 = kde(X), kde((X, Y))
# default pdf path (as in the PR)
@btime pdf($k, 0.5);
@btime pdf($k2, 0.5, 0.5);
# build the interpolant once, then reuse
ik, fik = InterpKDE(k), FastInterpKDE(k)
ik2, fik2 = InterpKDE(k2), FastInterpKDE(k2)
@btime pdf($ik, 0.5); @btime pdf($fik, 0.5);
@btime pdf($ik2, 0.5, 0.5); @btime pdf($fik2, 0.5, 0.5); |
The FastInterpolations default was QuadraticInterp() with a one-sided Left(QuadraticFit()) BC, which breaks reflection symmetry and can overshoot on symmetric inputs (flagged in the PR JuliaStats#150 review). Switch the default to QuadraticInterp(bc=MinCurvFit()): reflection-symmetric, no overshoot, and the same C1 quadratic order as the Interpolations.jl backend. The two backends differ only in spline construction and agree to ~1e-9 on a real KDE grid (npoints=2048). The default is centralized in a single FI_DefaultInterpMethod const, and the interpolation names now come from 'using FastInterpolations' instead of an explicit import list.
… override Describe that pdf interpolates through FastInterpolations by default, the QuadraticInterp(bc=MinCurvFit()) default scheme, choosing a scheme/BC via the method keyword, reusing InterpKDE/FastInterpKDE for repeated calls, and that the two backends can differ off-grid. README and docs/src/index.md kept in sync.
51954e6 to
751ce45
Compare
|
@devmotion Yes, your observations are all correct. To address the symmetry breaking and overshoot you raised, The scheme is also configurable per call through pdf(k, x; method = ConstantInterp())
pdf(k, x; method = LinearInterp())
pdf(k, x; method = QuadraticInterp(bc = MinCurvFit()))
pdf(k, x; method = CubicInterp())
pdf(k, x; method = CubicInterp(bc = ZeroCurvBC()))
pdf(k, x; method = CubicInterp(bc = BCPair(Deriv1(5.0), Deriv2(0.0))))The figure below shows these schemes on the same (symmetric) dataset: plotting codeusing KernelDensity, FastInterpolations, Plots
k = UnivariateKDE(0.0:1.0:4.0, [0.0, 1.0, 2.0, 1.0, 0.0]) # symmetric about x=2
methods = [ConstantInterp(), LinearInterp(),
QuadraticInterp(), QuadraticInterp(bc=MinCurvFit()),
CubicInterp(), CubicInterp(bc=BCPair(Deriv1(5.0), Deriv2(0.0)))]
titles = ["Constant", "Linear", "Quadratic", "Quadratic MinCurvFit",
"Cubic", "Cubic CustomBC\n(∂ₓf(0)=5.0; ∂ₓ²f(4)=0.0)"]
popts = (legend=:none, xlims=(-0.7, 4.7), ylims=(-0.2, 2.2))
p = [plot(FastInterpKDE(k; method=m).itp; title=t, popts...) for (m, t) in zip(methods, titles)]
plot(p...; layout=(3, 2), size=(1000, 700))
As for the difference between the two backends: they are built differently under the hood (nodal splines vs B-splines), so they won't match exactly off-grid. On small or coarse grids you may see a slight difference, but on the default KDE grid (2048 points) they agree within very small differences (e.g, < 1e-9). I hope this addresses your concerns. |

Summary
Evaluate
pdfthrough FastInterpolations.jl.This makes the common
pdf(k, …)calls substantially faster and allocation-free for point queries.This is not a breaking change: it is additive — the Interpolations.jl backend (
InterpKDE) is unchanged and remains fully supported, return types are identical.What changed
FastInterpKDEwrapper (src/fast_interp.jl), parallel toInterpKDE.pdf(k, x)/pdf(k, x, y)now evaluate through FastInterpolations.jl.InterpKDE(the Interpolations.jl path) is unchanged and stays available; return types are identical, so this is a drop-in change.pdf(k, x; method=...)lets users choose a different interpolation method or boundary condition (default: quadratic).Benchmark
Using FastInterpolations, common point queries (
pdf(k, x)/pdf(k, x, y)) run 10–40× faster with zero allocations.pdf(k, 0.5)pdf(k, k.x)pdf(k2, 0.5, 0.5)pdf(k2, k2.x, k2.y)Disclaimer
I am the author and maintainer of FastInterpolations.jl — a package for fast, zero-allocation N-dimensional interpolation (see the announcement for more). Please weigh this potential conflict of interest.
Gently ping potential reviewers:
@simonbyrne @andreasnoack @tomdstone