Repository: scipy/scipy (Python)

Issue:
BUG: `stats.bartlett`: returned statistic can be negative when exact value would be zero
### Describe your issue.

Bartlett's test, as implemented in `scipy.stats.bartlett`, returns a test statistic that follows a chi-square distribution under the null hypothesis. The test statistic value is always non-negative because it is based on a sum of squares. 

In the example provided below, I'm getting a small, *negative* test statistic value, `-7.080736255702299e-07`, which is unexpected I'm assuming this is due to some numeric inaccuracy. Maybe it could be fixed by replacing small negative values with zero.

~~The problem was introduced by~~Results in the following example changed after https://github.com/scipy/scipy/pull/20751. Before that PR, the example provided below was giving `2.7743484928754286e-11`, i.e., a small positive value.

### Reproducing Code Example

```python
import numpy as np
from scipy.stats import bartlett

a = np.array([10.1, 10.2, 10.3, 10.4], dtype=np.float32)
b = np.array([10.15, 10.25, 10.35, 10.45], dtype=np.float32)
c = np.array([10.05, 10.15, 10.25, 10.35], dtype=np.float32)

result = bartlett(a, b, c)
print("Bartlett test result for float32 data type arrays:", result)
```


### Error message

```shell
-
```


### SciPy/NumPy/Python version and system information

