Skip to content

Commit 6a284d4

Browse files
committed
Extract core of Silverman bandwith estimator to prepare multidimensional support
This does not yet enable a multidimensional estimator, but we'll need the functionality to compute covariances in order to do so.
1 parent fead17e commit 6a284d4

File tree

3 files changed

+165
-22
lines changed

3 files changed

+165
-22
lines changed

src/bandwidth.jl

Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,99 @@
1+
import LinearAlgebra: Symmetric, cholesky
2+
3+
# Get the effective sample size and (co)variance simultaneously
4+
#
5+
# - Calculate variance via Welford's algorithm:
6+
#
7+
# https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford%27s_online_algorithm
8+
#
9+
# - Calculate the effective sample size, based on weights and the bounds, using the
10+
# Kish effective sample size definition:
11+
#
12+
# n_eff = sum(weights)^2 / sum(weights .^ 2)
13+
#
14+
# https://search.r-project.org/CRAN/refmans/svyweight/html/eff_n.html
15+
# https://en.wikipedia.org/wiki/Weighted_arithmetic_mean#Reliability_weights
16+
function _neff_covar(coords::Tuple{Vararg{AbstractVector,N}},
17+
lo::Tuple{Vararg{Any,N}},
18+
hi::Tuple{Vararg{Any,N}},
19+
weights::Union{Nothing,<:AbstractVector}) where {N}
20+
T = promote_type(map(_unitlesseltype, coords)...)
21+
wsum = wsqr = isnothing(weights) ? zero(T) : zero(eltype(weights))
22+
23+
x = zeros(T, N)
24+
μ = zeros(T, N)
25+
μ₋₁ = zeros(T, N)
26+
Σ = zeros(T, N, N)
27+
I = isnothing(weights) ? eachindex(coords...) : eachindex(weights, coords...)
28+
for ii in I
29+
# @. x = coords[ii] * _invunit(typeof(coords[ii]))
30+
# !all(@. lo ≤ x ≤ hi) && continue
31+
indomain = true
32+
for jj in 1:N
33+
v = coords[jj][ii]
34+
x[jj] = v * oneunit(_invunit(typeof(v)))
35+
indomain &= lo[jj] v hi[jj]
36+
end
37+
!indomain && continue
38+
39+
w = isnothing(weights) ? one(wsum) : weights[ii]
40+
wsum += w
41+
wsqr += w^2
42+
ω = w / wsum
43+
ω̄ = one(ω) - ω
44+
45+
# @. μ₋₁ = μ
46+
# @. μ = ω̄ * μ₋₁ + ω * x
47+
for jj in 1:N
48+
μ₋₁[jj] = μ[jj]
49+
μ[jj] = ω̄ * μ[jj] + ω * x[jj]
50+
end
51+
# @. Σ = ω̄ * Σ + ω * (x - μ) * (x - μ₋₁)'
52+
# with only the upper-triangle filled
53+
for jj in 1:N
54+
yy = x[jj] - μ₋₁[jj]
55+
for kk in 1:jj
56+
Σ[kk,jj] = ω̄ * Σ[kk, jj] + ω * (x[kk] - μ[kk]) * yy
57+
end
58+
end
59+
end
60+
neff = wsum^2 / wsqr
61+
return neff, Symmetric(Σ)
62+
end
63+
64+
# specialize for 1D case where the variance is scalar, so allocating arrays can be avoided
65+
function _neff_covar(coords::Tuple{AbstractVector},
66+
lo::Tuple{Any},
67+
hi::Tuple{Any},
68+
weights::Union{Nothing,<:AbstractVector})
69+
T = promote_type(map(_unitlesseltype, coords)...)
70+
wsum = wsqr = isnothing(weights) ? zero(T) : zero(eltype(weights))
71+
72+
x = zero(T)
73+
μ = zero(T)
74+
μ₋₁ = zero(T)
75+
Σ = zero(T)
76+
I = isnothing(weights) ? eachindex(coords...) : eachindex(weights, coords...)
77+
for ii in I
78+
v = coords[1][ii]
79+
lo[1] v hi[1] || continue
80+
x = v * oneunit(_invunit(typeof(v)))
81+
82+
w = isnothing(weights) ? one(wsum) : weights[ii]
83+
wsum += w
84+
wsqr += w^2
85+
ω = w / wsum
86+
ω̄ = one(ω) - ω
87+
88+
μ₋₁ = μ
89+
μ = ω̄ * μ + ω * x
90+
Σ = ω̄ * Σ + ω * (x - μ) * (x - μ₋₁)
91+
end
92+
neff = wsum^2 / wsqr
93+
return neff, Σ
94+
end
95+
96+
197
"""
298
SilvermanBandwidth <: AbstractBandwidthEstimator
399
@@ -27,26 +123,8 @@ function bandwidth(::SilvermanBandwidth, v::AbstractVector{T},
27123
lo::T, hi::T, ::Boundary.T;
28124
weights::Union{Nothing, <:AbstractVector} = nothing
29125
) where {T}
30-
# Get the effective sample size and variance simultaneously
31-
# - See note in init() about neffective calculation
32-
# - Calculate variance via Welford's algorithm:
33-
# https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford%27s_online_algorithm
34-
wsum = zero(_unitless(T))
35-
wsqr = zero(wsum)
36-
μ = μ₋₁ = zero(T)
37-
σ² = zero(T)^2 # unitful numbers require correct squaring
38-
I = isnothing(weights) ? eachindex(v) : eachindex(v, weights)
39-
for ii in I
40-
x = @inbounds v[ii]
41-
w = isnothing(weights) ? one(wsum) : @inbounds weights[ii]
42-
lo x hi || continue # skip out-of-bounds elements
43-
wsum += w
44-
wsqr += w^2
45-
ω = w / wsum
46-
μ₋₁, μ = μ, (one(T) - ω) * μ + ω * x
47-
σ² = (one(T) - ω) * σ² + ω * (x - μ) * (x - μ₋₁)
48-
end
49-
neff = wsum^2 / wsqr
126+
neff, Σ = _neff_covar((v,), (lo,), (hi,), weights)
127+
σ = sqrt(Σ) * oneunit(eltype(v))
50128

51129
# From Hansen (2009) — https://users.ssc.wisc.edu/~bhansen/718/NonParametrics1.pdf
52130
# for a Gaussian kernel:
@@ -56,10 +134,11 @@ function bandwidth(::SilvermanBandwidth, v::AbstractVector{T},
56134
# - Section 2.9, letting ν = 2:
57135
# - bw = σ̂ n^(-1/5) C₂(k)
58136
# C₂(k) = 2 ( 8R(k)√π / 96κ₂² )^(1/5) == (4/3)^(1/5)
59-
return iszero²) ? eps(oneunit(T)) :
60-
sqrt(σ²) * (oftype(one(T), (4one(T) / 3)) / neff)^(one(T) / 5)
137+
return iszero(σ) ? eps(oneunit(T)) :
138+
σ * (oftype(one(T), (4one(T) / 3)) / neff)^(one(T) / 5)
61139
end
62140

141+
63142
module ISJ
64143
# An implementation of Brent's method, translated from the algorithm described in
65144
# https://en.wikipedia.org/wiki/Brent%27s_method

test/Project.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
33
Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f"
44
FFTW = "7a1cc6ca-52ef-59f5-83cd-3a7055c09341"
55
KernelDensityEstimation = "e46ec5f4-66dc-4371-a668-81bd92d19d7d"
6+
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
67
Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
78
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
89
Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"

test/kde.jl

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using .KDE: estimate
22

33
using FFTW
4+
using LinearAlgebra: I
45
using Statistics: std, var
56
using Random: Random, randn
67
using Unitful
@@ -409,6 +410,68 @@ end
409410
@testset "Silverman Bandwidth" begin
410411
σ = rv_norm_σ
411412

413+
@testset "Variance Estimation" begin
414+
# Check the implementation of the standard deviation and effective sample size
415+
# calculation
416+
v = view(rv_norm_long, 1:1_000_000)
417+
v1 = (v,)
418+
lo = (-10σ,)
419+
hi = (+10σ,)
420+
neff1, var1 = @inferred KDE._neff_covar(v1, lo, hi, nothing)
421+
@test neff1 == length(v)
422+
@test var1 σ^2 atol = 16.0 / sqrt(neff1)
423+
# weights = nothing is same as uniform (all ones)
424+
neff2, var2 = @inferred KDE._neff_covar(v1, lo, hi, fill(1.0, length(v)))
425+
@test neff2 == neff1
426+
@test var2 == var1
427+
428+
# 1D is special-cased
429+
# It should not allocate
430+
if VERSION >= v"1.12.0-beta3"
431+
@test (@allocated KDE._neff_covar(v1, lo, hi, nothing)) == 0
432+
else
433+
@test_broken (@allocated KDE._neff_covar(v1, lo, hi, nothing)) == 0
434+
end
435+
# 1D is special cased — verify that the general case matches
436+
gensig = Tuple{#=coords=# Tuple{Vararg{AbstractVector,N}},
437+
#=lo=# Tuple{Vararg{Any,N}},
438+
#=hi=# Tuple{Vararg{Any,N}},
439+
#=weights=# Nothing
440+
} where N
441+
neff3, var3 = invoke(KDE._neff_covar, gensig, v1, lo, hi, nothing)
442+
@test neff3 == neff1
443+
@test var3 isa AbstractMatrix{Float64}
444+
@test only(var3) == var1
445+
446+
447+
# multidimensional covariances
448+
449+
# using the same data multiple times is perfect correlation, so all entries
450+
# in the covariance matrix are identical (and equal to the variance of the data)
451+
# 2x2
452+
neff, covar = KDE._neff_covar((v, v), -10σ.*(1,1), 10σ.*(1,1), nothing)
453+
@test neff == length(v)
454+
@test covar == var1 .* ones(2, 2)
455+
# 3x3
456+
neff, covar = KDE._neff_covar((v, v, v), -10σ.*(1,1,1), 10σ.*(1,1,1), nothing)
457+
@test neff == length(v)
458+
@test covar == var1 .* ones(3, 3)
459+
# circularly-shifting one of the inputs decorrelates the inputs, so then the
460+
# covariance matrix should be approximately diagonal
461+
w = circshift(v, -1)
462+
# 2x2
463+
neff, covar = KDE._neff_covar((v, w), -10σ.*(1,1), 10σ.*(1,1), nothing)
464+
@test neff == length(v)
465+
@test covar var1 * I atol=16/sqrt(length(v))
466+
# 3x3 (not diagonal)
467+
neff, covar = KDE._neff_covar((v, v, w), -10σ.*(1,1,1), 10σ.*(1,1,1), nothing)
468+
@test neff == length(v)
469+
Σ = [1 1 0
470+
1 1 0
471+
0 0 1] .* var1
472+
@test covar Σ atol=16/sqrt(length(v))
473+
end
474+
412475
# Test that the estimator approximately matches the asymptotic behavior for a
413476
# the known Gaussian distribution behavior.
414477
# N.B. use very large numbers to reduce sample variance

0 commit comments

Comments
 (0)