Repository: scipy/scipy

Issue:
ENH: stats: `rv_continuous.stats` with array shapes: use `_stats` when possible and `_munp` otherwise?
After some input standardization, `rv_continuous.stats` calls:
```python3
mu, mu2, g1, g2 = self._stats(*goodargs)
```

For those outputs that are `None`, it uses `_munp` instead to calculate the moment.
e.g.:
```python3
if mu is None:
    mu = self._munp(1, *goodargs)
```

The problem is that the shape parameters can be arrays, and so `mu` could be an array. For some distributions (e.g. `kappa3`, `kappa4`), `_stats` can calculate the moments for some values of the shape parameters. However, `_stats` has only two options:

- return the moments for all values in the array of shapes or
- return `None` and let `_munp` calculate the moments.

It's all or nothing. At best this is inefficient, but it can also lead to other problems.

For example, `kappa3._stats` is currently implemented as:
```python3
    def _stats(self, a):
        outputs = [None if i < a else np.nan for i in range(1, 5)]
        return outputs[:]
```

Currently, it doesn't consider the possibility that `a` could be an array, so it fails when `a` is an array:
```python3
from scipy.stats import kappa3
kappa3.stats([1.5, 2.5])  # ValueError: The truth value of an array...
```

One option to make it work with arrays is to change `i < a` to `np.any(i < a)`. But this leads to inaccurate values of the moments for the elements where `i > a`. They should be `nan`, but `_munp` tries to calculate a numerical value... and doesn't get `nan`.

That is, if we change `i < a` to `np.any(i < a)`,
```python3
kappa3.stats([0.5])  # (array([nan]), array([nan]))
kappa3.stats([0.5, 1.5])
```
results in:
```

C:\Users\matth\scipy\scipy\stats\_distn_infrastructure.py:1801: IntegrationWarning: The integral is probably divergent, or slowly convergent.
  return integrate.quad(self._mom_integ1, 0, 1, args=(m,)+args)[0]
Out[32]: (array([-13.14848276,   2.31495165]), array([nan, nan]))
```

So I don't know how we would add array shape support to `kappa3` (or `kappa4`) without a lot of changes to `rv_continuous.stats`.

Suggestions on how to deal with this? I have some thoughts, but I don't really like them.


Discussion:
To close this issue, `kappa3` and `kappa4` `stats` should work for array inputs, and these distributions should be removed from the list `fail_loc_scale` in `test_continuous_basic.py` (assuming 13581 merges).
Hi @mdhaber ,

Using the following diff:

```diff
diff --git a/scipy/stats/_continuous_distns.py b/scipy/stats/_continuous_distns.py
index 0c92eb9c9..890836515 100644
--- a/scipy/stats/_continuous_distns.py
+++ b/scipy/stats/_continuous_distns.py
@@ -5964,7 +5964,7 @@ class kappa3_gen(rv_continuous):
         return (a/(q**-a - 1.0))**(1.0/a)
 
     def _stats(self, a):
-        outputs = [None if i < a else np.nan for i in range(1, 5)]
+        outputs = [None if np.any(i < a) else np.nan for i in range(1, 5)]
         return outputs[:]
 
 
diff --git a/scipy/stats/_distn_infrastructure.py b/scipy/stats/_distn_infrastructure.py
index 0d7d958c7..949004686 100644
--- a/scipy/stats/_distn_infrastructure.py
+++ b/scipy/stats/_distn_infrastructure.py
@@ -1857,6 +1857,8 @@ class rv_continuous(rv_generic):
         return (self.ppf(q, *args))**m
 
     def _mom1_sc(self, m, *args):
+        if np.any(np.array(args) >= m):
+            return np.nan
         return integrate.quad(self._mom_integ1, 0, 1, args=(m,)+args)[0]
```

I get these results:

```py
>>> from scipy.stats import kappa3
>>> kappa3.stats([0.5, 1.5])
(array([-13.14848276,          nan]), array([nan, nan]))
>>> kappa3.stats([0.5]) 
(array([nan]), array([nan]))
```
Any suggestions to improve or this works well?

