Repository: numpy/numpy

Issue:
Unexpected behaviour by numpy.pad() with mode="linear_ramp" and uint dtypes in 1.17
Since 1.17 `numpy.pad()` with `mode="linear_ramp"` seems to not use the array's edge value as the end value, but instead the value range maximum of the array's dtype. This seems to happen only for uint dtypes and only if `end_value > edge_value`.

### Reproducing code example:

```python
import sys, numpy as np; print(np.__version__, sys.version)

for dtype in ["uint8", "uint16", "uint32",
              "int8", "int16", "int32",
              "float16", "float32", "float64"]:
    arr = np.zeros((1,), dtype=dtype)
    arr_pad = np.pad(arr, (3, 0), mode="linear_ramp", end_values=3)
    print("%07s: %s" % (dtype, arr_pad))
```

Output for 1.17:
```bash
1.17.0 3.7.3 | packaged by conda-forge | (default, Jul  1 2019, 21:52:21) 
[GCC 7.3.0]
  uint8: [  3  87 172   0]
 uint16: [    3 21847 43692     0]
 uint32: [         3 1431655767 2863311532          0]
   int8: [3 2 1 0]
  int16: [3 2 1 0]
  int32: [3 2 1 0]
float16: [3. 2. 1. 0.]
float32: [3. 2. 1. 0.]
float64: [3. 2. 1. 0.]
```

Output for 1.16.4 (on a python 2.7 system):
```bash
('1.16.4', '2.7.15 |Anaconda, Inc.| (default, Oct 23 2018, 18:31:10) \n[GCC 7.3.0]')
  uint8: [3 2 1 0]
 uint16: [3 2 1 0]
 uint32: [3 2 1 0]
   int8: [3 2 1 0]
  int16: [3 2 1 0]
  int32: [3 2 1 0]
float16: [3. 2. 1. 0.]
float32: [3. 2. 1. 0.]
float64: [3. 2. 1. 0.]
```

### Further Observations

Using a 2D array does not fix the issue.

Using `end_values=(3, 0)` instead of `end_values=3` does not fix the issue.

However, the issue does not appear in 1.17 so long as the edge value is `>=3`:
```python
import sys, numpy as np; print(np.__version__, sys.version)

for dtype in ["uint8", "uint16", "uint32",
              "int8", "int16", "int32",
              "float16", "float32", "float64"]:
    arr = np.zeros((1,), dtype=dtype)
    arr = arr + 3
    arr_pad = np.pad(arr, (3, 0), mode="linear_ramp", end_values=(3, 0))
    print("%07s: %s" % (dtype, arr_pad))
```
Output:
```bash
1.17.0 3.7.3 | packaged by conda-forge | (default, Jul  1 2019, 21:52:21) 
[GCC 7.3.0]
  uint8: [3 3 3 3]
 uint16: [3 3 3 3]
 uint32: [3 3 3 3]
   int8: [3 3 3 3]
  int16: [3 3 3 3]
  int32: [3 3 3 3]
float16: [3. 3. 3. 3.]
float32: [3. 3. 3. 3.]
float64: [3. 3. 3. 3.]
```

### Related

Maybe this is related to issue #12293?

There is also a paragraph in the 1.17 release notes saying:

> Additionally, there are some corner cases with behavior changes:
> Padding max < min has changed to be more consistent across dtypes, but should not be relied upon.

This is in a section about changes to `clip`, but maybe it is still somehow related?


Discussion:
@lagru seems like we missed some corner case here, do you wan to have a look? Otherwise I can.
@seberg Thanks for the summoning. 

@aleju Thank you for the detailed bug report and the time you took investigating! I can reproduce this on 1.18.0.dev0+5f7f8e1 (3.7.3). This line

https://github.com/numpy/numpy/blob/d9b1e32cb8ef90d6b4a47853241db2a28146a57d/numpy/lib/arraypad.py#L73

is the culprit. In case the padded array is an unsigned type and the difference is negative we get an overflow because `stop` has the same type as the padded array. In case the edge value is >= 3 the difference is positive again and everything works as intended.

Simply casting `stop` to a float64 does fix this. Not sure if that is the most elegant/efficient way but I'll add a PR where this can be discussed.

Resolving patch:
diff --git a/numpy/lib/arraypad.py b/numpy/lib/arraypad.py
--- a/numpy/lib/arraypad.py
+++ b/numpy/lib/arraypad.py
@@ -17,66 +17,6 @@
 # Private utility functions.
 
 
