Repository: scipy/scipy

Issue:
ENH: override fit method for von mises
### Is your feature request related to a problem? Please describe.

The von Mises distribution is a circular distribution, so the current distribution fitting infrastructure is not ideal for it resulting in very slow and wrong fits (wrong concentration parameter).

Fortunately, fitting it is mathematically not very difficult: $\mu$ is given by the circular mean and $\kappa$ is the solution to
$$\frac{I_1(\kappa)}{I_0(\kappa)}-r=0$$
with the mean resultant length $r$ (cf. [Wikipedia](https://en.wikipedia.org/wiki/Von_Mises%E2%80%93Fisher_distribution#Concentration_parameter)).

### Describe the solution you'd like.

The fit method for vonmises should be overwritten. The following code snippet finds the parameters in principle, but I am very much confused with the loc/shape/scale parametrization used in `scipy.stats`.

```
from scipy.optimize import root_scalar
from scipy.stats import circmean, circvar
from scipy.special import i0e, i1e

mu = circmean(data)
r = 1 - circvar(data)

def solve_for_kappa(kappa):
    return i1e(kappa)/i0e(kappa) - r

root_res = root_scalar(solve_for_kappa, method="brentq",
                               bracket=(1e-8, 1e9))
if root_res.converged:
    kappa = root_res.root
```

Questions from my side:
* If neither loc/shape/scale are given, should the fit method return `mu ` and `kappa` ?
* If `loc` is given, should only `kappa `be returned?
* If `shape` is given, should only `mu` be returned?


### Describe alternatives you've considered.

_No response_

### Additional context (e.g. screenshots, GIFs)

There seems to be a lot of literature about the accurate computation of the ratio of  modified Bessel functions $\frac{I_v(x)}{I_{v-1}(x)}$ needed for the von Mises-Fisher distribution: tensorflow folks go to great length in their implementation (see [here](https://github.com/tensorflow/probability/blob/0759c57eb0306a5bb0fcc3d105bbcab9943092a5/tensorflow_probability/python/math/bessel.py#L349)). They use the following reference: http://www.jstor.com/stable/2006491

In all my tests so far in my ongoing work on the n-dimensional von Mises-Fisher distribution, I did not encounter any problems though, using exponentially scaled Bessel functions seemed to suffice also for large values.


Discussion:
Sounds like a sensible thing to do. Do you propose to make a PR?

Resolving patch:
diff --git a/scipy/stats/_continuous_distns.py b/scipy/stats/_continuous_distns.py
--- a/scipy/stats/_continuous_distns.py
+++ b/scipy/stats/_continuous_distns.py
@@ -9247,6 +9247,49 @@ def expect(self, func=None, args=(), loc=0, scale=1, lb=None, ub=None,
         return super().expect(func, args, loc,
                               scale, lb, ub, conditional, **kwds)
 
+    @_call_super_mom
+    @extend_notes_in_docstring(rv_continuous, notes="""\
+        The `scale` parameter is ignored. Fit data is assumed to represent
+        angles and will be wrapped onto the unit circle.\n\n""")
+    def fit(self, data, *args, **kwds):
+        if kwds.pop('superfit', False):
+            return super().fit(data, *args, **kwds)
+
+        data, fshape, floc, fscale = _check_fit_input_parameters(self, data,
+                                                                 args, kwds)
+        if self.a == -np.pi:
+            # vonmises line case, here the default fit method will be used
+            return super().fit(data, *args, **kwds)
+
+        # wrap data to interval [0, 2*pi]
+        data = np.mod(data, 2 * np.pi)
+
+        def find_mu(data):
+            return stats.circmean(data)
+
+        def find_kappa(data):
+            # kappa is the solution to
+            # r = I[1](kappa)/I[0](kappa)
+            #   = I[1](kappa) * exp(-kappa)/(I[0](kappa) * exp(-kappa))
+            #   = sc.i1e(kappa)/sc.i0e(kappa)
+            # where r = mean resultant length
+            r = 1 - stats.circvar(data)
+
+            def solve_for_kappa(kappa):
+                return sc.i1e(kappa)/sc.i0e(kappa) - r
+
+            root_res = root_scalar(solve_for_kappa, method="brentq",
+                                   bracket=(1e-8, 1e12))
+            return root_res.root
+
+        if floc is None:
+            floc = find_mu(data)
+            fshape = find_kappa(data)
+            return floc, fshape
+        else:
+            fshape = find_kappa(data)
+            return floc, fshape
+
 
 vonmises = vonmises_gen(name='vonmises')
 vonmises_line = vonmises_gen(a=-np.pi, b=np.pi, name='vonmises_line')


Test patch:
diff --git a/scipy/stats/tests/test_distributions.py b/scipy/stats/tests/test_distributions.py
--- a/scipy/stats/tests/test_distributions.py
+++ b/scipy/stats/tests/test_distributions.py
@@ -206,6 +206,40 @@ def test_vonmises_expect():
     assert np.issubdtype(res.dtype, np.complexfloating)
 
 
+@pytest.mark.parametrize('loc', [-0.5 * np.pi, 0, np.pi])
+@pytest.mark.parametrize('kappa', [1, 10, 100, 1000])
+def test_vonmises_fit_all(kappa, loc):
+    rng = np.random.default_rng(6762668991392531563)
+    data = stats.vonmises(loc=loc, kappa=kappa).rvs(100000, random_state=rng)
+    loc_fit, kappa_fit = stats.vonmises.fit(data)
+    loc_vec = np.array([np.cos(loc), np.sin(loc)])
+    loc_fit_vec = np.array([np.cos(loc_fit), np.sin(loc_fit)])
+    angle = np.arccos(loc_vec.dot(loc_fit_vec))
+    assert_allclose(angle, 0, atol=1e-2, rtol=0)
+    assert_allclose(kappa, kappa_fit, rtol=1e-2)
+
+
+def test_vonmises_fit_shape():
+    rng = np.random.default_rng(6762668991392531563)
+    loc = 0.25*np.pi
+    kappa = 10
+    data = stats.vonmises(loc=loc, kappa=kappa).rvs(100000, random_state=rng)
+    loc_fit, kappa_fit = stats.vonmises.fit(data, floc=loc)
+    assert loc_fit == loc
+    assert_allclose(kappa, kappa_fit, rtol=1e-2)
+
+
+def test_vonmises_fit_unwrapped_data():
+    rng = np.random.default_rng(6762668991392531563)
+    data = stats.vonmises(loc=0.5*np.pi, kappa=10).rvs(100000,
+                                                       random_state=rng)
+    shifted_data = data + 4*np.pi
+    loc_fit, kappa_fit = stats.vonmises.fit(data)
+    loc_fit_shifted, kappa_fit_shifted = stats.vonmises.fit(shifted_data)
+    assert_allclose(loc_fit, loc_fit_shifted)
+    assert_allclose(kappa_fit, kappa_fit_shifted)
+
+
 def _assert_less_or_close_loglike(dist, data, func, **kwds):
     """
     This utility function checks that the log-likelihood (computed by