3

I'm trying to figure out the best way to define a von-Mises distribution wrapped on a half-circle (I'm using it to draw directionless lines at different concentrations). I'm currently using SciPy's vonmises.rvs(). Essentially, I want to be able to put in, say, a mean orientation of pi/2 and have the distribution truncated to no more than pi/2 either side.

I could use a truncated normal distribution, but I will lose the wrapping of the von-mises (say if I want a mean orientation of 0)

I've seen this done in research papers looking at mapping fibre orientations, but I can't figure out how to implement it (in python). I'm a bit stuck on where to start.

If my von Mesis is defined as (from numpy.vonmises):

np.exp(kappa*np.cos(x-mu))/(2*np.pi*i0(kappa))

with:

mu, kappa = 0, 4.0

x = np.linspace(-np.pi, np.pi, num=51)

How would I alter it to use a wrap around a half-circle instead?

Could anyone with some experience with this offer some guidance?

Severin Pappadeux
  • 18,636
  • 3
  • 38
  • 64
mscone
  • 33
  • 4

2 Answers2

2

Is is useful to have direct numerical inverse CDF sampling, it should work great for distribution with bounded domain. Here is code sample, building PDF and CDF tables and sampling using inverse CDF method. Could be optimized and vectorized, of course

Code, Python 3.8, x64 Windows 10

import numpy as np
import matplotlib.pyplot as plt
import scipy.integrate as integrate

def PDF(x, μ, κ):
    return np.exp(κ*np.cos(x - μ))

N = 201

μ = np.pi/2.0
κ = 4.0

xlo = μ - np.pi/2.0
xhi = μ + np.pi/2.0

# PDF normaliztion

I = integrate.quad(lambda x: PDF(x, μ, κ), xlo, xhi)
print(I)
I = I[0]

x = np.linspace(xlo, xhi, N, dtype=np.float64)
step = (xhi-xlo)/(N-1)

p = PDF(x, μ, κ)/I # PDF table

# making CDF table
c = np.zeros(N, dtype=np.float64)

for k in range(1, N):
    c[k] = integrate.quad(lambda x: PDF(x, μ, κ), xlo, x[k])[0] / I

c[N-1] = 1.0 # so random() in [0...1) range would work right

#%%
# sampling from tabular CDF via insverse CDF method

def InvCDFsample(c, x, gen):
    r = gen.random()
    i = np.searchsorted(c, r, side='right')
    q = (r - c[i-1]) / (c[i] - c[i-1])
    return (1.0 - q) * x[i-1] + q * x[i]

# sampling test
RNG = np.random.default_rng()

s = np.empty(20000)

for k in range(0, len(s)):
    s[k] = InvCDFsample(c, x, RNG)

# plotting PDF, CDF and sampling density
plt.plot(x, p, 'b^') # PDF
plt.plot(x, c, 'r.') # CDF
n, bins, patches = plt.hist(s, x, density = True, color ='green', alpha = 0.7)
plt.show()

and graph with PDF, CDF and sampling histogram

enter image description here

Severin Pappadeux
  • 18,636
  • 3
  • 38
  • 64
  • Thanks for such a detailed response. This will work excellently for my case, and I'll work on optimising it (although I only need to perform the operation once every ~ 3 seconds!). I've seen on forums ([link](https://discourse.mc-stan.org/t/half-von-mises-distribution-wrapped-on-half-circle/10958)) and through asking some other people, that I could just multiply my angle by 2 to get it to wrap on a half-circle. I'm not sure I understand this, surely I would divide the angle by two? I wondered if you had any remarks on this, but I acknowledge that you've already given a great answer! – mscone Aug 18 '20 at 09:58
  • 1
    @mscone `Thanks for such a detailed response` Wel, you could accept the answer, or bump it or both. I read the discussion and always of such analogies. You don't have von Mises distribution, period. Any thing which you think might be taken from von Mises, or anyone else claim it will work because it was working for von Mises, are to be TESTED. This is actually the beauty of code I wrote. F.e. JohanC proposed to mirror angles - ok, mirror it and plot against numeric PDF, do Q-Q plot, K-S test, etc. If someone wants to divide angle (yes, halve the angles) - try it and plot against numeric PDF. – Severin Pappadeux Aug 19 '20 at 03:01
  • 1
    And concerning all proposal: I think, halving angles won't work. I think mirroring/adding/subtracting by JohanC won't work. I think acceptance/rejection will work. But you no could safely test all those possibilities for wide range of parameters (mu, kappa) and see how it is all going. Even when someone will bring you magic box claiming it generates RVs according to your desired distribution, you could check it – Severin Pappadeux Aug 19 '20 at 03:04
  • You make a good point that I can test them all, and I have been. I'm just a bit unfamiliar with the territory and requested some guidance. Thanks for your code, assistance, and time. – mscone Aug 19 '20 at 09:54
1

You could discard the values outside the desired range via numpy's filtering (theta=theta[(theta>=0)&(theta<=np.pi)], shortening the array of samples). So, you could first increment the number of generated samples, then filter and then take a subarray of the desired size.

Or you could add/subtract pi to put them all into that range (via theta = np.where(theta < 0, theta + np.pi, np.where(theta > np.pi, theta - np.pi, theta))). As noted by @SeverinPappadeux such changes the distribution and is probably not desired.

import matplotlib.pyplot as plt
from matplotlib.collections import LineCollection
import numpy as np
from scipy.stats import vonmises

mu = np.pi / 2
kappa = 4

orig_theta = vonmises.rvs(kappa, loc=mu, size=(10000))
fig, axes = plt.subplots(ncols=2, sharex=True, sharey=True, figsize=(12, 4))
for ax in axes:
    theta = orig_theta.copy()
    if ax == axes[0]:
        ax.set_title(f"$Von Mises, \\mu={mu:.2f}, \\kappa={kappa}$")
    else:
        theta = theta[(theta >= 0) & (theta <= np.pi)]
        print(len(theta))
        ax.set_title(f"$Von Mises, angles\\ filtered\\ ({100 * len(theta) / (len(orig_theta)):.2f}\\ \\%)$")
    segs = np.zeros((len(theta), 2, 2))
    segs[:, 1, 0] = np.cos(theta)
    segs[:, 1, 1] = np.sin(theta)
    line_segments = LineCollection(segs, linewidths=.1, colors='blue', alpha=0.5)
    ax.add_collection(line_segments)
    ax.autoscale()
    ax.set_aspect('equal')
plt.show()

comparison plot

JohanC
  • 71,591
  • 8
  • 33
  • 66
  • 1
    Adding/substracting is wrong, I believe. It won't follow von Mises shape anymore – Severin Pappadeux Aug 17 '20 at 16:21
  • Yes, you're right, the tails would get too fat. I wrongly assumed they would distribute similarly. I'll update the answer. – JohanC Aug 17 '20 at 16:44
  • Thanks for such a detailed reply! I was hoping there was a simple way to alter the equation to wrap around on a half-turn. I forgot to mention, the output must always be the same number of elements. Would it be appropriate to use rejection sampling? Ask for a value from vonmises.rvs, then accept or reject it depending on whether it's within a pi/2 distance from the desired mean? – mscone Aug 17 '20 at 16:50
  • 1
    For speed considerations, I really would generate too many samples and then take a subsample. `theta = vonmises.rvs(kappa, loc=mu, size=(15000))` and then `theta = theta[(theta >= 0) & (theta <= np.pi)][:10000]`. – JohanC Aug 17 '20 at 17:03