@Smit-create thanks for looking into this. I left a comment in the PR for you.

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
@@ -5289,7 +5289,8 @@ class kappa4_gen(rv_continuous):
 
     """
     def _argcheck(self, h, k):
-        return h == h
+        shape = np.broadcast_arrays(h, k)[0].shape
+        return np.full(shape, fill_value=True)
 
     def _get_support(self, h, k):
         condlist = [np.logical_and(h > 0, k > 0),
@@ -5438,19 +5439,31 @@ def f3(q, h, k):
                            [q, h, k],
                            default=np.nan)
 
-    def _stats(self, h, k):
-        if h >= 0 and k >= 0:
-            maxr = 5
-        elif h < 0 and k >= 0:
-            maxr = int(-1.0/h*k)
-        elif k < 0:
-            maxr = int(-1.0/k)
-        else:
-            maxr = 5
+    def _get_stats_info(self, h, k):
+        condlist = [
+            np.logical_and(h < 0, k >= 0),
+            k < 0,
+        ]
+
+        def f0(h, k):
+            return (-1.0/h*k).astype(int)
+
+        def f1(h, k):
+            return (-1.0/k).astype(int)
 
-        outputs = [None if r < maxr else np.nan for r in range(1, 5)]
+        return _lazyselect(condlist, [f0, f1], [h, k], default=5)
+
+    def _stats(self, h, k):
+        maxr = self._get_stats_info(h, k)
+        outputs = [None if np.any(r < maxr) else np.nan for r in range(1, 5)]
         return outputs[:]
 
+    def _mom1_sc(self, m, *args):
+        maxr = self._get_stats_info(args[0], args[1])
+        if m >= maxr:
+            return np.nan
+        return integrate.quad(self._mom_integ1, 0, 1, args=(m,)+args)[0]
+
 
 kappa4 = kappa4_gen(name='kappa4')
 


Test patch:
diff --git a/scipy/stats/tests/test_continuous_basic.py b/scipy/stats/tests/test_continuous_basic.py
--- a/scipy/stats/tests/test_continuous_basic.py
+++ b/scipy/stats/tests/test_continuous_basic.py
@@ -247,36 +247,33 @@ def test_levy_stable_random_state_property():
 def cases_test_moments():
     fail_normalization = set(['vonmises'])
     fail_higher = set(['vonmises', 'ncf'])
-    fail_loc_scale = set(['kappa4'])  # see gh-13582
 
     for distname, arg in distcont[:] + [(histogram_test_instance, tuple())]:
         if distname == 'levy_stable':
             continue
 
         if distname in distxslow_test_moments:
-            yield pytest.param(distname, arg, True, True, True, True,
+            yield pytest.param(distname, arg, True, True, True,
                                marks=pytest.mark.xslow(reason="too slow"))
             continue
 
         cond1 = distname not in fail_normalization
         cond2 = distname not in fail_higher
-        cond3 = distname not in fail_loc_scale
 
-        yield distname, arg, cond1, cond2, cond3, False
+        yield distname, arg, cond1, cond2, False
 
-        if not cond1 or not cond2 or not cond3:
+        if not cond1 or not cond2:
             # Run the distributions that have issues twice, once skipping the
             # not_ok parts, once with the not_ok parts but marked as knownfail
-            yield pytest.param(distname, arg, True, True, True, True,
+            yield pytest.param(distname, arg, True, True, True,
                                marks=pytest.mark.xfail)
 
 
 @pytest.mark.slow
 @pytest.mark.parametrize('distname,arg,normalization_ok,higher_ok,'
-                         'loc_scale_ok,is_xfailing',
+                         'is_xfailing',
                          cases_test_moments())
-def test_moments(distname, arg, normalization_ok, higher_ok, loc_scale_ok,
-                 is_xfailing):
+def test_moments(distname, arg, normalization_ok, higher_ok, is_xfailing):
     try:
         distfn = getattr(stats, distname)
     except TypeError:
@@ -300,9 +297,6 @@ def test_moments(distname, arg, normalization_ok, higher_ok, loc_scale_ok,
             check_var_expect(distfn, arg, m, v, distname)
             check_kurt_expect(distfn, arg, m, v, k, distname)
 
-        if loc_scale_ok:
-            check_loc_scale(distfn, arg, m, v, distname)
-
         check_moment(distfn, arg, m, v, distname)
 
 
@@ -823,11 +817,36 @@ def test_broadcasting_in_moments_gh12192_regression():
     npt.assert_allclose(vals3, expected3, rtol=1e-8)
     assert vals3.shape == expected3.shape
 
+
 def test_kappa3_array_gh13582():
     # https://github.com/scipy/scipy/pull/15140#issuecomment-994958241
     shapes = [0.5, 1.5, 2.5, 3.5, 4.5]
     moments = 'mvsk'
-    res = np.array([[stats.kappa3.stats(shape, moments=moment) for shape in shapes]
-                    for moment in moments])
+    res = np.array([[stats.kappa3.stats(shape, moments=moment)
+                   for shape in shapes] for moment in moments])
     res2 = np.array(stats.kappa3.stats(shapes, moments=moments))
     npt.assert_allclose(res, res2)
+
+
+def test_kappa4_array_gh13582():
+    h = np.array([-0.5, 2.5, 3.5, 4.5, -3])
+    k = np.array([-0.5, 1, -1.5, 0, 3.5])
+    moments = 'mvsk'
+    res = np.array([[stats.kappa4.stats(h[i], k[i], moments=moment)
+                   for i in range(5)] for moment in moments])
+    res2 = np.array(stats.kappa4.stats(h, k, moments=moments))
+    npt.assert_allclose(res, res2)
+
+    # https://github.com/scipy/scipy/pull/15250#discussion_r775112913
+    h = np.array([-1, -1/4, -1/4, 1, -1, 0])
+    k = np.array([1, 1, 1/2, -1/3, -1, 0])
+    res = np.array([[stats.kappa4.stats(h[i], k[i], moments=moment)
+                   for i in range(6)] for moment in moments])
+    res2 = np.array(stats.kappa4.stats(h, k, moments=moments))
+    npt.assert_allclose(res, res2)
+
+    # https://github.com/scipy/scipy/pull/15250#discussion_r775115021
+    h = np.array([-1, -0.5, 1])
+    k = np.array([-1, -0.5, 0, 1])[:, None]
+    res2 = np.array(stats.kappa4.stats(h, k, moments=moments))
+    assert res2.shape == (4, 4, 3)