```shell
Build Dependencies:
  blas:
    detection method: pkgconfig
    found: true
    include directory: /root/conda/envs/scipy-dev/include
    lib directory: /root/conda/envs/scipy-dev/lib
    name: openblas
    openblas configuration: USE_64BITINT=0 DYNAMIC_ARCH=1 DYNAMIC_OLDER= NO_CBLAS=
      NO_LAPACK=0 NO_LAPACKE= NO_AFFINITY=1 USE_OPENMP=0 PRESCOTT MAX_THREADS=128
    pc file directory: /root/conda/envs/scipy-dev/lib/pkgconfig
    version: 0.3.27
  lapack:
    detection method: pkgconfig
    found: true
    include directory: /root/conda/envs/scipy-dev/include
    lib directory: /root/conda/envs/scipy-dev/lib
    name: openblas
    openblas configuration: USE_64BITINT=0 DYNAMIC_ARCH=1 DYNAMIC_OLDER= NO_CBLAS=
      NO_LAPACK=0 NO_LAPACKE= NO_AFFINITY=1 USE_OPENMP=0 PRESCOTT MAX_THREADS=128
    pc file directory: /root/conda/envs/scipy-dev/lib/pkgconfig
    version: 0.3.27
  pybind11:
    detection method: pkgconfig
    include directory: /root/conda/envs/scipy-dev/include
    name: pybind11
    version: 2.12.0
Compilers:
  c:
    args: -march=nocona, -mtune=haswell, -ftree-vectorize, -fPIC, -fstack-protector-strong,
      -fno-plt, -O2, -ffunction-sections, -pipe, -isystem, /root/conda/envs/scipy-dev/include,
      -DNDEBUG, -D_FORTIFY_SOURCE=2, -O2, -isystem, /root/conda/envs/scipy-dev/include
    commands: /root/conda/envs/scipy-dev/bin/x86_64-conda-linux-gnu-cc
    linker: ld.bfd
    linker args: -Wl,-O2, -Wl,--sort-common, -Wl,--as-needed, -Wl,-z,relro, -Wl,-z,now,
      -Wl,--disable-new-dtags, -Wl,--gc-sections, -Wl,--allow-shlib-undefined, -Wl,-rpath,/root/conda/envs/scipy-dev/lib,
      -Wl,-rpath-link,/root/conda/envs/scipy-dev/lib, -L/root/conda/envs/scipy-dev/lib,
      -march=nocona, -mtune=haswell, -ftree-vectorize, -fPIC, -fstack-protector-strong,
      -fno-plt, -O2, -ffunction-sections, -pipe, -isystem, /root/conda/envs/scipy-dev/include,
      -DNDEBUG, -D_FORTIFY_SOURCE=2, -O2, -isystem, /root/conda/envs/scipy-dev/include
    name: gcc
    version: 12.3.0
  c++:
    args: -fvisibility-inlines-hidden, -fmessage-length=0, -march=nocona, -mtune=haswell,
      -ftree-vectorize, -fPIC, -fstack-protector-strong, -fno-plt, -O2, -ffunction-sections,
      -pipe, -isystem, /root/conda/envs/scipy-dev/include, -DNDEBUG, -D_FORTIFY_SOURCE=2,
      -O2, -isystem, /root/conda/envs/scipy-dev/include
    commands: /root/conda/envs/scipy-dev/bin/x86_64-conda-linux-gnu-c++
    linker: ld.bfd
    linker args: -Wl,-O2, -Wl,--sort-common, -Wl,--as-needed, -Wl,-z,relro, -Wl,-z,now,
      -Wl,--disable-new-dtags, -Wl,--gc-sections, -Wl,--allow-shlib-undefined, -Wl,-rpath,/root/conda/envs/scipy-dev/lib,
      -Wl,-rpath-link,/root/conda/envs/scipy-dev/lib, -L/root/conda/envs/scipy-dev/lib,
      -fvisibility-inlines-hidden, -fmessage-length=0, -march=nocona, -mtune=haswell,
      -ftree-vectorize, -fPIC, -fstack-protector-strong, -fno-plt, -O2, -ffunction-sections,
      -pipe, -isystem, /root/conda/envs/scipy-dev/include, -DNDEBUG, -D_FORTIFY_SOURCE=2,
      -O2, -isystem, /root/conda/envs/scipy-dev/include
    name: gcc
    version: 12.3.0
  cython:
    commands: cython
    linker: cython
    name: cython
    version: 3.0.10
  fortran:
    args: -march=nocona, -mtune=haswell, -ftree-vectorize, -fPIC, -fstack-protector-strong,
      -fno-plt, -O2, -ffunction-sections, -pipe, -isystem, /root/conda/envs/scipy-dev/include
    commands: /root/conda/envs/scipy-dev/bin/x86_64-conda-linux-gnu-gfortran
    linker: ld.bfd
    linker args: -Wl,-O2, -Wl,--sort-common, -Wl,--as-needed, -Wl,-z,relro, -Wl,-z,now,
      -Wl,--disable-new-dtags, -Wl,--gc-sections, -Wl,--allow-shlib-undefined, -Wl,-rpath,/root/conda/envs/scipy-dev/lib,
      -Wl,-rpath-link,/root/conda/envs/scipy-dev/lib, -L/root/conda/envs/scipy-dev/lib,
      -march=nocona, -mtune=haswell, -ftree-vectorize, -fPIC, -fstack-protector-strong,
      -fno-plt, -O2, -ffunction-sections, -pipe, -isystem, /root/conda/envs/scipy-dev/include
    name: gcc
    version: 12.3.0
  pythran:
    include directory: ../../../root/conda/envs/scipy-dev/lib/python3.12/site-packages/pythran
    version: 0.15.0
Machine Information:
  build:
    cpu: x86_64
    endian: little
    family: x86_64
    system: linux
  cross-compiled: false
  host:
    cpu: x86_64
    endian: little
    family: x86_64
    system: linux
Python Information:
  path: /root/conda/envs/scipy-dev/bin/python3.12
  version: '3.12'
```



Discussion:
Thanks, we'll fix it shortly. Yes, the easiest fix would probably be to clip to zero. I'll see if there is anything else that can be done to improve numerical stability, though.
Just thought I'd mention that this possibility was not introduced by recent work; it is pretty easy to find examples in older versions, too.