-def _linear_ramp(ndim, axis, start, stop, size, reverse=False):
-    """
-    Create a linear ramp of `size` in `axis` with `ndim`.
-
-    This algorithm behaves like a vectorized version of `numpy.linspace`.
-    The resulting linear ramp is broadcastable to any array that matches the
-    ramp in `shape[axis]` and `ndim`.
-
-    Parameters
-    ----------
-    ndim : int
-        Number of dimensions of the resulting array. All dimensions except
-        the one specified by `axis` will have the size 1.
-    axis : int
-        The dimension that contains the linear ramp of `size`.
-    start : int or ndarray
-        The starting value(s) of the linear ramp. If given as an array, its
-        size must match `size`.
-    stop : int or ndarray
-        The stop value(s) (not included!) of the linear ramp. If given as an
-        array, its size must match `size`.
-    size : int
-        The number of elements in the linear ramp. If this argument is 0 the
-        dimensions of `ramp` will all be of length 1 except for the one given
-        by `axis` which will be 0.
-    reverse : bool
-        If False, increment in a positive fashion, otherwise decrement.
-
-    Returns
-    -------
-    ramp : ndarray
-        Output array of dtype np.float64 that in- or decrements along the given
-        `axis`.
-
-    Examples
-    --------
-    >>> _linear_ramp(ndim=2, axis=0, start=np.arange(3), stop=10, size=2)
-    array([[0. , 1. , 2. ],
-           [5. , 5.5, 6. ]])
-    >>> _linear_ramp(ndim=3, axis=0, start=2, stop=0, size=0)
-    array([], shape=(0, 1, 1), dtype=float64)
-    """
-    # Create initial ramp
-    ramp = np.arange(size, dtype=np.float64)
-    if reverse:
-        ramp = ramp[::-1]
-
-    # Make sure, that ramp is broadcastable
-    init_shape = (1,) * axis + (size,) + (1,) * (ndim - axis - 1)
-    ramp = ramp.reshape(init_shape)
-
-    if size != 0:
-        # And scale to given start and stop values
-        gain = (stop - start) / float(size)
-        ramp = ramp * gain
-        ramp += start
-
-    return ramp
-
-
 def _round_if_needed(arr, dtype):
     """
     Rounds arr inplace if destination dtype is integer.
@@ -269,17 +209,25 @@ def _get_linear_ramps(padded, axis, width_pair, end_value_pair):
     """
     edge_pair = _get_edges(padded, axis, width_pair)
 
-    left_ramp = _linear_ramp(
-        padded.ndim, axis, start=end_value_pair[0], stop=edge_pair[0],
-        size=width_pair[0], reverse=False
+    left_ramp = np.linspace(
+        start=end_value_pair[0],
+        stop=edge_pair[0].squeeze(axis),  # Dimensions is replaced by linspace
+        num=width_pair[0],
+        endpoint=False,
+        dtype=padded.dtype,
+        axis=axis,
     )
-    _round_if_needed(left_ramp, padded.dtype)
 
-    right_ramp = _linear_ramp(
-        padded.ndim, axis, start=end_value_pair[1], stop=edge_pair[1],
-        size=width_pair[1], reverse=True
+    right_ramp = np.linspace(
+        start=end_value_pair[1],
+        stop=edge_pair[1].squeeze(axis),  # Dimension is replaced by linspace
+        num=width_pair[1],
+        endpoint=False,
+        dtype=padded.dtype,
+        axis=axis,
     )
-    _round_if_needed(right_ramp, padded.dtype)
+    # Reverse linear space in appropriate dimension
+    right_ramp = right_ramp[_slice_at_axis(slice(None, None, -1), axis)]
 
     return left_ramp, right_ramp
 


Test patch:
diff --git a/numpy/lib/tests/test_arraypad.py b/numpy/lib/tests/test_arraypad.py
--- a/numpy/lib/tests/test_arraypad.py
+++ b/numpy/lib/tests/test_arraypad.py
@@ -2,7 +2,6 @@
 
 """
 from __future__ import division, absolute_import, print_function
-from itertools import chain
 
 import pytest
 
@@ -11,6 +10,12 @@
 from numpy.lib.arraypad import _as_pairs
 
 
+_numeric_dtypes = (
+    np.sctypes["uint"]
+    + np.sctypes["int"]
+    + np.sctypes["float"]
+    + np.sctypes["complex"]
+)
 _all_modes = {
     'constant': {'constant_values': 0},
     'edge': {},
@@ -715,6 +720,24 @@ def test_end_values(self):
         assert_equal(a[0, :], 0.)
         assert_equal(a[-1, :], 0.)
 
+    @pytest.mark.parametrize("dtype", _numeric_dtypes)
+    def test_negative_difference(self, dtype):
+        """
+        Check correct behavior of unsigned dtypes if there is a negative
+        difference between the edge to pad and `end_values`. Check both cases
+        to be independent of implementation. Test behavior for all other dtypes
+        in case dtype casting interferes with complex dtypes. See gh-14191.
+        """
+        x = np.array([3], dtype=dtype)
+        result = np.pad(x, 3, mode="linear_ramp", end_values=0)
+        expected = np.array([0, 1, 2, 3, 2, 1, 0], dtype=dtype)
+        assert_equal(result, expected)
+
+        x = np.array([0], dtype=dtype)
+        result = np.pad(x, 3, mode="linear_ramp", end_values=3)
+        expected = np.array([3, 2, 1, 0, 1, 2, 3], dtype=dtype)
+        assert_equal(result, expected)
+
 
 class TestReflect(object):
     def test_check_simple(self):
@@ -1307,13 +1330,7 @@ def test_memory_layout_persistence(mode):
     assert np.pad(x, 5, mode).flags["F_CONTIGUOUS"]
 
 
-@pytest.mark.parametrize("dtype", chain(
-    # Skip "other" dtypes as they are not supported by all modes
-    np.sctypes["int"],
-    np.sctypes["uint"],
-    np.sctypes["float"],
-    np.sctypes["complex"]
-))
+@pytest.mark.parametrize("dtype", _numeric_dtypes)
 @pytest.mark.parametrize("mode", _all_modes.keys())
 def test_dtype_persistence(dtype, mode):
     arr = np.zeros((3, 2, 1), dtype=dtype)