Repository: scipy/scipy

Issue:
ENH: Support Random Unit Vector
### Is your feature request related to a problem? Please describe.

### Proposed new feature or change:

Description
========

Often you want to randomize a "direction" that doesn't have "size". This can be useful when:
* You want to randomize the direction of an NPC walk in a 2D or 3D games, where its speed is a predefined constant.
* You're developing particles simulation with all particles moving in random directions
* etc.

This random direction should be an `n`th dimensional unit vector which is randomized **uniformly** from the unit sphere. That means that sections of the unit sphere with equal areas should have the same chance of getting a vector from.

Implementation
===========

If one wants to randomize `n`th dimensional direction uniformly, one must do the following:
1. Generate `n` components of the vector using the standard normal distribution
2. Find the norm the generated vector
3. Divide the vector by its norm

In simple Python code:
```python
import numpy as np
from scipy import stats


def sphere_uniform(n: int):
    vec = stats.norm.rvs(size=n)
    size = np.linalg.norm(vec)
    return vec / size
```

This implementation suggestion is based on [this blog post](https://towardsdatascience.com/the-best-way-to-pick-a-unit-vector-7bd0cc54f9b). I'm not sure if there's a faster way of implementing it using C++ and Cython, but I'm confident that someone here will know.

Why Implement It in *Scipy*?
===================

Originally, I suggested implementing it on *Numpy* (numpy/numpy#21523), but they stated that they are not interested in expending the `numpy.random` library and suggested adding this new feature as part of `scipy.stats`.

I believe that random unit vectors are common enough to be a part of *Numpy* or *Scipy*, which are two of the most popular python libraries (if not the most popular ones). Scientists, gamers and engineers use random directions all the time, and I believe there is no reason why this ability shouldn't be provided out-of-the-box.


Discussion:
Seems a bit too specific to be on its own as a function to be honest. The following is sufficient (similar to what you have instead you want a uniform distribution, you can also center around 0. by subtracting 0.5 from `vec`):

```python
import numpy as np

def unit_vector(n: int):
    rng = np.default_rng()
    vec = rng.random(n)
    vec /= np.linalg.norm(vec)
    return vec
```

It gets even shorter if you don't mind using `np.random.rand(n)` directly. Hence I think this is a bearable burden for any use. 

Hey @ilayn , thank you for the quick response.

I respectfully disagree. I've encountered the need for randomizing a unit vector uniformly many times for the past few years during my physics degree, and in my job as an ML team lead. This is a quite needed ability that I'm sure people would be happy to have here at *Scipy*.

As for the implementation you've suggested, the problem with it that there's a tendency of the random vector to be in the diagonal line between the origin and the corner of the unit cube. This is very elegnatly explained in the blog post I've linked above.

That means that the distribution in the implementation you've suggested is not uniform as most users would assume it is. This is the reason one must use **normal** distribution to randomize the components of the vector and not **uniform** distribution between -1 and 1.

As many others thing, this is not something that is a "must have", we agree on that. People could implement this using one of the implementations you and I suggested.
I have quite some technical issues with that blogpost but regardless of the distribution which involves no additional code for changing it normal distribution, this is still a two-liner code that I would prefer burdening the user instead of maintaining within scipy or numpy. 
Uniformly-random vectors on the unit hypersphere is an important multivariate distribution. It's not _hard_ to implement oneself, _if_ one knows the necessary trick (and yes, the normal distribution _is_ necessary). If one doesn't know the appropriate trick, ad hoc implementations tend to fail in ways that are hard to diagnose in higher dimensions than 2.
What could be a good API for this?
Sth like a distribution `scipy.stats.UniformUnitSphere` with method `rvs` or just a function `scipy.stats.randomunitvector(size)` ? 
The former, I think. It would fit well as a subclass of `multi_rv_generic` in [`_multivariate.py`](https://github.com/scipy/scipy/blob/main/scipy/stats/_multivariate.py).
Good to see that other people find this feature needed and usefull.

Do I have a go on implementing it?
I asked on the mailing list if there are any objections against this and a function for spherical mean/variance. If there are none , I think it can be taken as a yes in a few days.
I think that's true, @dschmitz89.

@saroad2 One thing to look out for is to not start with the shortcomings we're addressing in gh-16278. If you use another multivariate distribution as a reference, you may be tempted to add `np.squeeze` to the output of `rvs`. This is undesirable. If `d` is the number of dimensions and `size` is passed in as a shape parameter (or `()` if `size is None`), then the output shape should be `size + (d,)`. (I think it's OK to support only scalar `d` for now because there is a decision to be made w.r.t. [how to vectorize over array shapes](https://github.com/scipy/scipy/issues/7689#issuecomment-1140394967).) 
What's the difference between `multi_rv_generic` and `multi_rv_frozen`? I didn't understance what the *frozen* stands for.
`multi_rv_generic` instances have methods that take the distribution parameters. One instance of the `multi_rv_generic` is made at the module level.

```python
stats.multivariate_normal.pdf(x, mean, cov)
```

`multi_rv_frozen` instances are created by calling the `multi_rv_generic` instance with the distribution parameters. The frozen instance then carries those distribution parameters around so that you can call the methods without providing the distribution parameters.

```python
my_norm = stats.multivariate_normal(mean, cov)
my_norm.pdf(x)
```

This scheme is relatively complicated for the multivariate distributions because each one is fairly different from the others. The univariate distributions have much more shared infrastructure and only need to implement the `generic` class. They all share a `frozen` implementation.
@saroad2 are you still interested in implementing this? If you do, I can review and merge.
Hey @mdhaber.

After many hours of trying, I could not build *Scipy* on my Windows environment. It was really frustrating.
Unfortunately, I don't have access to a Linux or Mac environments, so I cannot implement this ability for now.

Nevertheless, I would love to see it get implemented! It should be quite easy once you get to build *Scipy*...
Yeah that's the trick. I develop on Windows, too, and it was really difficult to get it working the first time. Have you followed [these instructions](http://scipy.github.io/devdocs/dev/contributor/windows.html#build-windows), including building OpenBLAS?

In the meantime, you could consider using [Gitpod](http://scipy.github.io/devdocs/dev/contributor/quickstart_gitpod.html#quickstart-gitpod) so you don't need to worry about building locally.

Also, building is supposed to become easier soon with this new Meson build system, but I'm not sure if it's working on Windows yet. (Perhaps others will chime in?) I'll let you know when I've been successful with that.

@saroad2 I am using the Windows Subsystem for Linux on Windows 11. Works like a charm there with the instructions for conda given [here](https://docs.scipy.org/doc/scipy/dev/dev_quickstart.html) . I admit, I have not even tried to build using windows directly. Anything related to complex compiled code on Windows has always been very painful for me too (Fortran ... ).
There is a discussion about building on windows in #16769 . Wondering if this is a blocking point for many potential contributors.

In case you cannot find the time, I would be able to take this over. 
Hey @dschmitz89 , thanks for the feedback!

In the following weeks I will not be able to work on this. A little bit preoccupied with other things.

I would love if you could take over so I won't block it.

Thanks!

Resolving patch:
diff --git a/scipy/stats/__init__.py b/scipy/stats/__init__.py
--- a/scipy/stats/__init__.py
+++ b/scipy/stats/__init__.py
@@ -174,6 +174,7 @@
    multivariate_t         -- Multivariate t-distribution
    multivariate_hypergeom -- Multivariate hypergeometric distribution
    random_table           -- Distribution of random tables with given marginals
+   uniform_direction      -- Uniform distribution on S(N-1)
 
 `scipy.stats.multivariate_normal` methods accept instances
 of the following class to represent the covariance.
diff --git a/scipy/stats/_multivariate.py b/scipy/stats/_multivariate.py
--- a/scipy/stats/_multivariate.py
+++ b/scipy/stats/_multivariate.py
@@ -28,7 +28,8 @@
            'unitary_group',
            'multivariate_t',
            'multivariate_hypergeom',
-           'random_table']
+           'random_table',
+           'uniform_direction']
 
 _LOG_2PI = np.log(2 * np.pi)
 _LOG_2 = np.log(2)
@@ -5524,3 +5525,169 @@ def _docfill(obj, docdict, template=None):
     method_frozen = random_table_frozen.__dict__[name]
     _docfill(method_frozen, _ctab_docdict_frozen, method.__doc__)
     _docfill(method, _ctab_docdict)
+
+
+class uniform_direction_gen(multi_rv_generic):
+    r"""A vector-valued uniform direction.
+
+    Return a random direction (unit vector).
+
+    The `dim` keyword specifies the dimension.
+
+    Methods
+    -------
+    rvs(dim=None, size=1, random_state=None)
+        Draw random directions.
+
+    Parameters
+    ----------
+    dim : scalar
+        Dimension of directions.
+    seed : {None, int, `numpy.random.Generator`,
+            `numpy.random.RandomState`}, optional
+
+        Used for drawing random variates.
+        If `seed` is `None`, the `~np.random.RandomState` singleton is used.
+        If `seed` is an int, a new ``RandomState`` instance is used, seeded
+        with seed.
+        If `seed` is already a ``RandomState`` or ``Generator`` instance,
+        then that object is used.
+        Default is `None`.
+
+    Notes
+    -----
+    This distribution generates unit vectors uniformly distributed on
+    the surface of a hypersphere. These can be interpreted as random
+    directions.
+    For example, if `dim` is 3, 3D vectors from the surface of :math:`S^2`
+    will be sampled.
+
+    References
+    ----------
+    .. [1] Marsaglia, G. (1972). "Choosing a Point from the Surface of a
+           Sphere". Annals of Mathematical Statistics. 43 (2): 645-646.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> from scipy.stats import uniform_direction
+    >>> x = uniform_direction.rvs(3)
+    >>> np.linalg.norm(x)
+    1.
+
+    This generates one random direction, a vector on the surface of
+    :math:`S^2`.
+
+    Alternatively, the object may be called (as a function) to fix the `dim`
+    parameter, return a "frozen" `random_direction` random variable:
+
+    >>> rv = uniform_direction(5)
+
+    """
+
+    def __init__(self, seed=None):
+        super().__init__(seed)
+        self.__doc__ = doccer.docformat(self.__doc__)
+
+    def __call__(self, dim=None, seed=None):
+        """Create a frozen n-dimensional uniform direction distribution.
+
+        See `uniform_direction` for more information.
+        """
+        return uniform_direction_frozen(dim, seed=seed)
+
+    def _process_parameters(self, dim):
+        """Dimension N must be specified; it cannot be inferred."""
+        if dim is None or not np.isscalar(dim) or dim < 1 or dim != int(dim):
+            raise ValueError("Dimension of vector must be specified, "
+                             "and must be an integer greater than 0.")
+
+        return int(dim)
+
+    def rvs(self, dim, size=None, random_state=None):
+        """Draw random samples from S(N-1).
+
+        Parameters
+        ----------
+        dim : integer
+            Dimension of space (N).
+        size : int or tuple of ints, optional
+            Given a shape of, for example, (m,n,k), m*n*k samples are
+            generated, and packed in an m-by-n-by-k arrangement.
+            Because each sample is N-dimensional, the output shape
+            is (m,n,k,N). If no shape is specified, a single (N-D)
+            sample is returned.
+        random_state : {None, int, `numpy.random.Generator`,
+                        `numpy.random.RandomState`}, optional
+
+            Pseudorandom number generator state used to generate resamples.
+
+            If `random_state` is ``None`` (or `np.random`), the
+            `numpy.random.RandomState` singleton is used.
+            If `random_state` is an int, a new ``RandomState`` instance is
+            used, seeded with `random_state`.
+            If `random_state` is already a ``Generator`` or ``RandomState``
+            instance then that instance is used.
+
+        Returns
+        -------
+        rvs : ndarray
+            Random direction vectors
+
+        """
+        random_state = self._get_random_state(random_state)
+        if size is None:
+            size = np.array([], dtype=int)
+        size = np.atleast_1d(size)
+
+        dim = self._process_parameters(dim)
+
+        samples = _sample_uniform_direction(dim, size, random_state)
+        return samples
+
+
+uniform_direction = uniform_direction_gen()
+
+
+class uniform_direction_frozen(multi_rv_frozen):
+    def __init__(self, dim=None, seed=None):
+        """Create a frozen n-dimensional uniform direction distribution.
+
+        Parameters
+        ----------
+        dim : int
+            Dimension of matrices
+        seed : {None, int, `numpy.random.Generator`,
+                `numpy.random.RandomState`}, optional
+
+            If `seed` is None (or `np.random`), the `numpy.random.RandomState`
+            singleton is used.
+            If `seed` is an int, a new ``RandomState`` instance is used,
+            seeded with `seed`.
+            If `seed` is already a ``Generator`` or ``RandomState`` instance
+            then that instance is used.
+
+        Examples
+        --------
+        >>> from scipy.stats import uniform_direction
+        >>> x = uniform_direction(3)
+        >>> x.rvs()
+
+        """
+        self._dist = uniform_direction_gen(seed)
+        self.dim = self._dist._process_parameters(dim)
+
+    def rvs(self, size=None, random_state=None):
+        return self._dist.rvs(self.dim, size, random_state)
+
+
+def _sample_uniform_direction(dim, size, random_state):
+    """
+    Private method to generate uniform directions
+    Reference: Marsaglia, G. (1972). "Choosing a Point from the Surface of a
+               Sphere". Annals of Mathematical Statistics. 43 (2): 645-646.
+    """
+    samples_shape = np.append(size, dim)
+    samples = random_state.standard_normal(samples_shape)
+    samples /= np.linalg.norm(samples, axis=-1, keepdims=True)
+    return samples


Test patch:
diff --git a/scipy/stats/tests/test_multivariate.py b/scipy/stats/tests/test_multivariate.py
--- a/scipy/stats/tests/test_multivariate.py
+++ b/scipy/stats/tests/test_multivariate.py
@@ -26,7 +26,7 @@
                          beta, wishart, multinomial, invwishart, chi2,
                          invgamma, norm, uniform, ks_2samp, kstest, binom,
                          hypergeom, multivariate_t, cauchy, normaltest,
-                         random_table)
+                         random_table, uniform_direction)
 from scipy.stats import _covariance, Covariance
 
 from scipy.integrate import romb
@@ -1975,6 +1975,62 @@ def test_to_corr(self):
         assert_allclose(m[0,0], 1)
 
 
+class TestUniformDirection:
+    @pytest.mark.parametrize("dim", [1, 3])
+    @pytest.mark.parametrize("size", [None, 1, 5, (5, 4)])
+    def test_samples(self, dim, size):
+        # test that samples have correct shape and norm 1
+        rng = np.random.default_rng(2777937887058094419)
+        uniform_direction_dist = uniform_direction(dim, seed=rng)
+        samples = uniform_direction_dist.rvs(size)
+        mean, cov = np.zeros(dim), np.eye(dim)
+        expected_shape = rng.multivariate_normal(mean, cov, size=size).shape
+        assert samples.shape == expected_shape
+        norms = np.linalg.norm(samples, axis=-1)
+        assert_allclose(norms, 1.)
+
+    @pytest.mark.parametrize("dim", [None, 0, (2, 2), 2.5])
+    def test_invalid_dim(self, dim):
+        message = ("Dimension of vector must be specified, "
+                   "and must be an integer greater than 0.")
+        with pytest.raises(ValueError, match=message):
+            uniform_direction.rvs(dim)
+
+    def test_frozen_distribution(self):
+        dim = 5
+        frozen = uniform_direction(dim)
+        frozen_seed = uniform_direction(dim, seed=514)
+
+        rvs1 = frozen.rvs(random_state=514)
+        rvs2 = uniform_direction.rvs(dim, random_state=514)
+        rvs3 = frozen_seed.rvs()
+
+        assert_equal(rvs1, rvs2)
+        assert_equal(rvs1, rvs3)
+
+    @pytest.mark.parametrize("dim", [2, 5, 8])
+    def test_uniform(self, dim):
+        rng = np.random.default_rng(1036978481269651776)
+        spherical_dist = uniform_direction(dim, seed=rng)
+        # generate random, orthogonal vectors
+        v1, v2 = spherical_dist.rvs(size=2)
+        v2 -= v1 @ v2 * v1
+        v2 /= np.linalg.norm(v2)
+        assert_allclose(v1 @ v2, 0, atol=1e-14)  # orthogonal
+        # generate data and project onto orthogonal vectors
+        samples = spherical_dist.rvs(size=10000)
+        s1 = samples @ v1
+        s2 = samples @ v2
+        angles = np.arctan2(s1, s2)
+        # test that angles follow a uniform distribution
+        # normalize angles to range [0, 1]
+        angles += np.pi
+        angles /= 2*np.pi
+        # perform KS test
+        uniform_dist = uniform()
+        kstest_result = kstest(angles, uniform_dist.cdf)
+        assert kstest_result.pvalue > 0.05
+
 class TestUnitaryGroup:
     def test_reproducibility(self):
         np.random.seed(514)