Repository: scipy/scipy

Issue:
Spherical mean/variance
**Is your feature request related to a problem? Please describe.**

Currently, scipy has functions to calculate the circular mean and variance but no analogue for spherical data.

**Describe the solution you'd like**

I am currently implementing a library for spherical statistics (https://github.com/dschmitz89/spherical_stats) and recently added functions for spherical mean and variance. Actually, they are quite simple, short and only require numpy, so they could also become a part of scipy.stats without too much effort. 

If you think this is interesting, I would work on a PR.


Discussion:
I asked here first because I understood the developer guidelines that way that it should be discussed first if a new method is important enough to become a part of scipy and if some core devs would be potentially interested in reviewing it.
@dschmitz89 Did you email the [scipy-dev mailing list](https://www.scipy.org/scipylib/mailing-lists.html)? That might help attract some attention to this. You might include some examples of how they are used to help others decide whether they are sufficiently general in use to be included.
@mdhaber : thanks for that suggestion, I will open an Email thread soon.

Regarding #12201 : I think building a new submodule 'spherical' is far beyond the scope of what I wanted to contribute. I just thought of adding two equivalents for circmean and circvar: sphermean and sphervar. I think those would already be helpful to many people for some very basic descriptive statistics of vector data.
Did this ever make it to the mailing list @dschmitz89? Are you still interested?
Oops, now it did. Lost track of this before apparently.
Directional mean is done, but variance is not.

Resolving patch:
diff --git a/scipy/stats/__init__.py b/scipy/stats/__init__.py
--- a/scipy/stats/__init__.py
+++ b/scipy/stats/__init__.py
@@ -423,7 +423,7 @@
 .. autosummary::
    :toctree: generated/
 
-   directionalmean
+   directional_stats
    circmean
    circvar
    circstd
diff --git a/scipy/stats/_morestats.py b/scipy/stats/_morestats.py
--- a/scipy/stats/_morestats.py
+++ b/scipy/stats/_morestats.py
@@ -33,7 +33,7 @@
            'fligner', 'mood', 'wilcoxon', 'median_test',
            'circmean', 'circvar', 'circstd', 'anderson_ksamp',
            'yeojohnson_llf', 'yeojohnson', 'yeojohnson_normmax',
-           'yeojohnson_normplot', 'directionalmean'
+           'yeojohnson_normplot', 'directional_stats'
            ]
 
 
@@ -4045,14 +4045,32 @@ def circstd(samples, high=2*pi, low=0, axis=None, nan_policy='propagate', *,
     return res
 
 
-def directionalmean(samples, *, axis=0, normalize=True):
+class DirectionalStats:
+    def __init__(self, mean_direction, mean_resultant_length):
+        self.mean_direction = mean_direction
+        self.mean_resultant_length = mean_resultant_length
+
+    def __repr__(self):
+        return (f"DirectionalStats(mean_direction={self.mean_direction},"
+                f" mean_resultant_length={self.mean_resultant_length})")
+
+
+def directional_stats(samples, *, axis=0, normalize=True):
     """
-    Computes the directional mean of a sample of vectors.
+    Computes sample statistics for directional data.
+
+    Computes the directional mean (also called the mean direction vector) and
+    mean resultant length of a sample of vectors.
 
     The directional mean is a measure of "preferred direction" of vector data.
-    It is analogous to the sample mean, but it is for use when the magnitude of
+    It is analogous to the sample mean, but it is for use when the length of
     the data is irrelevant (e.g. unit vectors).
 
+    The mean resultant length is a value between 0 and 1 used to quantify the
+    dispersion of directional data: the smaller the mean resultant length, the
+    greater the dispersion. Several definitions of directional variance
+    involving the mean resultant length are given in [1]_ and [2]_.
+
     Parameters
     ----------
     samples : array_like
@@ -4069,12 +4087,18 @@ def directionalmean(samples, *, axis=0, normalize=True):
 
     Returns
     -------
-    directionalmean : ndarray
-        Directional mean.
+    res : DirectionalStats
+        An object containing attributes:
+
+        mean_direction : ndarray
+            Directional mean.
+        mean_resultant_length : ndarray
+            The mean resultant length [1]_.
 
     See also
     --------
     circmean: circular mean; i.e. directional mean for 2D *angles*
+    circvar: circular variance; i.e. directional variance for 2D *angles*
 
     Notes
     -----
@@ -4083,26 +4107,36 @@ def directionalmean(samples, *, axis=0, normalize=True):
 
     .. code-block:: python
 
-        mean=samples.mean(axis=0)
-        directionalmean = mean/np.linalg.norm(mean)
+        mean = samples.mean(axis=0)
+        mean_resultant_length = np.linalg.norm(mean)
+        mean_direction = mean / mean_resultant_length
 
     This definition is appropriate for *directional* data (i.e. vector data
     for which the magnitude of each observation is irrelevant) but not
     for *axial* data (i.e. vector data for which the magnitude and *sign* of
     each observation is irrelevant).
 
+    Several definitions of directional variance involving the mean resultant
+    length ``R`` have been proposed, including ``1 - R`` [1]_, ``1 - R**2``
+    [2]_, and ``2 * (1 - R)`` [2]_. Rather than choosing one, this function
+    returns ``R`` as attribute `mean_resultant_length` so the user can compute
+    their preferred measure of dispersion.
+
     References
     ----------
     .. [1] Mardia, Jupp. (2000). *Directional Statistics*
        (p. 163). Wiley.
 
+    .. [2] https://en.wikipedia.org/wiki/Directional_statistics
+
     Examples
     --------
     >>> import numpy as np
-    >>> from scipy.stats import directionalmean
+    >>> from scipy.stats import directional_stats
     >>> data = np.array([[3, 4],    # first observation, 2D vector space
     ...                  [6, -8]])  # second observation
-    >>> directionalmean(data)
+    >>> dirstats = directional_stats(data)
+    >>> dirstats.mean_direction
     array([1., 0.])
 
     In contrast, the regular sample mean of the vectors would be influenced
@@ -4112,12 +4146,13 @@ def directionalmean(samples, *, axis=0, normalize=True):
     >>> data.mean(axis=0)
     array([4.5, -2.])
 
-    An exemplary use case for `directionalmean` is to find a *meaningful*
+    An exemplary use case for `directional_stats` is to find a *meaningful*
     center for a set of observations on a sphere, e.g. geographical locations.
 
     >>> data = np.array([[0.8660254, 0.5, 0.],
     ...                  [0.8660254, -0.5, 0.]])
-    >>> directionalmean(data)
+    >>> dirstats = directional_stats(data)
+    >>> dirstats.mean_direction
     array([1., 0., 0.])
 
     The regular sample mean on the other hand yields a result which does not
@@ -4126,6 +4161,14 @@ def directionalmean(samples, *, axis=0, normalize=True):
     >>> data.mean(axis=0)
     array([0.8660254, 0., 0.])
 
+    The function also returns the mean resultant length, which
+    can be used to calculate a directional variance. For example, using the
+    definition ``Var(z) = 1 - R`` from [2]_ where ``R`` is the
+    mean resultant length, we can calculate the directional variance of the
+    vectors in the above example as:
+
+    >>> 1 - dirstats.mean_resultant_length
+    0.13397459716167093
     """
     samples = np.asarray(samples)
     if samples.ndim < 2:
@@ -4136,5 +4179,7 @@ def directionalmean(samples, *, axis=0, normalize=True):
         vectornorms = np.linalg.norm(samples, axis=-1, keepdims=True)
         samples = samples/vectornorms
     mean = np.mean(samples, axis=0)
-    directional_mean = mean / np.linalg.norm(mean, axis=-1, keepdims=True)
-    return directional_mean
+    mean_resultant_length = np.linalg.norm(mean, axis=-1, keepdims=True)
+    mean_direction = mean / mean_resultant_length
+    return DirectionalStats(mean_direction,
+                            mean_resultant_length.squeeze(-1)[()])


Test patch:
diff --git a/scipy/stats/tests/test_morestats.py b/scipy/stats/tests/test_morestats.py
--- a/scipy/stats/tests/test_morestats.py
+++ b/scipy/stats/tests/test_morestats.py
@@ -2573,15 +2573,11 @@ def test_result(self, correction):
         assert_equal((res.statistic, res.pvalue, res.median, res.table), res)
 
 
-class TestDirectionalFuncs:
+class TestDirectionalStats:
     # Reference implementations are not available
-    def test_directionalmean_correctness(self):
+    def test_directional_stats_correctness(self):
         # Data from Fisher: Dispersion on a sphere, 1953 and
         # Mardia and Jupp, Directional Statistics.
-        # For x coordinate, result for spherical mean does not agree with
-        # Mardia & Jupp, probably due to rounding errors in reference, that's
-        # why the first entry of the spherical mean reference is different
-        # than in the book.
 
         decl = -np.deg2rad(np.array([343.2, 62., 36.9, 27., 359.,
                                      5.7, 50.4, 357.6, 44.]))
@@ -2592,30 +2588,46 @@ def test_directionalmean_correctness(self):
                          np.sin(incl)),
                         axis=1)
 
-        directional_mean = stats.directionalmean(data)
-        mean_rounded = np.round(directional_mean, 3)
+        dirstats = stats.directional_stats(data)
+        directional_mean = dirstats.mean_direction
+        mean_rounded = np.round(directional_mean, 4)
 
-        reference_mean = np.array([0.298, -0.135, -0.945])
+        reference_mean = np.array([0.2984, -0.1346, -0.9449])
         assert_allclose(mean_rounded, reference_mean)
 
-    def test_directionalmean_2d(self):
-        # Test that for circular data directionalmean yields
-        # same result as circmean
+    @pytest.mark.parametrize('angles, ref', [
+        ([-np.pi/2, np.pi/2], 1.),
+        ([0, 2*np.pi], 0.)
+    ])
+    def test_directional_stats_2d_special_cases(self, angles, ref):
+        if callable(ref):
+            ref = ref(angles)
+        data = np.stack([np.cos(angles), np.sin(angles)], axis=1)
+        res = 1 - stats.directional_stats(data).mean_resultant_length
+        assert_allclose(res, ref)
+
+    def test_directional_stats_2d(self):
+        # Test that for circular data directional_stats
+        # yields the same result as circmean/circvar
         rng = np.random.default_rng(0xec9a6899d5a2830e0d1af479dbe1fd0c)
         testdata = 2 * np.pi * rng.random((1000, ))
         testdata_vector = np.stack((np.cos(testdata),
                                     np.sin(testdata)),
                                    axis=1)
-        directional_mean = stats.directionalmean(testdata_vector)
+        dirstats = stats.directional_stats(testdata_vector)
+        directional_mean = dirstats.mean_direction
         directional_mean_angle = np.arctan2(directional_mean[1],
                                             directional_mean[0])
         directional_mean_angle = directional_mean_angle % (2*np.pi)
-
         circmean = stats.circmean(testdata)
         assert_allclose(circmean, directional_mean_angle)
 
-    def test_directionalmean_higher_dim(self):
-        # test that directionalmean works for higher dimensions
+        directional_var = 1 - dirstats.mean_resultant_length
+        circular_var = stats.circvar(testdata)
+        assert_allclose(directional_var, circular_var)
+
+    def test_directional_mean_higher_dim(self):
+        # test that directional_stats works for higher dimensions
         # here a 4D array is reduced over axis = 2
         data = np.array([[0.8660254, 0.5, 0.],
                          [0.8660254, -0.5, 0.]])
@@ -2624,42 +2636,38 @@ def test_directionalmean_higher_dim(self):
                               [1., 0., 0.]],
                              [[1., 0., 0.],
                               [1., 0., 0.]]])
-        directionalmean = stats.directionalmean(full_array, axis=2)
-        assert_allclose(expected, directionalmean)
+        dirstats = stats.directional_stats(full_array, axis=2)
+        assert_allclose(expected, dirstats.mean_direction)
 
-    def test_directionalmean_list_ndarray_input(self):
+    def test_directional_stats_list_ndarray_input(self):
         # test that list and numpy array inputs yield same results
         data = [[0.8660254, 0.5, 0.], [0.8660254, -0.5, 0]]
         data_array = np.asarray(data)
-        directional_mean = stats.directionalmean(data)
-        directional_mean_array = stats.directionalmean(data_array)
-        assert_allclose(directional_mean, directional_mean_array)
+        res = stats.directional_stats(data)
+        ref = stats.directional_stats(data_array)
+        assert_allclose(res.mean_direction, ref.mean_direction)
+        assert_allclose(res.mean_resultant_length,
+                        res.mean_resultant_length)
 
-    def test_directionalmean_1d_error(self):
+    def test_directional_stats_1d_error(self):
         # test that one-dimensional data raises ValueError
         data = np.ones((5, ))
         message = (r"samples must at least be two-dimensional. "
                    r"Instead samples has shape: (5,)")
         with pytest.raises(ValueError, match=re.escape(message)):
-            stats.directionalmean(data)
+            stats.directional_stats(data)
 
-    def test_directionalmean_normalize(self):
-        # test that unit vectors get properly normalized before
-        # directionalmean calculation
+    def test_directional_stats_normalize(self):
+        # test that directional stats calculations yield same results
+        # for unnormalized input with normalize=True and normalized
+        # input with normalize=False
         data = np.array([[0.8660254, 0.5, 0.],
                          [1.7320508, -1., 0.]])
-        expected = np.array([1., 0., 0.])
-        directional_mean = stats.directionalmean(data)
-        assert_allclose(expected, directional_mean)
-
-    def test_directionalmean_normalize_false(self):
-        # test that for already normalized unit vectors
-        # normalize=False returns same result as default
-        data = np.array([[0.8660254, 0.5, 0.],
-                         [0.8660254, -0.5, 0]])
-        expected = np.array([1., 0., 0.])
-        directional_mean_default = stats.directionalmean(data)
-        directional_mean = stats.directionalmean(data,
-                                                 normalize=False)
-        assert_allclose(expected, directional_mean)
-        assert_allclose(directional_mean_default, directional_mean)
+        res = stats.directional_stats(data, normalize=True)
+        normalized_data = data / np.linalg.norm(data, axis=-1,
+                                                keepdims=True)
+        ref = stats.directional_stats(normalized_data,
+                                      normalize=False)
+        assert_allclose(res.mean_direction, ref.mean_direction)
+        assert_allclose(res.mean_resultant_length,
+                        ref.mean_resultant_length)