```python3
import scipy
print(scipy.__version__)  # 1.11.4
rng = np.random.default_rng(42)
x = rng.random(size=8).astype(np.float32)
offset = rng.random(size=()).astype(np.float32)
result = bartlett(x, x-offset, x+offset)
result  # BartlettResult(statistic=-6.681222739236763e-15, pvalue=1.0)
```

I'd be willing to review a PR that clips at the end and adds a test. (Or, if there a simple rearrangement of the calculation that would ensure that the result is positive, that would be of interest, too.)
Nice, thanks for fixing it!



Patch:
diff --git a/scipy/stats/_morestats.py b/scipy/stats/_morestats.py
index 30ddf9faf3c3..c31704e4359a 100644
--- a/scipy/stats/_morestats.py
+++ b/scipy/stats/_morestats.py
@@ -18,6 +18,7 @@
     xp_minimum,
     xp_moveaxis_to_end,
     xp_vector_norm,
+    xp_clip
 )
 
 from ._ansari_swilk_statistics import gscale, swilk
@@ -2885,15 +2886,20 @@ def bartlett(*samples, axis=0):
     ssq = [arr[xp.newaxis, ...] for arr in ssq]
     Ni = xp.concat(Ni, axis=0)
     ssq = xp.concat(ssq, axis=0)
-    Ntot = xp.sum(Ni, axis=0)
-    spsq = xp.sum((Ni - 1)*ssq, axis=0) / (Ntot - k)
-    numer = (Ntot - k) * xp.log(spsq) - xp.sum((Ni - 1)*xp.log(ssq), axis=0)
-    denom = 1 + 1/(3*(k - 1)) * ((xp.sum(1/(Ni - 1), axis=0)) - 1/(Ntot - k))
+    # sum dtype can be removed when 2023.12 rules kick in
+    dtype = Ni.dtype
+    Ntot = xp.sum(Ni, axis=0, dtype=dtype)
+    spsq = xp.sum((Ni - 1)*ssq, axis=0, dtype=dtype) / (Ntot - k)
+    numer = ((Ntot - k) * xp.log(spsq)
+             - xp.sum((Ni - 1)*xp.log(ssq), axis=0, dtype=dtype))
+    denom = (1 + 1/(3*(k - 1))
+             * ((xp.sum(1/(Ni - 1), axis=0, dtype=dtype)) - 1/(Ntot - k)))
     T = numer / denom
 
     chi2 = _SimpleChi2(xp.asarray(k-1))
     pvalue = _get_pvalue(T, chi2, alternative='greater', symmetric=False, xp=xp)
 
+    T = xp_clip(T, min=0., max=xp.inf)
     T = T[()] if T.ndim == 0 else T
     pvalue = pvalue[()] if pvalue.ndim == 0 else pvalue
 


Test patch:
diff --git a/scipy/stats/tests/test_morestats.py b/scipy/stats/tests/test_morestats.py
index 4a4ade6abf85..c2e3fe6e8949 100644
--- a/scipy/stats/tests/test_morestats.py
+++ b/scipy/stats/tests/test_morestats.py
@@ -8,7 +8,6 @@
 from functools import partial
 
 import numpy as np
-import numpy.testing
 from numpy.random import RandomState
 from numpy.testing import (assert_array_equal, assert_almost_equal,
                            assert_array_less, assert_array_almost_equal,
@@ -783,6 +782,13 @@ def test_empty_arg(self, xp):
         xp_assert_equal(res.statistic, NaN)
         xp_assert_equal(res.pvalue, NaN)
 
+    def test_negative_pvalue_gh21152(self, xp):
+        a = xp.asarray([10.1, 10.2, 10.3, 10.4], dtype=xp.float32)
+        b = xp.asarray([10.15, 10.25, 10.35, 10.45], dtype=xp.float32)
+        c = xp.asarray([10.05, 10.15, 10.25, 10.35], dtype=xp.float32)
+        res = stats.bartlett(a, b, c)
+        assert xp.all(res.statistic >= 0)
+
 
 class TestLevene: