Repository: cupy/cupy

Issue:
Percentile output non-monotonic for monotonically increasing percentiles
# Description
For certain inputs, `cupy.percentile` with linear interpolation can produce non-monotonically increasing output for a monotonically-increasing list of desired percentiles. This is identical to the issue resolved by https://github.com/numpy/numpy/pull/16273 in numpy and can be resolved with the same adjustment in how the linear interpolation gets calculated in the `percentile_weightnening` kernel.

# Reproduction code
```python
import cupy
magic_value = -1.00795396e+08
arr = cupy.array([magic_value, 1.2, magic_value, 2.1, 3.5])
quantiles = cupy.linspace(0, 100, 21)
percentile = cupy.percentile(arr, quantiles, interpolation='linear')
print(percentile[0] <= percentile[1]) # Should be True; is False
```
This example was found using Hypothesis, and while the other values in the array can be anything, this "magic" value (or one of the other magic values that Hypothesis found) must be present twice in order to demonstrate the issue. These magic values are due to how floating error propagates with the current linear interpolation formula (additional details [here](https://math.stackexchange.com/questions/907327/accurate-floating-point-linear-interpolation)) and depend on which and how many percentiles are calculated.

# Environment
OS                           : Linux-5.8.0-36-generic-x86_64-with-glibc2.31
CuPy Version                 : 8.4.0
NumPy Version                : 1.20.0
SciPy Version                : None
Cython Build Version         : 0.29.21
CUDA Root                    : /home/whicks/anaconda3/envs/cupy
CUDA Build Version           : 11000
CUDA Driver Version          : 11020
CUDA Runtime Version         : 11000
cuBLAS Version               : 11200
cuFFT Version                : 10201
cuRAND Version               : 10201
cuSOLVER Version             : (10, 6, 0)
cuSPARSE Version             : 11101
NVRTC Version                : (11, 0)
Thrust Version               : 100909
CUB Build Version            : 100909
cuDNN Build Version          : 8005
cuDNN Version                : 8005
NCCL Build Version           : 2708
NCCL Runtime Version         : 2803
cuTENSOR Version             : 10202
Device 0 Name                : Quadro RTX 8000
Device 0 Compute Capability  : 75
Device 1 Name                : Quadro RTX 8000
Device 1 Compute Capability  : 75



Discussion:
This should require only a very small change, and I'm happy to take care of it myself later today or first thing tomorrow.
Thank you for reporting! It would be great if you could make a PR 👍🏼 

Resolving patch:
diff --git a/cupy/_statistics/order.py b/cupy/_statistics/order.py
--- a/cupy/_statistics/order.py
+++ b/cupy/_statistics/order.py
@@ -245,10 +245,17 @@ def _quantile_unchecked(a, q, axis=None, out=None, interpolation='linear',
             ptrdiff_t idx_below = floor(idx);
             U weight_above = idx - idx_below;
 
-            ptrdiff_t offset_i = _ind.get()[0] * offset;
             ptrdiff_t max_idx = size - 1;
-            ret = a[offset_i + idx_below] * (1.0 - weight_above)
-              + a[min(offset_i + idx_below + 1, max_idx)] * weight_above;
+            ptrdiff_t offset_bottom = _ind.get()[0] * offset + idx_below;
+            ptrdiff_t offset_top = min(offset_bottom + 1, max_idx);
+
+            U diff = a[offset_top] - a[offset_bottom];
+
+            if (weight_above < 0.5) {
+                ret = a[offset_bottom] + diff * weight_above;
+            } else {
+                ret = a[offset_top] - diff * (1 - weight_above);
+            }
             ''',
             'percentile_weightnening'
         )(indices, ap, ap.shape[-1] if ap.ndim > 1 else 0, ap.size, ret)


Test patch:
diff --git a/tests/cupy_tests/statistics_tests/test_order.py b/tests/cupy_tests/statistics_tests/test_order.py
--- a/tests/cupy_tests/statistics_tests/test_order.py
+++ b/tests/cupy_tests/statistics_tests/test_order.py
@@ -383,3 +383,29 @@ def test_ptp_all_nan(self, xp, dtype):
             pytest.skip()
         a = xp.array([float('nan'), float('nan')], dtype)
         return xp.ptp(a)
+
+
+# See gh-4607
+# "Magic" values used in this test were empirically found to result in
+# non-monotonicity for less accurate linear interpolation formulas
+@testing.parameterize(*testing.product({
+    'magic_value': (-29, -53, -207, -16373, -99999,)
+}))
+@testing.gpu
+class TestPercentileMonotonic(unittest.TestCase):
+
+    @testing.with_requires('numpy>=1.20')
+    @testing.for_float_dtypes(no_float16=True)
+    @testing.numpy_cupy_allclose()
+    def test_percentile_monotonic(self, dtype, xp):
+        a = testing.shaped_random((5,), xp, dtype)
+
+        a[0] = self.magic_value
+        a[1] = self.magic_value
+        q = xp.linspace(0, 100, 21)
+        percentiles = xp.percentile(a, q, interpolation='linear')
+
+        # Assert that percentile output increases monotonically
+        assert xp.all(xp.diff(percentiles) >= 0)
+
+        return percentiles