Repository: numpy/numpy

Issue:
ENH: expand_dims by multiple dimensions
_Background_: Quite frequently I would like ([and others](https://stackoverflow.com/questions/30031828/multiply-numpy-ndarray-with-1d-array-along-a-given-axis), it seems, too) to multiply a 1D array by an array of other arrays, say:
```python
import numpy as np
c = np.array([1, 2, 3])
E = np.ones((3, 3))
Ev = np.array([E, E, E])
# and I'd like to do this using NumPy broadcasting possibilities: 
c * Ev
```
Clearly, one option is to use comprehensions. Another is to expand `c` to have a suitable shape `(3,1,1)` by `expand_dims` or by `c[:, np.newaxis, np.newaxis]`.

_Enh_: allow `expand_dims` accept a tuple that shows where insert new dimensions.
If an int is passed, then do the same it does now.
If a tuple `(p, q, r, s, ...)` of length `d` is passed, then increase `ndim` by `d`, fill `shape` elements at the indices `p, q, r, s ...` with 1, fill the rest with the original array's dimensions.

Some examples that illustrate the idea:
```python
def new_expand_dims(a, axes):
    # if int is passed, retain the same behaviour
    if type(axes) == int:
        return np.expand_dims(a, axes)
    # insert axes to given indices
    for ax in sorted(axes):
        a = np.expand_dims(a, ax)
    return a


c = np.array([1, 3, 3])
csize = c.size

M = np.random.rand(8, 3, 4)


def test_int():
    assert new_expand_dims(c, 0).shape == (1, csize)


def test_consecutive_tpl():
    assert new_expand_dims(c, (0, 1)).shape == (1, 1, csize)


def test_non_consecutive_tpl():
    assert new_expand_dims(c, (0, 2)).shape == (1, csize, 1)


def test_302_tpl():
    assert new_expand_dims(c, (3, 0, 2)).shape == (1, csize, 1, 1)


def test_123_tpl():
    assert new_expand_dims(c, (1, 2, 3)).shape == (csize, 1, 1, 1)


def test_int_nd():
    assert new_expand_dims(M, 0).shape == (1, *M.shape)


def test_302_tpl_nd():
    assert new_expand_dims(M, (3, 0, 2)).shape == (1, 8, 1, 1, 3, 4)
```


### Numpy/Python version information:
1.15.3 3.7.1 (default, Oct 22 2018, 10:41:28) 
[GCC 8.2.1 20180831]



Discussion:
This gets a +1 from me - it's a natural extension of the axis arg in my opinion.

Your implementation isn't quite right for negative axes - I think a correct implementation would look something like
```python
def expand_dims(arr, axis):
    if type(axis) not in (tuple, list):
        try:
            axis = [operator.index(axis)]
        except TypeError:
            pass
    out_ndim = len(axis) + arr.ndim
    axis = normalize_axis_tuple(axis, out_ndim)

    sl = [slice(None)] * out_ndim
    for ax in axis:
        sl[ax] = np.newaxis
    return arr[tuple(sl)]
```

Or perhaps faster:
```python
def expand_dims(arr, axis):
    if type(axis) not in (tuple, list):
        try:
            axis = [operator.index(axis)]
        except TypeError:
            pass
    out_ndim = len(axis) + arr.ndim
    axis = normalize_axis_tuple(axis, out_ndim)

    shape_it = iter(arr.shape)
    shape = [1 if ax in axis else next(shape_it) for ax in range(out_ndim)]
    return arr.reshape(shape)
```

> Your implementation isn't quite right for negative axes - I think a correct implementation would look something like

I tried to show the idea and the possibility. Negative axes still confuse me, so if I knew how to implement it well, then I'd request a pull. 
@homocomputeris pull requests are always welcome, and if there are some smaller changes necessary they can still be discussed on the PR.
The `normalize_axis_tuple` function as far as I remember already handles negative ones, however, I think it might not be correct due to the fact that the output is larger then the input. I think it would have to be `normalize_axis_tuple(axis, in_ndim)` but then specifically allow `axis == in_ndim` (insert at end). Further, I think repeated axis should probably be allowed.

EDIT: Just to note that I think it is a natural and very good extension of the API and I hope to see a PR! :)
It would be nice to be able to append a number of dims without specifying their position, e.g.:
```python
>>> a = np.ones((3,))
>>> b = np.add_dims(a, 5)
>>> b.ndim
6
>>> b.shape
(3,1,1,1,1,1)
```
:+1: to the general idea, which nicely makes `expand_dims` more closely the opposite of `squeeze`. I think the implementation of @eric-wieser with negative axes counting relative to the output number of dimensions makes most sense. @homocomputeris - do you want to try to make a PR? You could just use the implementation suggested by @eric-wieser but add test cases and adjust the documentation (both are very important but also a little tedious and often the reason things end up not getting done...).

p.s. I'm a bit less enthusiastic about the `add_dims` idea; one can just do
```
b = a.reshape(a.shape + (1,) * 5)
```
> I'm a bit less enthusiastic about the add_dims idea; 

I feel the same way. Explicit is better than implicit, and it's not clear to me that inserting then at the end is obvious.
@seberg:

> I think it might not be correct due to the fact that the output is larger then the input.

That's handled by passing in `out_ndim` rather than `arr.ndim`
@eric-wieser sure, but that means we specifcy the new axis based on the output dimension instead of input dimension. That seems odd to me right now, although maybe mostly because `np.insert` uses the input dimension IIRC. But also `np.searchsorted` or so would return indices based on the input dimension (although that is a bit different from inserting dimensions).

Hmmm, I will have to think about it more:
```
input_expand_dims(np.ones((2, 3, 4)), [1, 3, 3, 3]).shape == ((2, 1, 3, 4, 1, 1, 1))
ouput_expand_dims(np.ones((2, 3, 4)), [1, 4, 5, 6]).shape == ((2, 1, 3, 4, 1, 1, 1))
```
I think it might be good to ping the list before we decide. But that doesn't mean we can't start to have a PR, it should be pretty straight forward to adept the code in either direction.
Note that stack and expand_dims already use the axis argument to refer to output and not input dimensions
The choice is between:
* normalize to `in_arr.ndim + 1`, allow duplicates
* normalize to `out_arr.ndim`, forbid duplicates
Ah, whatever, expand dims indeed uses output dimension with respect to negative ones already (and with respect to negative it is nicer I agree). Maybe for axis it is the better convention and it is just not "inserting" axes in a sense.
@mhvk I'd like to try to make a pull request, but I've lost the thread of this output/input dimensions discussion.
@homocomputeris - thanks! I think the conclusion was that the model proposed by @eric-wieser made most sense, i.e., `axis` refers to the output. So, what I wrote above holds: start with his code (see https://github.com/numpy/numpy/issues/12290#issuecomment-434184229) and add tests and documentation. 

p.s. If you need help, don't hesitate to ask - though I hope that our development guide is helpful: https://docs.scipy.org/doc/numpy/dev/

Resolving patch:
diff --git a/numpy/lib/shape_base.py b/numpy/lib/shape_base.py
--- a/numpy/lib/shape_base.py
+++ b/numpy/lib/shape_base.py
@@ -1,7 +1,6 @@
 from __future__ import division, absolute_import, print_function
 
 import functools
-import warnings
 
 import numpy.core.numeric as _nx
 from numpy.core.numeric import (
@@ -11,6 +10,7 @@
 from numpy.core.multiarray import normalize_axis_index
 from numpy.core import overrides
 from numpy.core import vstack, atleast_3d
+from numpy.core.numeric import normalize_axis_tuple
 from numpy.core.shape_base import _arrays_for_stack_dispatcher
 from numpy.lib.index_tricks import ndindex
 from numpy.matrixlib.defmatrix import matrix  # this raises all the right alarm bells
@@ -29,7 +29,7 @@
 
 
 def _make_along_axis_idx(arr_shape, indices, axis):
-	# compute dimensions to iterate over
+    # compute dimensions to iterate over
     if not _nx.issubdtype(indices.dtype, _nx.integer):
         raise IndexError('`indices` must be an integer array')
     if len(arr_shape) != indices.ndim:
@@ -517,22 +517,26 @@ def expand_dims(a, axis):
     Insert a new axis that will appear at the `axis` position in the expanded
     array shape.
 
-    .. note:: Previous to NumPy 1.13.0, neither ``axis < -a.ndim - 1`` nor
-       ``axis > a.ndim`` raised errors or put the new axis where documented.
-       Those axis values are now deprecated and will raise an AxisError in the
-       future.
-
     Parameters
     ----------
     a : array_like
         Input array.
-    axis : int
-        Position in the expanded axes where the new axis is placed.
+    axis : int or tuple of ints
+        Position in the expanded axes where the new axis (or axes) is placed.
+
+        .. deprecated:: 1.13.0
+            Passing an axis where ``axis > a.ndim`` will be treated as
+            ``axis == a.ndim``, and passing ``axis < -a.ndim - 1`` will
+            be treated as ``axis == 0``. This behavior is deprecated.
+
+        .. versionchanged:: 1.18.0
+            A tuple of axes is now supported.  Out of range axes as
+            described above are now forbidden and raise an `AxisError`.
 
     Returns
     -------
-    res : ndarray
-        View of `a` with the number of dimensions increased by one.
+    result : ndarray
+        View of `a` with the number of dimensions increased.
 
     See Also
     --------
@@ -542,11 +546,11 @@ def expand_dims(a, axis):
 
     Examples
     --------
-    >>> x = np.array([1,2])
+    >>> x = np.array([1, 2])
     >>> x.shape
     (2,)
 
-    The following is equivalent to ``x[np.newaxis,:]`` or ``x[np.newaxis]``:
+    The following is equivalent to ``x[np.newaxis, :]`` or ``x[np.newaxis]``:
 
     >>> y = np.expand_dims(x, axis=0)
     >>> y
@@ -554,13 +558,26 @@ def expand_dims(a, axis):
     >>> y.shape
     (1, 2)
 
-    >>> y = np.expand_dims(x, axis=1)  # Equivalent to x[:,np.newaxis]
+    The following is equivalent to ``x[:, np.newaxis]``:
+
+    >>> y = np.expand_dims(x, axis=1)
     >>> y
     array([[1],
            [2]])
     >>> y.shape
     (2, 1)
 
+    ``axis`` may also be a tuple:
+
+    >>> y = np.expand_dims(x, axis=(0, 1))
+    >>> y
+    array([[[1, 2]]])
+
+    >>> y = np.expand_dims(x, axis=(2, 0))
+    >>> y
+    array([[[1],
+            [2]]])
+
     Note that some examples may use ``None`` instead of ``np.newaxis``.  These
     are the same objects:
 
@@ -573,18 +590,16 @@ def expand_dims(a, axis):
     else:
         a = asanyarray(a)
 
-    shape = a.shape
-    if axis > a.ndim or axis < -a.ndim - 1:
-        # 2017-05-17, 1.13.0
-        warnings.warn("Both axis > a.ndim and axis < -a.ndim - 1 are "
-                      "deprecated and will raise an AxisError in the future.",
-                      DeprecationWarning, stacklevel=3)
-    # When the deprecation period expires, delete this if block,
-    if axis < 0:
-        axis = axis + a.ndim + 1
-    # and uncomment the following line.
-    # axis = normalize_axis_index(axis, a.ndim + 1)
-    return a.reshape(shape[:axis] + (1,) + shape[axis:])
+    if type(axis) not in (tuple, list):
+        axis = (axis,)
+
+    out_ndim = len(axis) + a.ndim
+    axis = normalize_axis_tuple(axis, out_ndim)
+
+    shape_it = iter(a.shape)
+    shape = [1 if ax in axis else next(shape_it) for ax in range(out_ndim)]
+
+    return a.reshape(shape)
 
 
 row_stack = vstack


Test patch:
diff --git a/numpy/lib/tests/test_shape_base.py b/numpy/lib/tests/test_shape_base.py
--- a/numpy/lib/tests/test_shape_base.py
+++ b/numpy/lib/tests/test_shape_base.py
@@ -289,14 +289,26 @@ def test_functionality(self):
             assert_(b.shape[axis] == 1)
             assert_(np.squeeze(b).shape == s)
 
-    def test_deprecations(self):
-        # 2017-05-17, 1.13.0
+    def test_axis_tuple(self):
+        a = np.empty((3, 3, 3))
+        assert np.expand_dims(a, axis=(0, 1, 2)).shape == (1, 1, 1, 3, 3, 3)
+        assert np.expand_dims(a, axis=(0, -1, -2)).shape == (1, 3, 3, 3, 1, 1)
+        assert np.expand_dims(a, axis=(0, 3, 5)).shape == (1, 3, 3, 1, 3, 1)
+        assert np.expand_dims(a, axis=(0, -3, -5)).shape == (1, 1, 3, 1, 3, 3)
+
+    def test_axis_out_of_range(self):
         s = (2, 3, 4, 5)
         a = np.empty(s)
-        with warnings.catch_warnings():
-            warnings.simplefilter("always")
-            assert_warns(DeprecationWarning, expand_dims, a, -6)
-            assert_warns(DeprecationWarning, expand_dims, a, 5)
+        assert_raises(np.AxisError, expand_dims, a, -6)
+        assert_raises(np.AxisError, expand_dims, a, 5)
+
+        a = np.empty((3, 3, 3))
+        assert_raises(np.AxisError, expand_dims, a, (0, -6))
+        assert_raises(np.AxisError, expand_dims, a, (0, 5))
+
+    def test_repeated_axis(self):
+        a = np.empty((3, 3, 3))
+        assert_raises(ValueError, expand_dims, a, axis=(1, 1))
 
     def test_subclasses(self):
         a = np.arange(10).reshape((2, 5))