Repository: pydata/sparse

Issue:
Reduce operations raise error for negative axis values
Reduce operations like `.sum()`, `.prod()` etc. raise exceptions when you pass a negative `axis` value:

```python
import sparse
sparse.random((40, 50)).sum(0)  # works
sparse.random((40, 50)).sum(1)  # works
sparse.random((40, 50)).sum(-1)  # fails, should be equivalent to .sum(1)
```

The traceback I get:

```
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "proj_dir/sparse/sparse/coo.py", line 1038, in prod
    return self.reduce(np.multiply, axis=axis, keepdims=keepdims, dtype=dtype)
  File "proj_dir/sparse/sparse/coo.py", line 771, in reduce
    a = self.transpose(neg_axis + axis)
  File "proj_dir/sparse/sparse/coo.py", line 1099, in transpose
    raise ValueError("repeated axis in transpose")
ValueError: repeated axis in transpose
```

Discussion:
codecov-io: # [Codecov](https://codecov.io/gh/pydata/sparse/pull/118?src=pr&el=h1) Report
> Merging [#118](https://codecov.io/gh/pydata/sparse/pull/118?src=pr&el=desc) into [master](https://codecov.io/gh/pydata/sparse/commit/97d5690116f499f43e79c3debfe7d7881c8b10ba?src=pr&el=desc) will **increase** coverage by `<.01%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/pydata/sparse/pull/118/graphs/tree.svg?height=150&width=650&token=H212u0Uxxw&src=pr)](https://codecov.io/gh/pydata/sparse/pull/118?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #118      +/-   ##
==========================================
+ Coverage   96.04%   96.04%   +<.01%     
==========================================
  Files          11       11              
  Lines        1896     1897       +1     
==========================================
+ Hits         1821     1822       +1     
  Misses         75       75
```

| Flag | Coverage Δ | |
|---|---|---|
| #python27 | `95.15% <100%> (ø)` | :arrow_up: |
| #python36 | `96.04% <100%> (ø)` | :arrow_up: |

| [Impacted Files](https://codecov.io/gh/pydata/sparse/pull/118?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [sparse/coo.py](https://codecov.io/gh/pydata/sparse/pull/118/diff?src=pr&el=tree#diff-c3BhcnNlL2Nvby5weQ==) | `95.05% <100%> (ø)` | :arrow_up: |
| [sparse/tests/test\_coo.py](https://codecov.io/gh/pydata/sparse/pull/118/diff?src=pr&el=tree#diff-c3BhcnNlL3Rlc3RzL3Rlc3RfY29vLnB5) | `99.83% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pydata/sparse/pull/118?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pydata/sparse/pull/118?src=pr&el=footer). Last update [97d5690...2e470ae](https://codecov.io/gh/pydata/sparse/pull/118?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/sparse/coo.py b/sparse/coo.py
index c305802..5100167 100644
--- a/sparse/coo.py
+++ b/sparse/coo.py
@@ -712,13 +712,15 @@ class COO(SparseArray, NDArrayOperatorsMixin):
         if not isinstance(axis, tuple):
             axis = (axis,)
 
+        axis = tuple(a if a >= 0 else a + self.ndim for a in axis)
+
         if set(axis) == set(range(self.ndim)):
             result = method.reduce(self.data, **kwargs)
             if self.nnz != self.size:
                 result = method(result, _zero_of_dtype(self.dtype)[()], **kwargs)
         else:
             axis = tuple(axis)
-            neg_axis = tuple(ax for ax in range(self.ndim) if ax not in axis)
+            neg_axis = tuple(ax for ax in range(self.ndim) if ax not in set(axis))
 
             a = self.transpose(neg_axis + axis)
             a = a.reshape((np.prod([self.shape[d] for d in neg_axis]),


Test patch:
diff --git a/sparse/tests/test_coo.py b/sparse/tests/test_coo.py
index f03742b..8c4a767 100644
--- a/sparse/tests/test_coo.py
+++ b/sparse/tests/test_coo.py
@@ -19,7 +19,7 @@ from sparse.utils import assert_eq, is_lexsorted, random_value_array
     ('prod', {}, {}),
     ('min', {}, {}),
 ])
-@pytest.mark.parametrize('axis', [None, 0, 1, 2, (0, 2)])
+@pytest.mark.parametrize('axis', [None, 0, 1, 2, (0, 2), -3, (1, -1)])
 @pytest.mark.parametrize('keepdims', [True, False])
 def test_reductions(reduction, axis, keepdims, kwargs, eqkwargs):
     x = sparse.random((2, 3, 4), density=.25)