Repository: pydata/sparse

Issue:
No implementation for 'numpy.moveaxis' 
**Describe the bug**
There is currently no moveaxis implementation for sparse tensors.

**Minimal code to Reproduce**
```python
import sparse
import numpy as np

shape = (10, 10, 10)

tensor = sparse.random(shape)

np.moveaxis(tensor, 1, 0)
```

**Expected behavior**
This should permute the dimensions as documented in the [numpy.moveaxis](https://numpy.org/doc/1.18/reference/generated/numpy.moveaxis.html) function.

**System**
 - OS and version: osx
 - `sparse` version: '0.10.0' (also verified with '0.10.0+9.g24d26c8')
 - NumPy version: '1.18.1'
 - Numba version: '0.50.0'

**Additional context**
Moveaxis is used in TensorLy: https://github.com/tensorly/tensorly/issues/131
Currently, a workaround is being used: 
https://github.com/tensorly/tensorly/blob/f9be446ec9f7fd5827dcbab8efad58ead99d124e/tensorly/contrib/sparse/backend/numpy_backend.py#L31




Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/pydata/sparse/pull/362?src=pr&el=h1) Report
> Merging [#362](https://codecov.io/gh/pydata/sparse/pull/362?src=pr&el=desc) into [master](https://codecov.io/gh/pydata/sparse/commit/24d26c86eab309399308812e91c4799b4ba1d265&el=desc) will **decrease** coverage by `0.05%`.
> The diff coverage is `100.00%`.


```diff
@@            Coverage Diff             @@
##           master     #362      +/-   ##
==========================================
- Coverage   94.13%   94.07%   -0.06%     
==========================================
  Files          19       19              
  Lines        2420     2396      -24     
==========================================
- Hits         2278     2254      -24     
  Misses        142      142              
```



lgtm-com[bot]: This pull request **fixes 1 alert** when merging aa5d04906904aa62b614a99f2e06b214a3ab5158 into 24d26c86eab309399308812e91c4799b4ba1d265 - [view on LGTM.com](https://lgtm.com/projects/g/pydata/sparse/rev/pr-04d394646fd723da3e8c15dffcc668d20843bc72)

**fixed alerts:**

* 1 for Mismatch between signature and use of an overridden method

Patch:
diff --git a/docs/generated/sparse.rst b/docs/generated/sparse.rst
index fa65882..6acb7c5 100644
--- a/docs/generated/sparse.rst
+++ b/docs/generated/sparse.rst
@@ -54,6 +54,8 @@ API
     load_npz
 
     matmul
+    
+    moveaxis
 
     nanmax
 
diff --git a/sparse/_common.py b/sparse/_common.py
index efbf853..913000d 100644
--- a/sparse/_common.py
+++ b/sparse/_common.py
@@ -1,6 +1,8 @@
 import numpy as np
+from collections.abc import Iterable
+from sparse import COO
 
-from ._utils import check_compressed_axes
+from ._utils import check_compressed_axes, normalize_axis
 from ._coo import (
     clip,
     tensordot,
@@ -463,3 +465,58 @@ def asnumpy(a, dtype=None, order=None):
     if isinstance(a, SparseArray):
         a = a.todense()
     return np.array(a, dtype=dtype, copy=False, order=order)
+
+
+# this code was taken from numpy.moveaxis
+# (cf. numpy/core/numeric.py, lines 1340-1409, v1.18.4)
+# https://github.com/numpy/numpy/blob/v1.18.4/numpy/core/numeric.py#L1340-L1409
+def moveaxis(a, source, destination):
+    """
+    Move axes of an array to new positions.
+
+    Other axes remain in their original order.
+
+    Parameters
+    ----------
+    a : COO
+        The array whose axes should be reordered.
+    source : int or List[int]
+        Original positions of the axes to move. These must be unique.
+    destination : int or List[int]
+        Destination positions for each of the original axes. These must also be unique.
+
+    Returns
+    -------
+    COO
+        Array with moved axes.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> import sparse
+    >>> x = sparse.COO.from_numpy(np.ones((2, 3, 4, 5)))
+    >>> sparse.moveaxis(x, (0, 1), (2, 3))
+    <COO: shape=(4, 5, 2, 3), dtype=float64, nnz=120, fill_value=0.0>
+    """
+
+    if not isinstance(source, Iterable):
+        source = (source,)
+    if not isinstance(destination, Iterable):
+        destination = (destination,)
+
+    source = normalize_axis(source, a.ndim)
+    destination = normalize_axis(destination, a.ndim)
+
+    if len(source) != len(destination):
+        raise ValueError(
+            "`source` and `destination` arguments must have "
+            "the same number of elements"
+        )
+
+    order = [n for n in range(a.ndim) if n not in source]
+
+    for dest, src in sorted(zip(destination, source)):
+        order.insert(dest, src)
+
+    result = a.transpose(order)
+    return result


Test patch:
diff --git a/sparse/tests/test_coo.py b/sparse/tests/test_coo.py
index ae77f63..3acc3a4 100644
--- a/sparse/tests/test_coo.py
+++ b/sparse/tests/test_coo.py
@@ -233,6 +233,37 @@ def test_swapaxes_error(axis1, axis2):
         x.swapaxes(axis1, axis2)
 
 
+@pytest.mark.parametrize(
+    "source, destination",
+    [
+        [0, 1],
+        [2, 1],
+        [-2, 1],
+        [-2, -3],
+        [(0, 1), (2, 3)],
+        [(-1, 0), (0, 1)],
+        [(0, 1, 2), (2, 1, 0)],
+        [(0, 1, 2), (-2, -3, -1)],
+    ],
+)
+def test_moveaxis(source, destination):
+    x = sparse.random((2, 3, 4, 5), density=0.25)
+    y = x.todense()
+    xx = sparse.moveaxis(x, source, destination)
+    yy = np.moveaxis(y, source, destination)
+    assert_eq(xx, yy)
+
+
+@pytest.mark.parametrize(
+    "source, destination", [[0, -4], [(0, 5), (1, 2)], [(0, 1, 2), (2, 1)]]
+)
+def test_moveaxis_error(source, destination):
+    x = sparse.random((2, 3, 4), density=0.25)
+
+    with pytest.raises(ValueError):
+        sparse.moveaxis(x, source, destination)
+
+
 @pytest.mark.parametrize(
     "a,b",
     [