Repository: numpy/numpy

Issue:
rollaxis() has confusing error messages and should maybe interpret negative start argument differently (Trac #1441)
_Original ticket http://projects.scipy.org/numpy/ticket/1441 on 2010-03-31 by trac user kbasye, assigned to unknown._

The rollaxis() function in numeric.py allows negative arguments for both the axis and the start.  For the axis, the usual Python interpretation of a negative value applies; the axis is chosen by counting from the back.  For the start, however, -1 is not the last possible position, but the second to last.  I understand that fixing this is a change in the function's behavior, but at least the existing behavior is undocumented :-).  

In both cases, the error messages can be confusing if too-negative values are given because the error raised uses modified values of the arguments and suggests a legal range that's smaller than what the function can actually handle.

Since it's short and I've changed many lines, I'm just going to paste a suggested replacement here rather than a diff - hope that's OK.

```
def rollaxis(a, axis, start=0):
    """                                                                                                                                        
    Roll the specified axis until it lies in a given position.                                                                                 

    Parameters                                                                                                                                 
    ----------                                                                                                                                 
    a : ndarray                                                                                                                                
        Input array.                                                                                                                           
    axis : int                                                                                                                                 
        The axis to roll.  The positions of the other axes do not                                                                              
        change relative to one another.                                                                                                        
    start : int, optional                                                                                                                      
        The axis is rolled until it lies before this position.                                                                                 

    Returns                                                                                                                                    
    -------                                                                                                                                    
    res : ndarray                                                                                                                              
        Output array.                                                                                                                          

    See Also                                                                                                                                   
    --------                                                                                                                                   
    roll : Roll the elements of an array by a number of positions along a                                                                      
           given axis.                                                                                                                         

    Examples                                                                                                                                   
    --------                                                                                                                                   
    >>> a = np.ones((3,4,5,6))                                                                                                                 
    >>> rollaxis(a, 3, 1).shape                                                                                                                
    (3, 6, 4, 5)                                                                                                                               
    >>> rollaxis(a, 2).shape                                                                                                                   
    (5, 3, 4, 6)                                                                                                                               
    >>> rollaxis(a, 1, 4).shape                                                                                                                
    (3, 5, 6, 4)                                                                                                                               

    First axis becomes the last:                                                                                                               

    >>> rollaxis(a, 0, -1).shape                                                                                                               
    (4, 5, 6, 3)                                                                                                                               

    Last axis becomes the first:                                                                                                               

    >>> rollaxis(a, -1).shape                                                                                                                  
    (6, 3, 4, 5)                                                                                                                               

    """
    n = a.ndim
    orig_axis, orig_start = axis, start
    if axis < 0:
        axis += n
    if start < 0:
        start += n+1
    msg = 'rollaxis: %s (%d) must be >= %d and < %d'
    if not (0 <= axis < n):
        raise ValueError, msg % ('axis', orig_axis, -n, n)
    if not (0 <= start < n+1):
        raise ValueError, msg % ('start', orig_start, -n-1, n+1)
    if (axis < start): # removing axis below will shift the start position                                                                     
        start -= 1
    if axis == start:
        return a
    axes = range(0,n)
    axes.remove(axis)
    axes.insert(start, axis)
    return a.transpose(axes)
```



Discussion:
_@rgommers wrote on 2010-09-21_

The documentation in your proposed new version for "start" is actually consistent only with the current behavior. I found rollaxis confusing at first too, but when I read the docstring now it makes sense. 

Either way, since you're proposing to modify existing behavior, the correct place to discuss this on the mailing list. Since it will break a lot of existing code I don't think there's a lot of chance this gets accepted though.

Your extra examples and more accurate error message should be included I think even if the proposed change is rejected.

Milestone changed to `Unscheduled` by @rgommers on 2010-09-21

_trac user kbasye wrote on 2010-09-21_

Replying to [comment:1 rgommers]:

> The documentation in your proposed new version for "start" is actually consistent only with the current behavior. I found rollaxis confusing at first too, but when I read the docstring now it makes sense. 

I assume you are referring to the sentence describing the semantics of the 'start' argument: "The axis is rolled until it lies before this position."  

I actually don't think this is worded very well; I'd prefer 'at' rather than 'before'.  Assuming that the positions of the shape are numbered starting with 0 (which is how the 'axis' argument works), then, as the first example shows, using a 'start' value of 1 rolls the specified axis until it lies _at_ position 1.  To me, 'before' position 1 would have to mean 'at' position 0, which isn't what happens.  And with that change, the interpretation of '-1' for 'start' should mean the last position, which is how my change would work.  

I suppose one reading that would make the current version correct is "The axis is rolled until it lies before <<the axis currently at>> this position" - in which case you're correct that it doesn't accurately describe the new behavior I propose.  But then I don't think there is any good way to describe moving something to the last position (as in the third example), since there's no current axis at position 4 to be 'before' (as it were), which seems like another reason to prefer the 'at' version.

> Either way, since you're proposing to modify existing behavior, the correct place to discuss this on the mailing list. Since it will break a lot of existing code I don't think there's a lot of chance this gets accepted though.

Yeah, I know.  I'm somewhat tempted to see if folks would go for the addition of moveaxis() which would work this way.  Calling this function 'rollaxis' seems sort of odd, given that only one axis moves - compare the roll() function, which really does roll in the usual register sense.  BTW, all this came up because it seemed like there really should be a better way to get the first (or any other axis) into the last position than doing np.rollaxis(a, axis, len(a.shape)) but right now I don't think there is. I'll float it on the list and see what happens.

> Your extra examples and more accurate error message should be included I think even if the proposed change is rejected.

I'd settle for that.

And thanks for replying!

I'd love moveaxis(A, from, to). Rollaxis is perplexing for me as it doesn't seem to allow me to "roll" axes forward.

I think this does it:

```
def moveaxisto(A, src, dst):
    """Move an axis to a specific position in the shape.."""
    # normalize negative inds:
    src %= ndim(A)
    dst %= ndim(A)
    inds = range(ndim(A))
    del inds[srd]
    if dst > src:
        dst -= 1
    inds = inds[:dst] + [src] + inds[dst:]
    return transpose(A, inds)
```

is np.swapaxes what you are looking for?

Swapaxes isn't what I'm looking for: I want to take one axis and put it somewhere else. I often work with multidimensional arrays of 3D points (a stack of grids of points) and sometimes it makes sense for the 3 dimension to be first as in x, y, z = foo and sometimes it makes sense for it to be last as in passing a planar RGB image to imshow.

It seems like rollaxis sort of does this but not quite: it only seems to work in one direction. And swapaxes winds up transposing other axes as well so a planar 1024x768 image becomes an interleaved 768x1024 image.

@BenFrantzDale PRs are welcome.


Resolving patch:
diff --git a/numpy/core/fromnumeric.py b/numpy/core/fromnumeric.py
--- a/numpy/core/fromnumeric.py
+++ b/numpy/core/fromnumeric.py
@@ -518,7 +518,7 @@ def transpose(a, axes=None):
 
     See Also
     --------
-    rollaxis
+    moveaxis
     argsort
 
     Notes
diff --git a/numpy/core/numeric.py b/numpy/core/numeric.py
--- a/numpy/core/numeric.py
+++ b/numpy/core/numeric.py
@@ -1,6 +1,7 @@
 from __future__ import division, absolute_import, print_function
 
 import sys
+import operator
 import warnings
 import collections
 from numpy.core import multiarray
@@ -15,8 +16,10 @@
 if sys.version_info[0] >= 3:
     import pickle
     basestring = str
+    import builtins
 else:
     import cPickle as pickle
+    import __builtin__ as builtins
 
 loads = pickle.loads
 
@@ -31,15 +34,15 @@
     'ascontiguousarray', 'asfortranarray', 'isfortran', 'empty_like',
     'zeros_like', 'ones_like', 'correlate', 'convolve', 'inner', 'dot',
     'einsum', 'outer', 'vdot', 'alterdot', 'restoredot', 'roll',
-    'rollaxis', 'cross', 'tensordot', 'array2string', 'get_printoptions',
-    'set_printoptions', 'array_repr', 'array_str', 'set_string_function',
-    'little_endian', 'require', 'fromiter', 'array_equal', 'array_equiv',
-    'indices', 'fromfunction', 'isclose', 'load', 'loads', 'isscalar',
-    'binary_repr', 'base_repr', 'ones', 'identity', 'allclose',
-    'compare_chararrays', 'putmask', 'seterr', 'geterr', 'setbufsize',
-    'getbufsize', 'seterrcall', 'geterrcall', 'errstate', 'flatnonzero',
-    'Inf', 'inf', 'infty', 'Infinity', 'nan', 'NaN', 'False_', 'True_',
-    'bitwise_not', 'CLIP', 'RAISE', 'WRAP', 'MAXDIMS', 'BUFSIZE',
+    'rollaxis', 'moveaxis', 'cross', 'tensordot', 'array2string',
+    'get_printoptions', 'set_printoptions', 'array_repr', 'array_str',
+    'set_string_function', 'little_endian', 'require', 'fromiter',
+    'array_equal', 'array_equiv', 'indices', 'fromfunction', 'isclose', 'load',
+    'loads', 'isscalar', 'binary_repr', 'base_repr', 'ones', 'identity',
+    'allclose', 'compare_chararrays', 'putmask', 'seterr', 'geterr',
+    'setbufsize', 'getbufsize', 'seterrcall', 'geterrcall', 'errstate',
+    'flatnonzero', 'Inf', 'inf', 'infty', 'Infinity', 'nan', 'NaN', 'False_',
+    'True_', 'bitwise_not', 'CLIP', 'RAISE', 'WRAP', 'MAXDIMS', 'BUFSIZE',
     'ALLOW_THREADS', 'ComplexWarning', 'full', 'full_like', 'matmul',
     'shares_memory', 'may_share_memory', 'MAY_SHARE_BOUNDS', 'MAY_SHARE_EXACT',
     'TooHardError',
@@ -1422,6 +1425,7 @@ def rollaxis(a, axis, start=0):
 
     See Also
     --------
+    moveaxis : Move array axes to new positions.
     roll : Roll the elements of an array by a number of positions along a
         given axis.
 
@@ -1457,6 +1461,91 @@ def rollaxis(a, axis, start=0):
     return a.transpose(axes)
 
 
+def _validate_axis(axis, ndim, argname):
+    try:
+        axis = [operator.index(axis)]
+    except TypeError:
+        axis = list(axis)
+    axis = [a + ndim if a < 0 else a for a in axis]
+    if not builtins.all(0 <= a < ndim for a in axis):
+        raise ValueError('invalid axis for this array in `%s` argument' %
+                         argname)
+    if len(set(axis)) != len(axis):
+        raise ValueError('repeated axis in `%s` argument' % argname)
+    return axis
+
+
+def moveaxis(a, source, destination):
+    """
+    Move axes of an array to new positions.
+
+    Other axes remain in their original order.
+
+    .. versionadded::1.11.0
+
+    Parameters
+    ----------
+    a : np.ndarray
+        The array whose axes should be reordered.
+    source : int or sequence of int
+        Original positions of the axes to move. These must be unique.
+    destination : int or sequence of int
+        Destination positions for each of the original axes. These must also be
+        unique.
+
+    Returns
+    -------
+    result : np.ndarray
+        Array with moved axes. This array is a view of the input array.
+
+    See Also
+    --------
+    transpose: Permute the dimensions of an array.
+    swapaxes: Interchange two axes of an array.
+
+    Examples
+    --------
+
+    >>> x = np.zeros((3, 4, 5))
+    >>> np.moveaxis(x, 0, -1).shape
+    (4, 5, 3)
+    >>> np.moveaxis(x, -1, 0).shape
+    (5, 3, 4)
+
+    These all achieve the same result:
+
+    >>> np.transpose(x).shape
+    (5, 4, 3)
+    >>> np.swapaxis(x, 0, -1).shape
+    (5, 4, 3)
+    >>> np.moveaxis(x, [0, 1], [-1, -2]).shape
+    (5, 4, 3)
+    >>> np.moveaxis(x, [0, 1, 2], [-1, -2, -3]).shape
+    (5, 4, 3)
+
+    """
+    try:
+        # allow duck-array types if they define transpose
+        transpose = a.transpose
+    except AttributeError:
+        a = asarray(a)
+        transpose = a.transpose
+
+    source = _validate_axis(source, a.ndim, 'source')
+    destination = _validate_axis(destination, a.ndim, 'destination')
+    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 = transpose(order)
+    return result
+
+
 # fix hack in scipy which imports this function
 def _move_axis_to_0(a, axis):
     return rollaxis(a, axis, 0)


Test patch:
diff --git a/numpy/core/tests/test_numeric.py b/numpy/core/tests/test_numeric.py
--- a/numpy/core/tests/test_numeric.py
+++ b/numpy/core/tests/test_numeric.py
@@ -11,7 +11,8 @@
 from numpy.random import rand, randint, randn
 from numpy.testing import (
     TestCase, run_module_suite, assert_, assert_equal, assert_raises,
-    assert_array_equal, assert_almost_equal, assert_array_almost_equal, dec
+    assert_raises_regex, assert_array_equal, assert_almost_equal,
+    assert_array_almost_equal, dec
 )
 
 
@@ -2029,6 +2030,80 @@ def test_results(self):
             assert_(not res.flags['OWNDATA'])
 
 
+class TestMoveaxis(TestCase):
+    def test_move_to_end(self):
+        x = np.random.randn(5, 6, 7)
+        for source, expected in [(0, (6, 7, 5)),
+                                 (1, (5, 7, 6)),
+                                 (2, (5, 6, 7)),
+                                 (-1, (5, 6, 7))]:
+            actual = np.moveaxis(x, source, -1).shape
+            assert_(actual, expected)
+
+    def test_move_new_position(self):
+        x = np.random.randn(1, 2, 3, 4)
+        for source, destination, expected in [
+                (0, 1, (2, 1, 3, 4)),
+                (1, 2, (1, 3, 2, 4)),
+                (1, -1, (1, 3, 4, 2)),
+                ]:
+            actual = np.moveaxis(x, source, destination).shape
+            assert_(actual, expected)
+
+    def test_preserve_order(self):
+        x = np.zeros((1, 2, 3, 4))
+        for source, destination in [
+                (0, 0),
+                (3, -1),
+                (-1, 3),
+                ([0, -1], [0, -1]),
+                ([2, 0], [2, 0]),
+                (range(4), range(4)),
+                ]:
+            actual = np.moveaxis(x, source, destination).shape
+            assert_(actual, (1, 2, 3, 4))
+
+    def test_move_multiples(self):
+        x = np.zeros((0, 1, 2, 3))
+        for source, destination, expected in [
+                ([0, 1], [2, 3], (2, 3, 0, 1)),
+                ([2, 3], [0, 1], (2, 3, 0, 1)),
+                ([0, 1, 2], [2, 3, 0], (2, 3, 0, 1)),
+                ([3, 0], [1, 0], (0, 3, 1, 2)),
+                ([0, 3], [0, 1], (0, 3, 1, 2)),
+                ]:
+            actual = np.moveaxis(x, source, destination).shape
+            assert_(actual, expected)
+
+    def test_errors(self):
+        x = np.random.randn(1, 2, 3)
+        assert_raises_regex(ValueError, 'invalid axis .* `source`',
+                            np.moveaxis, x, 3, 0)
+        assert_raises_regex(ValueError, 'invalid axis .* `source`',
+                            np.moveaxis, x, -4, 0)
+        assert_raises_regex(ValueError, 'invalid axis .* `destination`',
+                            np.moveaxis, x, 0, 5)
+        assert_raises_regex(ValueError, 'repeated axis in `source`',
+                            np.moveaxis, x, [0, 0], [0, 1])
+        assert_raises_regex(ValueError, 'repeated axis in `destination`',
+                            np.moveaxis, x, [0, 1], [1, 1])
+        assert_raises_regex(ValueError, 'must have the same number',
+                            np.moveaxis, x, 0, [0, 1])
+        assert_raises_regex(ValueError, 'must have the same number',
+                            np.moveaxis, x, [0, 1], [0])
+
+    def test_array_likes(self):
+        x = np.ma.zeros((1, 2, 3))
+        result = np.moveaxis(x, 0, 0)
+        assert_(x.shape, result.shape)
+        assert_(isinstance(result, np.ma.MaskedArray))
+
+        x = [1, 2, 3]
+        result = np.moveaxis(x, 0, 0)
+        assert_(x, list(result))
+        assert_(isinstance(result, np.ndarray))
+
+
 class TestCross(TestCase):
     def test_2x2(self):
         u = [1, 2]