Repository: scipy/scipy

Issue:
The test suite fails on Python 3.10: issue with factorial() on NaN
On Python 3.10, test_mixed_nan_inputs() of scipy.special.tests.test_basic.TestFactorialFunctions fails with:

```
                if un.size:
>                   val = math.factorial(un[0])
E                   TypeError: 'float' object cannot be interpreted as an integer
```

See the downstream Fedora issue: https://bugzilla.redhat.com/show_bug.cgi?id=1898157

I wrote PR #13121 to fix this issue.

The issue was worked around by the commit f7b6202ae69282b0942a2f5671737a85a558e1b9 for Python 3.9:

```
commit f7b6202ae69282b0942a2f5671737a85a558e1b9
Author: Ralf Gommers <ralf.gommers@gmail.com>
Date:   Sat May 9 15:31:07 2020 +0200

    TST: filter out deprecation warning for using factorial() with floats
    
    This was failing in py3.9-dev CI
```

cc @rgommers

#### Scipy/Numpy/Python version information:

* scipy  1.5.4-1.fc33 
* Python 3.10.0a2
* python3-numpy 1:1.19.4-2.fc34


Discussion:
For 1.4.0, gh-11022 discusses a change in behaviour. Comments there indicate propagating `nan`'s, but no one pointed out that the function is not documented to accept floats to begin with.

The PR that closed that issue (PR gh-11254) does discuss it a bit, quoting: _I'm not opposed to forcing input `n` to be `int` in `_factorial`, but if we do that I think it should be consistent behavior within the function. For example, currently, `math.factorial(5.1)` throws a `ValueError` but `math.factorial(5.0)` is the correct `int` returned._

gh-11579 and gh-11984 both have discussion on deprecating `nan` input to `factorial`, that seems to be preferred. Unclear why gh-11984 was closed, there's no PR referring to it. Probably due to my commit f7b6202ae6928, but that was just a quick fix to stop CI from failing.

Cc @rlucas7 @person142 @andyfaff who worked on this before.

Random side note, searching history for `factorial` we also find this little gem in `scipy._lib._util`:
```
def float_factorial(n: int) -> float:
    """Compute the factorial and return as a float

    Returns infinity when result is too large for a double
    """
    return float(math.factorial(n)) if n < 171 else np.inf
```

Also, there's `factorial2` and `factorialk` functions which do not special-case `nan`.

Proposed solution:

0. Desired behaviour is to raise an exception on float input (_all_ float input, no special-casing of `[1.0, 2.0, 3.0]`).
1. In 1.6.0, (a) give a `DeprecationWarning` for float input, and (b) simply skip the problematic test for Python >=3.10
2. For 1.8.0, change to the desired behaviour.

>For 1.4.0, gh-11022 discusses a change in behaviour. Comments there indicate propagating nan's, but no one pointed out that the function is not documented to accept floats to begin with.

`factorial` is also documented as returning `int` when `exact=True` (or `object` to avoid overflow) but in the presence of NaNs it returns a `float` array instead. The result is no longer exact and the `float` type will overflow for any `n >= 171`.
> factorial is also documented as returning int when exact=True (or object to avoid overflow) but in the presence of NaNs it returns a float array instead.

Feedback from someone completly new to numpy and scipy: I was surprised that putting a single NaN in an array of integers convert the whole array to floats. And yeah, I was confused by exact=True meaning :-) I would be less surprising if the function reject an array (raise an error) if a single value is a float if exact=True.

> In 1.6.0, (a) give a DeprecationWarning for float input, and (b) simply skip the problematic test for Python >=3.10

math.factorial() emits a DeprecationWarning on Python 3.9 if the argument is a float. See the scipy unit test which ignores the DeprecationWarning on purpose.

Yeah, skipping the whole test on Python 3.10 was my first attempt :-)
> Feedback from someone completly new to numpy and scipy: I was surprised that putting a single NaN in an array of integers convert the whole array to floats

```
>>> isinstance(np.nan, float)
True
```

numpy arrays are all of a single type (unless they're object arrays), so you can't mix int and float. The presence of a float in what is otherwise a list of ints coerces everything to a float.
I wrote PR #13130 trivial change to only skip the discussed test on Python 3.10. It would unblock me for now, and then you can decide what to do in the long term ;-)
> numpy arrays are all of a single type (unless they're object arrays), so you can't mix int and float. The presence of a float in what is otherwise a list of ints coerces everything to a float.

Sure. I understand the rationale. It's just to say that surprising that special.factorial() accepts NaN for exact=True. It looks like a bug in the API, unexpected special case.

It looks like it was a good idea that math.factorial() stops accepting float, so it forces thinking about the API that you want to provide ;-)
Skipping the test on Python >= 3.10 was done in gh-13130. Reopening this issue because we still have to deprecate the current behaviour.
Needs to be re-milestoned
Is this issue the reason that SciPy's upper bound version is [set](https://github.com/scipy/scipy/blob/master/setup.py#L555) to be "<3.10"?  Because of that limit, poetry refuses to upgrade SciPy on any project that leaves the Python version unbounded.  Poetry says:
```
   1: fact: scipy (1.7.0) requires Python >=3.7,<3.10
   1: derived: not scipy (==1.7.0)
   1: fact: scipy (1.6.3) requires Python >=3.7,<3.10
   1: derived: not scipy (==1.6.3)
   1: fact: scipy (1.6.2) requires Python >=3.7,<3.10
   1: derived: not scipy (==1.6.2)
   1: fact: scipy (1.6.0) depends on numpy (>=1.16.5)
   1: selecting scipy (1.6.0)
```
I noticed that NumPy doesn't seem to have this behaviour.
That's unrelated to this issue. Python 3.10 is simply not yet released and 1.7.0 therefore does not support it. Why is Poetry trying to use Python 3.10 already?

> Because of that limit, poetry refuses to upgrade SciPy on any project that leaves the Python version unbounded.

This seems like bad design anyway on Poetry's side. After Python 3.10.0 is released, it takes a while for projects to put up wheels that support that new Python version. So at that point Poetry will refuse to work for pretty much anyone that does not have an upper bound on Python version.

> I noticed that NumPy doesn't seem to do have this behaviour.

It will after I fix it. Currently `setup.py` says `python_requires='>=3.7',`, which is wrong on the 1.21.x maintenance branch.
@rgommers Okay, I trust you :smile:.   I hope you don't mind that I'll link your comment to the issue that I'll file with Poetry.


Resolving patch:
diff --git a/benchmarks/benchmarks/special.py b/benchmarks/benchmarks/special.py
--- a/benchmarks/benchmarks/special.py
+++ b/benchmarks/benchmarks/special.py
@@ -110,7 +110,7 @@ def time_factorial_exact_false_array_positive_float(self):
     def time_factorial_exact_false_array_negative_float(self):
         factorial(self.negative_floats, exact=False)
 
-    @with_attributes(params=[(100, 200, 400)],
+    @with_attributes(params=[(100, 200, 400, 10_000, 20_000)],
                      param_names=['n'])
     def time_factorial_exact_true_scalar_positive_int(self, n):
         factorial(n, exact=True)
@@ -118,18 +118,12 @@ def time_factorial_exact_true_scalar_positive_int(self, n):
     def time_factorial_exact_true_scalar_negative_int(self):
         factorial(-10000, exact=True)
 
-    def time_factorial_exact_true_scalar_negative_float(self):
-        factorial(-10000.8, exact=True)
-
     def time_factorial_exact_true_array_positive_int(self):
         factorial(self.positive_ints, exact=True)
 
     def time_factorial_exact_true_array_negative_int(self):
         factorial(self.negative_ints, exact=True)
 
-    def time_factorial_exact_true_array_negative_float(self):
-        factorial(self.negative_floats, exact=True)
-
 
 class Factorial2(Benchmark):
     def setup(self, *args):
@@ -150,7 +144,7 @@ def time_factorial2_exact_false_array_positive_int(self):
     def time_factorial2_exact_false_array_negative_int(self):
         factorial2(self.negative_ints, exact=False)
 
-    @with_attributes(params=[(100, 200, 400)],
+    @with_attributes(params=[(100, 200, 400, 10_000, 20_000)],
                      param_names=['n'])
     def time_factorial2_exact_true_scalar_positive_int(self, n):
         factorial2(n, exact=True)
@@ -160,7 +154,7 @@ def time_factorial2_exact_true_scalar_negative_int(self):
 
 
 class FactorialK(Benchmark):
-    @with_attributes(params=[(100, 500), range(1, 10)],
+    @with_attributes(params=[(100, 500, 10_000, 20_000), (3, 6, 9)],
                      param_names=['n', 'k'])
     def time_factorialk_exact_true_scalar_positive_int(self, n, k):
         factorialk(n, k, exact=True)
diff --git a/scipy/special/_basic.py b/scipy/special/_basic.py
--- a/scipy/special/_basic.py
+++ b/scipy/special/_basic.py
@@ -78,6 +78,14 @@
 ]
 
 
+# mapping k to last n such that factorialk(n, k) < np.iinfo(np.int64).max
+_FACTORIALK_LIMITS_64BITS = {1: 20, 2: 33, 3: 44, 4: 54, 5: 65,
+                             6: 74, 7: 84, 8: 93, 9: 101}
+# mapping k to last n such that factorialk(n, k) < np.iinfo(np.int32).max
+_FACTORIALK_LIMITS_32BITS = {1: 12, 2: 19, 3: 25, 4: 31, 5: 37,
+                             6: 43, 7: 47, 8: 51, 9: 56}
+
+
 def _nonneg_int_or_fail(n, var_name, strict=True):
     try:
         if strict:
@@ -2725,23 +2733,99 @@ def perm(N, k, exact=False):
 
 
 # https://stackoverflow.com/a/16327037
-def _range_prod(lo, hi):
+def _range_prod(lo, hi, k=1):
     """
-    Product of a range of numbers.
+    Product of a range of numbers spaced k apart (from hi).
 
-    Returns the product of
+    For k=1, this returns the product of
     lo * (lo+1) * (lo+2) * ... * (hi-2) * (hi-1) * hi
     = hi! / (lo-1)!
 
+    For k>1, it correspond to taking only every k'th number when
+    counting down from hi - e.g. 18!!!! = _range_prod(1, 18, 4).
+
     Breaks into smaller products first for speed:
     _range_prod(2, 9) = ((2*3)*(4*5))*((6*7)*(8*9))
     """
-    if lo + 1 < hi:
+    if lo + k < hi:
         mid = (hi + lo) // 2
-        return _range_prod(lo, mid) * _range_prod(mid + 1, hi)
-    if lo == hi:
-        return lo
-    return lo * hi
+        if k > 1:
+            # make sure mid is a multiple of k away from hi
+            mid = mid - ((mid - hi) % k)
+        return _range_prod(lo, mid, k) * _range_prod(mid + k, hi, k)
+    elif lo + k == hi:
+        return lo * hi
+    else:
+        return hi
+
+
+def _exact_factorialx_array(n, k=1):
+    """
+    Exact computation of factorial for an array.
+
+    The factorials are computed in incremental fashion, by taking
+    the sorted unique values of n and multiplying the intervening
+    numbers between the different unique values.
+
+    In other words, the factorial for the largest input is only
+    computed once, with each other result computed in the process.
+
+    k > 1 corresponds to the multifactorial.
+    """
+    un = np.unique(n)
+    # numpy changed nan-sorting behaviour with 1.21, see numpy/numpy#18070;
+    # to unify the behaviour, we remove the nan's here; the respective
+    # values will be set separately at the end
+    un = un[~np.isnan(un)]
+
+    # Convert to object array if np.int64 can't handle size
+    if np.isnan(n).any():
+        dt = float
+    elif k in _FACTORIALK_LIMITS_64BITS.keys():
+        if un[-1] > _FACTORIALK_LIMITS_64BITS[k]:
+            # e.g. k=1: 21! > np.iinfo(np.int64).max
+            dt = object
+        elif un[-1] > _FACTORIALK_LIMITS_32BITS[k]:
+            # e.g. k=3: 26!!! > np.iinfo(np.int32).max
+            dt = np.int64
+        else:
+            dt = np.int_
+    else:
+        # for k >= 10, we always use object
+        dt = object
+
+    out = np.empty_like(n, dtype=dt)
+
+    # Handle invalid/trivial values
+    un = un[un > 1]
+    out[n < 2] = 1
+    out[n < 0] = 0
+
+    # Calculate products of each range of numbers
+    # we can only multiply incrementally if the values are k apart;
+    # therefore we partition `un` into "lanes", i.e. its residues modulo k
+    for lane in range(0, k):
+        ul = un[(un % k) == lane] if k > 1 else un
+        if ul.size:
+            # after np.unique, un resp. ul are sorted, ul[0] is the smallest;
+            # cast to python ints to avoid overflow with np.int-types
+            val = _range_prod(1, int(ul[0]), k=k)
+            out[n == ul[0]] = val
+            for i in range(len(ul) - 1):
+                # by the filtering above, we have ensured that prev & current
+                # are a multiple of k apart
+                prev = ul[i]
+                current = ul[i + 1]
+                # we already multiplied all factors until prev; continue
+                # building the full factorial from the following (`prev + 1`);
+                # use int() for the same reason as above
+                val *= _range_prod(int(prev + 1), int(current), k=k)
+                out[n == current] = val
+
+    if np.isnan(n).any():
+        out = out.astype(np.float64)
+        out[np.isnan(n)] = np.nan
+    return out
 
 
 def factorial(n, exact=False):
@@ -2792,51 +2876,58 @@ def factorial(n, exact=False):
     120
 
     """
-    if exact:
-        if np.ndim(n) == 0:
-            if np.isnan(n):
-                return n
-            return 0 if n < 0 else math.factorial(n)
+    # don't use isscalar due to numpy/numpy#23574; 0-dim arrays treated below
+    if np.ndim(n) == 0 and not isinstance(n, np.ndarray):
+        # scalar cases
+        if n is None or np.isnan(n):
+            return np.nan
+        elif not (np.issubdtype(type(n), np.integer)
+                  or np.issubdtype(type(n), np.floating)):
+            raise ValueError(
+                f"Unsupported datatype for factorial: {type(n)}\n"
+                "Permitted data types are integers and floating point numbers"
+            )
+        elif n < 0:
+            return 0
+        elif exact and np.issubdtype(type(n), np.integer):
+            return math.factorial(n)
+        # we do not raise for non-integers with exact=True due to
+        # historical reasons, though deprecation would be possible
+        return _ufuncs._factorial(n)
+
+    # arrays & array-likes
+    n = asarray(n)
+    if n.size == 0:
+        # return empty arrays unchanged
+        return n
+    if not (np.issubdtype(n.dtype, np.integer)
+            or np.issubdtype(n.dtype, np.floating)):
+        raise ValueError(
+            f"Unsupported datatype for factorial: {n.dtype}\n"
+            "Permitted data types are integers and floating point numbers"
+        )
+    if exact and not np.issubdtype(n.dtype, np.integer):
+        # legacy behaviour is to support mixed integers/NaNs;
+        # deprecate this for exact=True
+        n_flt = n[~np.isnan(n)]
+        if np.allclose(n_flt, n_flt.astype(np.int64)):
+            warnings.warn(
+                "Non-integer arrays (e.g. due to presence of NaNs) "
+                "together with exact=True are deprecated. Either ensure "
+                "that the the array has integer dtype or use exact=False.",
+                DeprecationWarning,
+                stacklevel=2
+            )
         else:
-            n = asarray(n)
-            un = np.unique(n).astype(object)
-
-            # Convert to object array of long ints if np.int_ can't handle size
-            if np.isnan(n).any():
-                dt = float
-            elif un[-1] > 20:
-                dt = object
-            elif un[-1] > 12:
-                dt = np.int64
-            else:
-                dt = np.int_
-
-            out = np.empty_like(n, dtype=dt)
-
-            # Handle invalid/trivial values
-            # Ignore runtime warning when less operator used w/np.nan
-            with np.errstate(all='ignore'):
-                un = un[un > 1]
-                out[n < 2] = 1
-                out[n < 0] = 0
-
-            # Calculate products of each range of numbers
-            if un.size:
-                val = math.factorial(un[0])
-                out[n == un[0]] = val
-                for i in range(len(un) - 1):
-                    prev = un[i] + 1
-                    current = un[i + 1]
-                    val *= _range_prod(prev, current)
-                    out[n == current] = val
-
-            if np.isnan(n).any():
-                out = out.astype(np.float64)
-                out[np.isnan(n)] = n[np.isnan(n)]
-            return out
-    else:
-        out = _ufuncs._factorial(n)
-        return out
+            msg = ("factorial with exact=True does not "
+                   "support non-integral arrays")
+            raise ValueError(msg)
+
+    if exact:
+        return _exact_factorialx_array(n)
+    # we do not raise for non-integers with exact=True due to
+    # historical reasons, though deprecation would be possible
+    return _ufuncs._factorial(n)
 
 
 def factorial2(n, exact=False):
@@ -2845,15 +2936,14 @@ def factorial2(n, exact=False):
     This is the factorial with every second value skipped.  E.g., ``7!! = 7 * 5
     * 3 * 1``.  It can be approximated numerically as::
 
-      n!! = special.gamma(n/2+1)*2**((m+1)/2)/sqrt(pi)  n odd
-          = 2**(n/2) * (n/2)!                           n even
+      n!! = 2 ** (n / 2) * gamma(n / 2 + 1) * sqrt(2 / pi)  n odd
+          = 2 ** (n / 2) * gamma(n / 2 + 1)                 n even
+          = 2 ** (n / 2) * (n / 2)!                         n even
 
     Parameters
     ----------
     n : int or array_like
-        Calculate ``n!!``.  Arrays are only supported with `exact` set
-        to False. If ``n < -1``, the return value is 0.
-        Otherwise if ``n <= 0``, the return value is 1.
+        Calculate ``n!!``.  If ``n < 0``, the return value is 0.
     exact : bool, optional
         The result can be approximated rapidly using the gamma-formula
         above (default).  If `exact` is set to True, calculate the
@@ -2874,27 +2964,47 @@ def factorial2(n, exact=False):
     105
 
     """
-    if exact:
-        if n < -1:
+    def _approx(n):
+        # main factor that both even/odd approximations share
+        val = np.power(2, n / 2) * gamma(n / 2 + 1)
+        mask = np.ones_like(n, dtype=np.float64)
+        mask[n % 2 == 1] = sqrt(2 / pi)
+        # analytical continuation (based on odd integers)
+        # is scaled down by a factor of sqrt(2 / pi)
+        # compared to the value of even integers.
+        return val * mask
+
+    # don't use isscalar due to numpy/numpy#23574; 0-dim arrays treated below
+    if np.ndim(n) == 0 and not isinstance(n, np.ndarray):
+        # scalar cases
+        if n is None or np.isnan(n):
+            return np.nan
+        elif not np.issubdtype(type(n), np.integer):
+            msg = "factorial2 does not support non-integral scalar arguments"
+            raise ValueError(msg)
+        elif n < 0:
             return 0
-        if n <= 0:
+        elif n in {0, 1}:
             return 1
-        val = 1
-        for k in range(n, 0, -2):
-            val *= k
-        return val
-    else:
-        n = asarray(n)
-        vals = zeros(n.shape, 'd')
-        cond1 = (n % 2) & (n >= -1)
-        cond2 = (1-(n % 2)) & (n >= -1)
-        oddn = extract(cond1, n)
-        evenn = extract(cond2, n)
-        nd2o = oddn / 2.0
-        nd2e = evenn / 2.0
-        place(vals, cond1, gamma(nd2o + 1) / sqrt(pi) * pow(2.0, nd2o + 0.5))
-        place(vals, cond2, gamma(nd2e + 1) * pow(2.0, nd2e))
-        return vals
+        # general integer case
+        if exact:
+            return _range_prod(1, n, k=2)
+        return _approx(n)
+    # arrays & array-likes
+    n = asarray(n)
+    if n.size == 0:
+        # return empty arrays unchanged
+        return n
+    if not np.issubdtype(n.dtype, np.integer):
+        raise ValueError("factorial2 does not support non-integral arrays")
+    if exact:
+        return _exact_factorialx_array(n, k=2)
+    # approximation
+    vals = zeros(n.shape)
+    cond = (n >= 0)
+    n_to_compute = extract(cond, n)
+    place(vals, cond, _approx(n_to_compute))
+    return vals
 
 
 def factorialk(n, k, exact=True):
@@ -2912,9 +3022,8 @@ def factorialk(n, k, exact=True):
 
     Parameters
     ----------
-    n : int
-        Calculate multifactorial. If ``n < 1 - k``, the return value is 0.
-        Otherwise if ``n <= 0``, the return value is 1.
+    n : int or array_like
+        Calculate multifactorial. If `n` < 0, the return value is 0.
     k : int
         Order of multifactorial.
     exact : bool, optional
@@ -2940,17 +3049,38 @@ def factorialk(n, k, exact=True):
     10
 
     """
-    if exact:
-        if n < 1-k:
+    if not np.issubdtype(type(k), np.integer) or k < 1:
+        raise ValueError(f"k must be a positive integer, received: {k}")
+    if not exact:
+        raise NotImplementedError
+
+    helpmsg = ""
+    if k in {1, 2}:
+        func = "factorial" if k == 1 else "factorial2"
+        helpmsg = f"\nYou can try to use {func} instead"
+
+    # don't use isscalar due to numpy/numpy#23574; 0-dim arrays treated below
+    if np.ndim(n) == 0 and not isinstance(n, np.ndarray):
+        # scalar cases
+        if n is None or np.isnan(n):
+            return np.nan
+        elif not np.issubdtype(type(n), np.integer):
+            msg = "factorialk does not support non-integral scalar arguments!"
+            raise ValueError(msg + helpmsg)
+        elif n < 0:
             return 0
-        if n <= 0:
+        elif n in {0, 1}:
             return 1
-        val = 1
-        for j in range(n, 0, -k):
-            val = val*j
-        return val
-    else:
-        raise NotImplementedError
+        return _range_prod(1, n, k=k)
+    # arrays & array-likes
+    n = asarray(n)
+    if n.size == 0:
+        # return empty arrays unchanged
+        return n
+    if not np.issubdtype(n.dtype, np.integer):
+        msg = "factorialk does not support non-integral arrays!"
+        raise ValueError(msg + helpmsg)
+    return _exact_factorialx_array(n, k=k)
 
 
 def zeta(x, q=None, out=None):


Test patch:
diff --git a/scipy/special/tests/test_basic.py b/scipy/special/tests/test_basic.py
--- a/scipy/special/tests/test_basic.py
+++ b/scipy/special/tests/test_basic.py
@@ -17,7 +17,9 @@
 ###  test_pbvv_seq
 ###  test_sph_harm
 
+import functools
 import itertools
+import operator
 import platform
 import sys
 
@@ -38,6 +40,8 @@
 from scipy.special import elliprc, elliprd, elliprf, elliprg, elliprj
 from scipy.special import mathieu_odd_coef, mathieu_even_coef
 
+from scipy.special._basic import _FACTORIALK_LIMITS_64BITS, \
+    _FACTORIALK_LIMITS_32BITS
 from scipy.special._testutils import with_special_errors, \
      assert_func_equal, FuncData
 
@@ -1954,107 +1958,364 @@ def test_expm1more(self):
 
 
 class TestFactorialFunctions:
-    def test_factorial(self):
-        # Some known values, float math
-        assert_array_almost_equal(special.factorial(0), 1)
-        assert_array_almost_equal(special.factorial(1), 1)
-        assert_array_almost_equal(special.factorial(2), 2)
-        assert_array_almost_equal([6., 24., 120.],
-                                  special.factorial([3, 4, 5], exact=False))
-        assert_array_almost_equal(special.factorial([[5, 3], [4, 3]]),
-                                  [[120, 6], [24, 6]])
-
-        # Some known values, integer math
-        assert_equal(special.factorial(0, exact=True), 1)
-        assert_equal(special.factorial(1, exact=True), 1)
-        assert_equal(special.factorial(2, exact=True), 2)
-        assert_equal(special.factorial(5, exact=True), 120)
-        assert_equal(special.factorial(15, exact=True), 1307674368000)
-
-        # ndarray shape is maintained
-        assert_equal(special.factorial([7, 4, 15, 10], exact=True),
-                     [5040, 24, 1307674368000, 3628800])
-
-        assert_equal(special.factorial([[5, 3], [4, 3]], True),
-                     [[120, 6], [24, 6]])
-
-        # object arrays
-        assert_equal(special.factorial(np.arange(-3, 22), True),
-                     special.factorial(np.arange(-3, 22), False))
-
-        # int64 array
-        assert_equal(special.factorial(np.arange(-3, 15), True),
-                     special.factorial(np.arange(-3, 15), False))
-
-        # int32 array
-        assert_equal(special.factorial(np.arange(-3, 5), True),
-                     special.factorial(np.arange(-3, 5), False))
-
+    @pytest.mark.parametrize("exact", [True, False])
+    def test_factorialx_scalar_return_type(self, exact):
+        assert np.isscalar(special.factorial(1, exact=exact))
+        assert np.isscalar(special.factorial2(1, exact=exact))
+        assert np.isscalar(special.factorialk(1, 3, exact=True))
+
+    @pytest.mark.parametrize("n", [-1, -2, -3])
+    @pytest.mark.parametrize("exact", [True, False])
+    def test_factorialx_negative(self, exact, n):
+        assert_equal(special.factorial(n, exact=exact), 0)
+        assert_equal(special.factorial2(n, exact=exact), 0)
+        assert_equal(special.factorialk(n, 3, exact=True), 0)
+
+    @pytest.mark.parametrize("exact", [True, False])
+    def test_factorialx_negative_array(self, exact):
+        assert_func = assert_array_equal if exact else assert_allclose
         # Consistent output for n < 0
-        for exact in (True, False):
-            assert_array_equal(0, special.factorial(-3, exact))
-            assert_array_equal([1, 2, 0, 0],
-                               special.factorial([1, 2, -5, -4], exact))
-
-        for n in range(0, 22):
-            # Compare all with math.factorial
-            correct = math.factorial(n)
-            assert_array_equal(correct, special.factorial(n, True))
-            assert_array_equal(correct, special.factorial([n], True)[0])
-
-            assert_allclose(float(correct), special.factorial(n, False))
-            assert_allclose(float(correct), special.factorial([n], False)[0])
-
-            # Compare exact=True vs False, scalar vs array
-            assert_array_equal(special.factorial(n, True),
-                               special.factorial(n, False))
-
-            assert_array_equal(special.factorial([n], True),
-                               special.factorial([n], False))
-
-    @pytest.mark.parametrize('x, exact', [
-        (1, True),
-        (1, False),
-        (np.array(1), True),
-        (np.array(1), False),
-    ])
-    def test_factorial_0d_return_type(self, x, exact):
-        assert np.isscalar(special.factorial(x, exact=exact))
-
-    def test_factorial2(self):
-        assert_array_almost_equal([105., 384., 945.],
-                                  special.factorial2([7, 8, 9], exact=False))
-        assert_equal(special.factorial2(7, exact=True), 105)
-
-    def test_factorialk(self):
-        assert_equal(special.factorialk(5, 1, exact=True), 120)
-        assert_equal(special.factorialk(5, 3, exact=True), 10)
-
-    @pytest.mark.parametrize('x, exact', [
-        (np.nan, True),
-        (np.nan, False),
-        (np.array([np.nan]), True),
-        (np.array([np.nan]), False),
-    ])
-    def test_nan_inputs(self, x, exact):
-        result = special.factorial(x, exact=exact)
-        assert_(np.isnan(result))
-
-    # GH-13122: special.factorial() argument should be an array of integers.
-    # On Python 3.10, math.factorial() reject float.
-    # On Python 3.9, a DeprecationWarning is emitted.
-    # A numpy array casts all integers to float if the array contains a
-    # single NaN.
-    @pytest.mark.skipif(sys.version_info >= (3, 10),
-                        reason="Python 3.10+ math.factorial() requires int")
-    def test_mixed_nan_inputs(self):
+        assert_func(special.factorial([-5, -4, 0, 1], exact=exact),
+                    [0, 0, 1, 1])
+        assert_func(special.factorial2([-5, -4, 0, 1], exact=exact),
+                    [0, 0, 1, 1])
+        assert_func(special.factorialk([-5, -4, 0, 1], 3, exact=True),
+                    [0, 0, 1, 1])
+
+    @pytest.mark.parametrize("exact", [True, False])
+    @pytest.mark.parametrize("content", [np.nan, None, np.datetime64('nat')],
+                             ids=["NaN", "None", "NaT"])
+    def test_factorialx_nan(self, content, exact):
+        # scalar
+        assert special.factorial(content, exact=exact) is np.nan
+        assert special.factorial2(content, exact=exact) is np.nan
+        assert special.factorialk(content, 3, exact=True) is np.nan
+        # array-like (initializes np.array with default dtype)
+        if content is not np.nan:
+            # None causes object dtype, which is not supported; as is datetime
+            with pytest.raises(ValueError, match="Unsupported datatype.*"):
+                special.factorial([content], exact=exact)
+        elif exact:
+            # cannot use `is np.nan` see https://stackoverflow.com/a/52124109
+            with pytest.warns(DeprecationWarning, match="Non-integer array.*"):
+                assert np.isnan(special.factorial([content], exact=exact)[0])
+        else:
+            assert np.isnan(special.factorial([content], exact=exact)[0])
+        # factorial{2,k} don't support array case due to dtype constraints
+        with pytest.raises(ValueError, match="factorial2 does not support.*"):
+            special.factorial2([content], exact=exact)
+        with pytest.raises(ValueError, match="factorialk does not support.*"):
+            special.factorialk([content], 3, exact=True)
+        # array-case also tested in test_factorial{,2,k}_corner_cases
+
+    @pytest.mark.parametrize("levels", range(1, 5))
+    @pytest.mark.parametrize("exact", [True, False])
+    def test_factorialx_array_shape(self, levels, exact):
+        def _nest_me(x, k=1):
+            """
+            Double x and nest it k times
+
+            For example:
+            >>> _nest_me([3, 4], 2)
+            [[[3, 4], [3, 4]], [[3, 4], [3, 4]]]
+            """
+            if k == 0:
+                return x
+            else:
+                return _nest_me([x, x], k-1)
+
+        def _check(res, nucleus):
+            exp = np.array(_nest_me(nucleus, k=levels), dtype=object)
+            # test that ndarray shape is maintained
+            # need to cast to float due to numpy/numpy#21220
+            assert_allclose(res.astype(np.float64), exp.astype(np.float64))
+
+        n = np.array(_nest_me([5, 25], k=levels))
+        exp_nucleus = {1: [120, math.factorial(25)],
+                       # correctness of factorial2() is tested elsewhere
+                       2: [15, special.factorial2(25, exact=True)],
+                       3: [10, special.factorialk(25, 3)]}
+
+        _check(special.factorial(n, exact=exact), exp_nucleus[1])
+        _check(special.factorial2(n, exact=exact), exp_nucleus[2])
+        _check(special.factorialk(n, 3, exact=True), exp_nucleus[3])
+
+    @pytest.mark.parametrize("exact", [True, False])
+    @pytest.mark.parametrize("dim", range(0, 5))
+    def test_factorialx_array_dimension(self, dim, exact):
+        n = np.array(5, ndmin=dim)
+        exp = {1: 120, 2: 15, 3: 10}
+        assert_allclose(special.factorial(n, exact=exact),
+                        np.array(exp[1], ndmin=dim))
+        assert_allclose(special.factorial2(n, exact=exact),
+                        np.array(exp[2], ndmin=dim))
+        assert_allclose(special.factorialk(n, 3, exact=True),
+                        np.array(exp[3], ndmin=dim))
+
+    @pytest.mark.parametrize("exact", [True, False])
+    @pytest.mark.parametrize("level", range(1, 5))
+    def test_factorialx_array_like(self, level, exact):
+        def _nest_me(x, k=1):
+            if k == 0:
+                return x
+            else:
+                return _nest_me([x], k-1)
+
+        n = _nest_me([5], k=level-1)  # nested list
+        exp_nucleus = {1: 120, 2: 15, 3: 10}
+        assert_func = assert_array_equal if exact else assert_allclose
+        assert_func(special.factorial(n, exact=exact),
+                    np.array(exp_nucleus[1], ndmin=level))
+        assert_func(special.factorial2(n, exact=exact),
+                    np.array(exp_nucleus[2], ndmin=level))
+        assert_func(special.factorialk(n, 3, exact=True),
+                    np.array(exp_nucleus[3], ndmin=level))
+
+    # note that n=170 is the last integer such that factorial(n) fits float64
+    @pytest.mark.parametrize('n', range(30, 180, 10))
+    def test_factorial_accuracy(self, n):
+        # Compare exact=True vs False, i.e. that the accuracy of the
+        # approximation is better than the specified tolerance.
+
+        rtol = 6e-14 if sys.platform == 'win32' else 1e-15
+        # need to cast exact result to float due to numpy/numpy#21220
+        assert_allclose(float(special.factorial(n, exact=True)),
+                        special.factorial(n, exact=False), rtol=rtol)
+        assert_allclose(special.factorial([n], exact=True).astype(float),
+                        special.factorial([n], exact=False), rtol=rtol)
+
+    @pytest.mark.parametrize('n',
+                             list(range(0, 22)) + list(range(30, 180, 10)))
+    def test_factorial_int_reference(self, n):
+        # Compare all with math.factorial
+        correct = math.factorial(n)
+        assert_array_equal(correct, special.factorial(n, True))
+        assert_array_equal(correct, special.factorial([n], True)[0])
+
+        rtol = 6e-14 if sys.platform == 'win32' else 1e-15
+        assert_allclose(float(correct), special.factorial(n, False),
+                        rtol=rtol)
+        assert_allclose(float(correct), special.factorial([n], False)[0],
+                        rtol=rtol)
+
+    @pytest.mark.parametrize("exact", [True, False])
+    def test_factorial_float_reference(self, exact):
+        def _check(n, expected):
+            # support for exact=True with scalar floats grandfathered in
+            assert_allclose(special.factorial(n, exact=exact), expected)
+            # non-integer types in arrays only allowed with exact=False
+            assert_allclose(special.factorial([n])[0], expected)
+
+        # Reference values from mpmath for gamma(n+1)
+        _check(0.01, 0.994325851191506032181932988)
+        _check(1.11, 1.051609009483625091514147465)
+        _check(5.55, 314.9503192327208241614959052)
+        _check(11.1, 50983227.84411615655137170553)
+        _check(33.3, 2.493363339642036352229215273e+37)
+        _check(55.5, 9.479934358436729043289162027e+73)
+        _check(77.7, 3.060540559059579022358692625e+114)
+        _check(99.9, 5.885840419492871504575693337e+157)
+        # close to maximum for float64
+        _check(170.6243, 1.79698185749571048960082e+308)
+
+    @pytest.mark.parametrize("dtype", [np.int64, np.float64,
+                                       np.complex128, object])
+    @pytest.mark.parametrize("exact", [True, False])
+    @pytest.mark.parametrize("dim", range(0, 5))
+    # test empty & non-empty arrays, with nans and mixed
+    @pytest.mark.parametrize("content",
+                             [[], [1], [1.1], [np.nan], [np.nan, 1]],
+                             ids=["[]", "[1]", "[1.1]", "[NaN]", "[NaN, 1]"])
+    def test_factorial_array_corner_cases(self, content, dim, exact, dtype):
+        if dtype == np.int64 and any(np.isnan(x) for x in content):
+            pytest.skip("impossible combination")
+        n = np.array(content, ndmin=dim, dtype=dtype)
+        result = None
+        if not content:
+            result = special.factorial(n, exact=exact)
+        elif not (np.issubdtype(n.dtype, np.integer)
+                  or np.issubdtype(n.dtype, np.floating)):
+            with pytest.raises(ValueError, match="Unsupported datatype*"):
+                special.factorial(n, exact=exact)
+        elif (exact and not np.issubdtype(n.dtype, np.integer) and n.size and
+              np.allclose(n[~np.isnan(n)], n[~np.isnan(n)].astype(np.int64))):
+            # using integers but in array with wrong dtype (e.g. due to NaNs)
+            with pytest.warns(DeprecationWarning, match="Non-integer array.*"):
+                result = special.factorial(n, exact=exact)
+                # expected dtype is integer, unless there are NaNs
+                dtype = np.float_ if np.any(np.isnan(n)) else np.int_
+        elif exact and not np.issubdtype(n.dtype, np.integer):
+            with pytest.raises(ValueError, match="factorial with exact=.*"):
+                special.factorial(n, exact=exact)
+        else:
+            # no error
+            result = special.factorial(n, exact=exact)
+
+        if result is not None:
+            # expected result is empty if and only if n is empty,
+            # and has the same dtype & dimension as n
+            with suppress_warnings() as sup:
+                sup.filter(DeprecationWarning)
+                r = special.factorial(n.ravel(), exact=exact) if n.size else []
+            expected = np.array(r, ndmin=dim, dtype=dtype)
+            assert_equal(result, expected)
+
+    @pytest.mark.parametrize("exact", [True, False])
+    @pytest.mark.parametrize("n", [1, 1.1, 2 + 2j, np.nan, None],
+                             ids=["1", "1.1", "2+2j", "NaN", "None"])
+    def test_factorial_scalar_corner_cases(self, n, exact):
+        if (n is None or n is np.nan or np.issubdtype(type(n), np.integer)
+                or np.issubdtype(type(n), np.floating)):
+            # no error
+            result = special.factorial(n, exact=exact)
+            exp = np.nan if n is np.nan or n is None else special.factorial(n)
+            assert_equal(result, exp)
+        else:
+            with pytest.raises(ValueError, match="Unsupported datatype*"):
+                special.factorial(n, exact=exact)
+
+    # use odd increment to make sure both odd & even numbers are tested!
+    @pytest.mark.parametrize('n', range(30, 180, 11))
+    def test_factorial2_accuracy(self, n):
+        # Compare exact=True vs False, i.e. that the accuracy of the
+        # approximation is better than the specified tolerance.
+
+        rtol = 2e-14 if sys.platform == 'win32' else 1e-15
+        # need to cast exact result to float due to numpy/numpy#21220
+        assert_allclose(float(special.factorial2(n, exact=True)),
+                        special.factorial2(n, exact=False), rtol=rtol)
+        assert_allclose(special.factorial2([n], exact=True).astype(float),
+                        special.factorial2([n], exact=False), rtol=rtol)
+
+    @pytest.mark.parametrize('n',
+                             list(range(0, 22)) + list(range(30, 180, 11)))
+    def test_factorial2_int_reference(self, n):
+        # Compare all with correct value
+
+        # Cannot use np.product due to overflow
+        correct = functools.reduce(operator.mul, list(range(n, 0, -2)), 1)
+
+        assert_array_equal(correct, special.factorial2(n, True))
+        assert_array_equal(correct, special.factorial2([n], True)[0])
+
+        assert_allclose(float(correct), special.factorial2(n, False))
+        assert_allclose(float(correct), special.factorial2([n], False)[0])
+
+    @pytest.mark.parametrize("dtype", [np.int64, np.float64,
+                                       np.complex128, object])
+    @pytest.mark.parametrize("exact", [True, False])
+    @pytest.mark.parametrize("dim", range(0, 5))
+    # test empty & non-empty arrays, with nans and mixed
+    @pytest.mark.parametrize("content", [[], [1], [np.nan], [np.nan, 1]],
+                             ids=["[]", "[1]", "[NaN]", "[NaN, 1]"])
+    def test_factorial2_array_corner_cases(self, content, dim, exact, dtype):
+        if dtype == np.int64 and any(np.isnan(x) for x in content):
+            pytest.skip("impossible combination")
+        n = np.array(content, ndmin=dim, dtype=dtype)
+        if np.issubdtype(n.dtype, np.integer) or (not content):
+            # no error
+            result = special.factorial2(n, exact=exact)
+            # expected result is identical to n for exact=True resp. empty
+            # arrays (assert_allclose chokes on object), otherwise up to tol
+            func = assert_equal if exact or (not content) else assert_allclose
+            func(result, n)
+        else:
+            with pytest.raises(ValueError, match="factorial2 does not*"):
+                special.factorial2(n, 3)
+
+    @pytest.mark.parametrize("exact", [True, False])
+    @pytest.mark.parametrize("n", [1, 1.1, 2 + 2j, np.nan, None],
+                             ids=["1", "1.1", "2+2j", "NaN", "None"])
+    def test_factorial2_scalar_corner_cases(self, n, exact):
+        if n is None or n is np.nan or np.issubdtype(type(n), np.integer):
+            # no error
+            result = special.factorial2(n, exact=exact)
+            exp = np.nan if n is np.nan or n is None else special.factorial(n)
+            assert_equal(result, exp)
+        else:
+            with pytest.raises(ValueError, match="factorial2 does not*"):
+                special.factorial2(n, exact=exact)
+
+    @pytest.mark.parametrize('k', list(range(1, 5)) + [10, 20])
+    @pytest.mark.parametrize('n',
+                             list(range(0, 22)) + list(range(22, 100, 11)))
+    def test_factorialk_int_reference(self, n, k):
+        # Compare all with correct value
+
+        # Would be nice to use np.product here, but that's
+        # broken on windows, see numpy/numpy#21219
+        correct = functools.reduce(operator.mul, list(range(n, 0, -k)), 1)
+
+        assert_array_equal(correct, special.factorialk(n, k, True))
+        assert_array_equal(correct, special.factorialk([n], k, True)[0])
+
+        # exact=False not yet supported
+        # assert_allclose(float(correct), special.factorialk(n, k, False))
+        # assert_allclose(float(correct), special.factorialk([n], k, False)[0])
+
+    @pytest.mark.parametrize("dtype", [np.int64, np.float64,
+                                       np.complex128, object])
+    @pytest.mark.parametrize("dim", range(0, 5))
+    # test empty & non-empty arrays, with nans and mixed
+    @pytest.mark.parametrize("content", [[], [1], [np.nan], [np.nan, 1]],
+                             ids=["[]", "[1]", "[NaN]", "[NaN, 1]"])
+    def test_factorialk_array_corner_cases(self, content, dim, dtype):
+        if dtype == np.int64 and any(np.isnan(x) for x in content):
+            pytest.skip("impossible combination")
+        n = np.array(content, ndmin=dim, dtype=dtype)
+        if np.issubdtype(n.dtype, np.integer) or (not content):
+            # no error; expected result is identical to n
+            assert_equal(special.factorialk(n, 3), n)
+        else:
+            with pytest.raises(ValueError, match="factorialk does not*"):
+                special.factorialk(n, 3)
+
+    @pytest.mark.parametrize("exact", [True, False])
+    @pytest.mark.parametrize("k", range(1, 5))
+    @pytest.mark.parametrize("n", [1, 1.1, 2 + 2j, np.nan, None],
+                             ids=["1", "1.1", "2+2j", "NaN", "None"])
+    def test_factorialk_scalar_corner_cases(self, n, k, exact):
+        if not exact:
+            with pytest.raises(NotImplementedError):
+                special.factorialk(n, k=k, exact=exact)
+        elif n is None or n is np.nan or np.issubdtype(type(n), np.integer):
+            # no error
+            result = special.factorial2(n, exact=exact)
+            nan_cond = n is np.nan or n is None
+            expected = np.nan if nan_cond else special.factorialk(n, k=k)
+            assert_equal(result, expected)
+        else:
+            with pytest.raises(ValueError, match="factorialk does not*"):
+                special.factorialk(n, k=k, exact=exact)
+
+    @pytest.mark.parametrize("k", [0, 1.1, np.nan, "1"])
+    def test_factorialk_raises_k(self, k):
+        with pytest.raises(ValueError, match="k must be a positive integer*"):
+            special.factorialk(1, k)
+
+    @pytest.mark.parametrize("k", range(1, 12))
+    def test_factorialk_dtype(self, k):
+        if k in _FACTORIALK_LIMITS_64BITS.keys():
+            n = np.array([_FACTORIALK_LIMITS_32BITS[k]])
+            assert_equal(special.factorialk(n, k).dtype, np.int_)
+            assert_equal(special.factorialk(n + 1, k).dtype, np.int64)
+            # assert maximality of limits for given dtype
+            assert special.factorialk(n + 1, k) > np.iinfo(np.int32).max
+
+            n = np.array([_FACTORIALK_LIMITS_64BITS[k]])
+            assert_equal(special.factorialk(n, k).dtype, np.int64)
+            assert_equal(special.factorialk(n + 1, k).dtype, object)
+            assert special.factorialk(n + 1, k) > np.iinfo(np.int64).max
+        else:
+            # for k >= 10, we always return object
+            assert_equal(special.factorialk(np.array([1]), k).dtype, object)
+
+    def test_factorial_mixed_nan_inputs(self):
         x = np.array([np.nan, 1, 2, 3, np.nan])
-        with suppress_warnings() as sup:
-            sup.filter(DeprecationWarning, "Using factorial\\(\\) with floats is deprecated")
-            result = special.factorial(x, exact=True)
-            assert_equal(np.array([np.nan, 1, 2, 6, np.nan]), result)
-            result = special.factorial(x, exact=False)
-            assert_equal(np.array([np.nan, 1, 2, 6, np.nan]), result)
+        expected = np.array([np.nan, 1, 2, 6, np.nan])
+        assert_equal(special.factorial(x, exact=False), expected)
+        with pytest.warns(DeprecationWarning, match=r"Non-integer array.*"):
+            assert_equal(special.factorial(x, exact=True), expected)
 
 
 class TestFresnel: