JuliaStats / Distributions.jl

A Julia package for probability distributions and associated functions.
Other
1.1k stars 414 forks source link

Generically supporting wrapped distributions #1716

Open sethaxen opened 1 year ago

sethaxen commented 1 year ago

1715 proposes adding the Wrapped Normal distribution. Other common named wrapped distributions include:

One could support these generically, as well as arbitrary wrappings, with a "wrapper" distribution Wrapped, that could take a distribution with support on the real line and an interval around which to wrap it. e.g.

using Distributions

struct Wrapped{D<:UnivariateDistribution, S<:ValueSupport, T <: Real} <: UnivariateDistribution{S}
    unwrapped::D
    lower::T      # lower bound
    upper::T      # upper bound
    k::Int
    function Wrapped(d::UnivariateDistribution, l::T, u::T, k::Int=1) where {T <: Real}
        new{typeof(d), Distributions.value_support(typeof(d)), T}(d, l, u, k)
    end
end

wrapped(d::UnivariateDistribution, l::T, u::T, k::Int=1) where {T<:Real} = Wrapped(d, l, u, k)
wrapped(d::UnivariateDistribution, l::Real, u::Real, k::Int=1) = Wrapped(d, Base.promote(l, u)..., k)

Base.minimum(d::Wrapped) = d.lower
Base.maximum(d::Wrapped) = d.upper

function Distributions.pdf(d::Wrapped, x::Real; tol=sqrt(eps(float(typeof(x)))), maxiter=1_000)
    u = d.upper
    l = d.lower
    period = u - l
    z = mod(x, period)
    p = pdf(d.unwrapped, z)
    for i in 1:maxiter
        r = i // d.k
        zl = muladd(-r, period, z)
        zu = muladd(r, period, z)
        pl = insupport(d.unwrapped, zl) ? pdf(d.unwrapped, zl) : zero(p)
        pu = insupport(d.unwrapped, zu) ? pdf(d.unwrapped, zu) : zero(p)
        Δp = pl + pu
        p += Δp
        abs(Δp) < p * tol && break
    end
    return p / d.k
end

Advantages

Disadvantages:

An example usage

using StatsPlots

fig = plot(
    plot(wrapped(Normal(π/3, 1), -π, π); title="Wrapped Normal", proj=:polar),
    plot(wrapped(Cauchy(π/3, 1), 0, 2π); title="Wrapped Cauchy", proj=:polar),
    plot(wrapped(Uniform(-1, 1), -π, π, 8); title="8-times Wrapped Uniform", proj=:polar),
    plot(wrapped(Poisson(10), 0, 15); title="Wrapped Poisson");
    label="", dpi=180, titlefontsize=10
)

tmp

adapted from original post by @sethaxen in https://github.com/JuliaStats/Distributions.jl/issues/1715#issuecomment-1536129396

sethaxen commented 1 year ago

This also makes it easy to create axial distributions from directional distributions:

plot(wrapped(VonMises(π/4, 10), -π, π, 2); proj=:polar, label="")

tmp

sethaxen commented 1 year ago

In some cases, like WrappedNormal in https://github.com/JuliaStats/Distributions.jl/issues/1715, depending on the parameters of unwrapped, we might at construction time be able to compute some heuristic to decide which method to use e.g. for density evaluation, which the above approach would require us do during every density evaluation. One solution would be to add some field of arbitrary type to Wrapped that could in an overloaded constructor be filled with any relevant precomputations that would be used in pdf, etc.

After more thought, this can also be handled generically. For some arbitrary wrapped distribution, we can compute the density by:

A field use_characteristic::Bool, defaulting to False, would specify which approach should be used. One can then overload wrapped(::MyDist, l, u) to specify that the characteristic function should instead be used, which can in special cases like WrappedNormal be chosen by the parameters.