Repository: numpy/numpy
Pull request: ENH: add signature argument to vectorize for vectorizing like generalized ufuncs
I brought this up on the mailing list today and received some support, so I
thought I would share a preliminary patch.

~~Still needs tests and more extensive documentation.~~

Example usage (from the docstring):

```
Vectorized calculation of Pearson correlation coefficient and its p-value:

>>> import scipy.stats
>>> pearsonr = np.vectorize(scipy.stats.pearsonr,
...                         signature='(n),(n)->(),()')
>>> pearsonr([[0, 1, 2, 3]], [[1, 2, 3, 4], [4, 3, 2, 1]])
(array([ 1., -1.]), array([ 0.,  0.]))

Vectorized convolution:

>>> convolve = np.vectorize(np.convolve, signature='(n),(m)->(k)')
>>> convolve(np.eye(4), [1, 2, 1])
array([[ 1.,  2.,  1.,  0.,  0.,  0.],
       [ 0.,  1.,  2.,  1.,  0.,  0.],
       [ 0.,  0.,  1.,  2.,  1.,  0.],
       [ 0.,  0.,  0.,  1.,  2.,  1.]])
```

A note on speed: a `vectorize` with `signature` has about 6x the fixed overhead of `vectorize` (60us vs 10us) and also a much larger overhead per loop (2us vs 100ns). I don't think these numbers will make any difference for the intended use case (only trivial Python functions runs in less than 10us) but I thought I would toss them out there anyways. I have yet to make any attempts at optimization.

``` python
# run on python 3.5.2
import numpy as np
import operator

vectorize_add = np.vectorize(operator.add, otypes='i')
guvectorize_add = np.vectorize(operator.add, signature='(),()->()', otypes='i')

x = np.ones(1000000, dtype=int)

%timeit vectorize_add(1, 1)
#100000 loops, best of 3: 10.6 µs per loop

%timeit guvectorize_add(1, 1)
#10000 loops, best of 3: 63.4 µs per loop

%timeit vectorize_add(x, x)
#10 loops, best of 3: 112 ms per loop

%timeit guvectorize_add(x, x)
#1 loop, best of 3: 2.22 s per loop
```

Also fixes #5868


Problem statement:
vectorize fails for zero-dimensioned arrays
The following used to work and return an empty 5 by 0 array.

``` python
np.vectorize(lambda x: x+1)(np.zeros([5,0]))
```

Now it stack traces:

```
/usr/lib/python2.7/dist-packages/numpy/lib/function_base.pyc in __call__(self, *args, **kwargs)
   1571             vargs.extend([kwargs[_n] for _n in names])
   1572 
-> 1573         return self._vectorize_call(func=func, args=vargs)
   1574 
   1575     def _get_ufunc_and_otypes(self, func, args):

/usr/lib/python2.7/dist-packages/numpy/lib/function_base.pyc in _vectorize_call(self, func, args)
   1631             _res = func()
   1632         else:
-> 1633             ufunc, otypes = self._get_ufunc_and_otypes(func=func, args=args)
   1634 
   1635             # Convert args to object arrays first

/usr/lib/python2.7/dist-packages/numpy/lib/function_base.pyc in _get_ufunc_and_otypes(self, func, args)
   1594             # Assumes that ufunc first evaluates the 0th elements in the input
   1595             # arrays (the input values are not checked to ensure this)
-> 1596             inputs = [asarray(_a).flat[0] for _a in args]
   1597             outputs = func(*inputs)
   1598 

IndexError: index 0 is out of bounds for axis 0 with size 0
> /usr/lib/python2.7/dist-packages/numpy/lib/function_base.py(1596)_get_ufunc_and_otypes()
   1595             # arrays (the input values are not checked to ensure this)
-> 1596             inputs = [asarray(_a).flat[0] for _a in args]
   1597             outputs = func(*inputs)
```

Seems to me that there should first be a check for empty arrays before attempting to see the return types of the function?

Hints from issue discussion:
The relevant source code lines were last modified over 3 years ago in [this](https://github.com/numpy/numpy/commit/127ae2f54d2c96fc7318fe12a3e2009e517828d1) commit. When (in what version) did this "use to work"?

This worked in numpy 1.6

I hope you are not implying that breaking what used to work is desirable behaviour just because version 1.6 is 3 years old.

Sure, this would be better to work again if possible (even if I don't like vectorize much ;)). The code seems only for output type detection (which may indeed be tricky with empty arguments).

So the workaround is:

```
np.vectorize(lambda x: x+1, otypes=[np.float64])(np.zeros([5,0]))
```

A possible fix could be to use a default-constructed element (e.g. _a.dtype.type()) and see what _func does with it. This would not require _a to have any elements, but it could potentially break if attempted on a nonempty _a. So we branch on _a.size to decide what to do.

``` diff
diff --git a/numpy/lib/function_base.py b/numpy/lib/function_base.py
--- a/numpy/lib/function_base.py
+++ b/numpy/lib/function_base.py
@@ -1773,7 +1773,13 @@ class vectorize(object):
             # the subsequent call when the ufunc is evaluated.
             # Assumes that ufunc first evaluates the 0th elements in the input
             # arrays (the input values are not checked to ensure this)
-            inputs = [asarray(_a).flat[0] for _a in args]
+            #
+            # If there are empty arrays, we instead use a
+            # default-constructed element of the input empty input
+            # type.
+            arrinputs = [asarray(_a) for _a in args]
+            inputs = [_a.flat[0] if _a.size else _a.dtype.type()
+                      for _a in arrinputs]
             outputs = func(*inputs)

             # Performance note: profiling indicates that -- for simple
```

No, of course unintentionally breaking things that work is rarely, if ever, OK. But that an issue takes 3 years to surface does help triage a 700+ issue list...

But getting back to `np.vectorize`, I think you proposed solution would end up doing something like:

```
>>> np.int + 1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'type' and 'int'
```

or am I reading it wrong?

Actually, it seems to me it didn't really worked in 1.6, even if it did not raise an error: `np.vectorize` needs to figure out what the dtype of its return array should be, and to do so it calls the provided python function on its first input... But if the array is zero-sized there's nothing to pass in, and no return to check.

With "on the face of ambiguity, refuse the temptation to choose" in mind, raising an error seems like a very sensible option. It could probably use a more informative one, including a hint that, if you need it to work on empty arrays, the return type should be explicitly specified, as @seberg has demonstrated.

> Actually, it seems to me it didn't really worked in 1.6, even if it did not raise an error

I can't easily check on numpy 1.6 now because it's gone with my OS upgrade. But whatever it did was better than stack tracing. I am guessing the type might have been irrelevant because it was for an empty array, so there were no elements whose type could have clashed with whatever other thing I was doing.

> or am I reading it wrong?

I think so. I did not mean np.int, but rather np.int(). Note the function call.

> if you need it to work on empty arrays, the return type should be explicitly specified, as @seberg has demonstrated.

This is not the best way to handle empty arrays, because I don't know a priori if I need it to work on empty arrays or not. I am guessing you are familiar with [the following](http://ftp.cs.wisc.edu/Approx/empty.pdf)? The Matlab folks long ago figured that handing empty arrays transparently is a very useful and natural thing to do. This is especially important in light of boolean indexing, which can produce empty arrays at any time.

The special-casing you suggest would effectively mean that np.vectorize would always have to be called with the type of the output argument, which seems like an undue burden upon the caller. An alternative could be to assume that the output type is NoneType and only change this default if there is anything to change it to.

> The Matlab folks

Lest my comment be ignored because we all hate Matlab, let me say it this way instead: Carl de Boor, the guy whose splines you might have heard of, thinks we should treat empty arrays consistently.

You're being a bit aggressive and fighty here. Do you think you could make
an effort to dial that down a little and assume good faith? We're all on
the same side etc.
.
NumPy does handle empty arrays consistently. And part of that consistency
is that it keeps track of, and cares about, the type of the elements that
would be in the array if they were present -- empty arrays have a dtype
just like any other array. What we're worrying about is that we don't want
np.vectorize to be creating functions where, e.g., they always return an
array of strings except sometimes (for an empty input) they return an array
of floats. That would be, well, inconsistent :-). And the only way to avoid
it AFAICT is to add arguments to vectorize that tell it what types this
function works with. (This might be a good idea in any case, e.g. if you
want floats as input you could say so and then let the ufunc machinery do
error checking and casting.)
 On May 14, 2015 2:00 PM, "jordigh" notifications@github.com wrote:

> The Matlab folks
> 
> Lest my comment be ignored because we all hate Matlab, let me say it this
> way instead: Carl de Boor, the guy whose splines you might have heard of,
> thinks we should treat empty arrays consistently.
> 
> —
> Reply to this email directly or view it on GitHub
> https://github.com/numpy/numpy/issues/5868#issuecomment-102168473.

> You're being a bit aggressive and fighty here. 

Sorry. This is not my first time running into numpy bugs with empty arrays, and being told that I should check functions calls to make sure I'm not passing it an empty array is very frustrating. I'll try to be nicer. Let me know if I fail at being nicer.

I really value what you are doing, and I hope we can all make numpy better.

> And the only way to avoid it AFAICT is to add arguments to vectorize that tell it what types this
> function works with. 

A user could just say the dtype is one thing but their function be returning a different type. So the only real way to avoid this is static type declarations. Since we're not going to get those except optionally in Python 3.5, the next best thing is to call the function on default arguments and hope the function to be vectorised is returning the same type regardless of inputs. Or maybe we could introduce a new numpy type for empty arrays?

@jordigh I've opened PR https://github.com/numpy/numpy/pull/5880 that applies your patch.

Commit message: ENH: add signature argument to vectorize for generalized ufuncs

Example usage (from the docstring):

    Vectorized convolution:

    >>> convolve = np.vectorize(np.convolve, signature='(n),(m)->(k)')
    >>> convolve(np.eye(4), [1, 2, 1])
    array([[ 1.,  2.,  1.,  0.,  0.,  0.],
           [ 0.,  1.,  2.,  1.,  0.,  0.],
           [ 0.,  0.,  1.,  2.,  1.,  0.],
           [ 0.,  0.,  0.,  1.,  2.,  1.]])

Patch to review:
diff --git a/numpy/lib/function_base.py b/numpy/lib/function_base.py
index cb1a04f47b54..beaa77576cc7 100644
--- a/numpy/lib/function_base.py
+++ b/numpy/lib/function_base.py
@@ -1,9 +1,10 @@
 from __future__ import division, absolute_import, print_function
 
-import warnings
-import sys
 import collections
 import operator
+import re
+import sys
+import warnings
 
 import numpy as np
 import numpy.core.numeric as _nx
@@ -24,7 +25,7 @@
 from numpy.lib.twodim_base import diag
 from .utils import deprecate
 from numpy.core.multiarray import (
-    _insert, add_docstring, digitize, bincount, 
+    _insert, add_docstring, digitize, bincount,
     interp as compiled_interp, interp_complex as compiled_interp_complex
     )
 from numpy.core.umath import _add_newdoc_ufunc as add_newdoc_ufunc
@@ -1792,7 +1793,7 @@ def interp(x, xp, fp, left=None, right=None, period=None):
 
     Returns
     -------
-    y : float or complex (corresponding to fp) or ndarray 
+    y : float or complex (corresponding to fp) or ndarray
         The interpolated values, same shape as `x`.
 
     Raises
@@ -1899,7 +1900,7 @@ def interp(x, xp, fp, left=None, right=None, period=None):
             return interp_func(x, xp, fp, left, right)
         else:
             return interp_func(x, xp, fp, left, right).item()
-                
+
 def angle(z, deg=0):
     """
     Return the angle of the complex argument.
@@ -2242,17 +2243,116 @@ def disp(mesg, device=None, linefeed=True):
     return
 
 
+# See http://docs.scipy.org/doc/numpy/reference/c-api.generalized-ufuncs.html
+_DIMENSION_NAME = r'\w+'
+_CORE_DIMENSION_LIST = '(?:%s(?:,%s)*)?' % (_DIMENSION_NAME, _DIMENSION_NAME)
+_ARGUMENT = r'\(%s\)' % _CORE_DIMENSION_LIST
+_ARGUMENT_LIST = '%s(?:,%s)*' % (_ARGUMENT, _ARGUMENT)
+_SIGNATURE = '^%s->%s$' % (_ARGUMENT_LIST, _ARGUMENT_LIST)
+
+
+def _parse_gufunc_signature(signature):
+    """
+    Parse string signatures for a generalized universal function.
+
+    Arguments
+    ---------
+    signature : string
+        Generalized universal function signature, e.g., ``(m,n),(n,p)->(m,p)``
+        for ``np.matmul``.
+
+    Returns
+    -------
+    Tuple of input and output core dimensions parsed from the signature, each
+    of the form List[Tuple[str, ...]].
+    """
+    if not re.match(_SIGNATURE, signature):
+        raise ValueError(
+            'not a valid gufunc signature: {}'.format(signature))
+    return tuple([tuple(re.findall(_DIMENSION_NAME, arg))
+                  for arg in re.findall(_ARGUMENT, arg_list)]
+                 for arg_list in signature.split('->'))
+
+
+def _update_dim_sizes(dim_sizes, arg, core_dims):
+    """
+    Incrementally check and update core dimension sizes for a single argument.
+
+    Arguments
+    ---------
+    dim_sizes : Dict[str, int]
+        Sizes of existing core dimensions. Will be updated in-place.
+    arg : ndarray
+        Argument to examine.
+    core_dims : Tuple[str, ...]
+        Core dimensions for this argument.
+    """
+    if not core_dims:
+        return
+
+    num_core_dims = len(core_dims)
+    if arg.ndim < num_core_dims:
+        raise ValueError(
+            '%d-dimensional argument does not have enough '
+            'dimensions for all core dimensions %r'
+            % (arg.ndim, core_dims))
+
+    core_shape = arg.shape[-num_core_dims:]
+    for dim, size in zip(core_dims, core_shape):
+        if dim in dim_sizes:
+            if size != dim_sizes[dim]:
+                raise ValueError(
+                    'inconsistent size for core dimension %r: %r vs %r'
+                    % (dim, size, dim_sizes[dim]))
+        else:
+            dim_sizes[dim] = size
+
+
+def _parse_input_dimensions(args, input_core_dims):
+    """
+    Parse broadcast and core dimensions for vectorize with a signature.
+
+    Arguments
+    ---------
+    args : Tuple[ndarray, ...]
+        Tuple of input arguments to examine.
+    input_core_dims : List[Tuple[str, ...]]
+        List of core dimensions corresponding to each input.
+
+    Returns
+    -------
+    broadcast_shape : Tuple[int, ...]
+        Common shape to broadcast all non-core dimensions to.
+    dim_sizes : Dict[str, int]
+        Common sizes for named core dimensions.
+    """
+    broadcast_args = []
+    dim_sizes = {}
+    for arg, core_dims in zip(args, input_core_dims):
+        _update_dim_sizes(dim_sizes, arg, core_dims)
+        broadcast_args.append(arg[(Ellipsis,) + len(core_dims) * (0,)])
+    broadcast_shape = np.lib.stride_tricks._broadcast_shape(*broadcast_args)
+    return broadcast_shape, dim_sizes
+
+
+def _calculate_shapes(broadcast_shape, dim_sizes, list_of_core_dims):
+    """Helper for calculating broadcast shapes with core dimensions."""
+    return [broadcast_shape + tuple(dim_sizes[dim] for dim in core_dims)
+            for core_dims in list_of_core_dims]
+
+
 class vectorize(object):
     """
-    vectorize(pyfunc, otypes='', doc=None, excluded=None, cache=False)
+    vectorize(pyfunc, otypes=None, doc=None, excluded=None, cache=False,
+              signature=None)
 
     Generalized function class.
 
-    Define a vectorized function which takes a nested sequence
-    of objects or numpy arrays as inputs and returns a
-    numpy array as output. The vectorized function evaluates `pyfunc` over
-    successive tuples of the input arrays like the python map function,
-    except it uses the broadcasting rules of numpy.
+    Define a vectorized function which takes a nested sequence of objects or
+    numpy arrays as inputs and returns an single or tuple of numpy array as
+    output. The vectorized function evaluates `pyfunc` over successive tuples
+    of the input arrays like the python map function, except it uses the
+    broadcasting rules of numpy.
 
     The data type of the output of `vectorized` is determined by calling
     the function with the first element of the input.  This can be avoided
@@ -2282,6 +2382,15 @@ class vectorize(object):
 
         .. versionadded:: 1.7.0
 
+    signature : string, optional
+        Generalized universal function signature, e.g., ``(m,n),(n)->(m)`` for
+        vectorized matrix-vector multiplication. If provided, ``pyfunc`` will
+        be called with (and expected to return) arrays with shapes given by the
+        size of corresponding core dimensions. By default, ``pyfunc`` is
+        assumed to take scalars as input and output.
+
+        .. versionadded:: 1.12.0
+
     Returns
     -------
     vectorized : callable
@@ -2301,7 +2410,7 @@ class vectorize(object):
     array([3, 4, 1, 2])
 
     The docstring is taken from the input function to `vectorize` unless it
-    is specified
+    is specified:
 
     >>> vfunc.__doc__
     'Return a-b if a>b, otherwise return a+b'
@@ -2310,7 +2419,7 @@ class vectorize(object):
     'Vectorized `myfunc`'
 
     The output type is determined by evaluating the first element of the input,
-    unless it is specified
+    unless it is specified:
 
     >>> out = vfunc([1, 2, 3, 4], 2)
     >>> type(out[0])
@@ -2340,6 +2449,25 @@ class vectorize(object):
     >>> vpolyval([1, 2, 3], x=[0, 1])
     array([3, 6])
 
+    The `signature` argument allows for vectorizing functions that act on
+    non-scalar arrays of fixed length. For example, you can use it for a
+    vectorized calculation of Pearson correlation coefficient and its p-value:
+
+    >>> import scipy.stats
+    >>> pearsonr = np.vectorize(scipy.stats.pearsonr,
+    ...                         signature='(n),(n)->(),()')
+    >>> pearsonr([[0, 1, 2, 3]], [[1, 2, 3, 4], [4, 3, 2, 1]])
+    (array([ 1., -1.]), array([ 0.,  0.]))
+
+    Or for a vectorized convolution:
+
+    >>> convolve = np.vectorize(np.convolve, signature='(n),(m)->(k)')
+    >>> convolve(np.eye(4), [1, 2, 1])
+    array([[ 1.,  2.,  1.,  0.,  0.,  0.],
+           [ 0.,  1.,  2.,  1.,  0.,  0.],
+           [ 0.,  0.,  1.,  2.,  1.,  0.],
+           [ 0.,  0.,  0.,  1.,  2.,  1.]])
+
     See Also
     --------
     frompyfunc : Takes an arbitrary Python function and returns a ufunc
@@ -2359,12 +2487,17 @@ class vectorize(object):
     The new keyword argument interface and `excluded` argument support
     further degrades performance.
 
+    References
+    ----------
+    .. [1] NumPy Reference, section `Generalized Universal Function API
+           <http://docs.scipy.org/doc/numpy/reference/c-api.generalized-ufuncs.html>`_.
     """
 
-    def __init__(self, pyfunc, otypes='', doc=None, excluded=None,
-                 cache=False):
+    def __init__(self, pyfunc, otypes=None, doc=None, excluded=None,
+                 cache=False, signature=None):
         self.pyfunc = pyfunc
         self.cache = cache
+        self.signature = signature
         self._ufunc = None    # Caching to improve default performance
 
         if doc is None:
@@ -2373,22 +2506,25 @@ def __init__(self, pyfunc, otypes='', doc=None, excluded=None,
             self.__doc__ = doc
 
         if isinstance(otypes, str):
-            self.otypes = otypes
-            for char in self.otypes:
+            for char in otypes:
                 if char not in typecodes['All']:
-                    raise ValueError(
-                        "Invalid otype specified: %s" % (char,))
+                    raise ValueError( "Invalid otype specified: %s" % (char,))
         elif iterable(otypes):
-            self.otypes = ''.join([_nx.dtype(x).char for x in otypes])
-        else:
-            raise ValueError(
-                "Invalid otype specification")
+            otypes = ''.join([_nx.dtype(x).char for x in otypes])
+        elif otypes is not None:
+            raise ValueError("Invalid otype specification")
+        self.otypes = otypes
 
         # Excluded variable support
         if excluded is None:
             excluded = set()
         self.excluded = set(excluded)
 
+        if signature is not None:
+            self._in_and_out_core_dims = _parse_gufunc_signature(signature)
+        else:
+            self._in_and_out_core_dims = None
+
     def __call__(self, *args, **kwargs):
         """
         Return arrays with the results of `pyfunc` broadcast (vectorized) over
@@ -2425,7 +2561,7 @@ def _get_ufunc_and_otypes(self, func, args):
         if not args:
             raise ValueError('args can not be empty')
 
-        if self.otypes:
+        if self.otypes is not None:
             otypes = self.otypes
             nout = len(otypes)
 
@@ -2477,24 +2613,78 @@ def _func(*vargs):
 
     def _vectorize_call(self, func, args):
         """Vectorized call to `func` over positional `args`."""
-        if not args:
-            _res = func()
+        if self.signature is not None:
+            res = self._vectorize_call_with_signature(func, args)
+        elif not args:
+            res = func()
         else:
             ufunc, otypes = self._get_ufunc_and_otypes(func=func, args=args)
 
             # Convert args to object arrays first
-            inputs = [array(_a, copy=False, subok=True, dtype=object)
-                      for _a in args]
+            inputs = [array(a, copy=False, subok=True, dtype=object)
+                      for a in args]
 
             outputs = ufunc(*inputs)
 
             if ufunc.nout == 1:
-                _res = array(outputs,
-                             copy=False, subok=True, dtype=otypes[0])
+                res = array(outputs, copy=False, subok=True, dtype=otypes[0])
             else:
-                _res = tuple([array(_x, copy=False, subok=True, dtype=_t)
-                              for _x, _t in zip(outputs, otypes)])
-        return _res
+                res = tuple([array(x, copy=False, subok=True, dtype=t)
+                             for x, t in zip(outputs, otypes)])
+        return res
+
+    def _vectorize_call_with_signature(self, func, args):
+        """Vectorized call over positional argument with a signature."""
+        input_core_dims, output_core_dims = self._in_and_out_core_dims
+
+        if len(args) != len(input_core_dims):
+            raise TypeError('wrong number of positional arguments: '
+                            'expected %r, got %r'
+                            % (len(input_core_dims), len(args)))
+        args = tuple(asanyarray(arg) for arg in args)
+
+        # consider checking for size 0 inputs?
+        broadcast_shape, dim_sizes = _parse_input_dimensions(
+            args, input_core_dims)
+        input_shapes = _calculate_shapes(broadcast_shape, dim_sizes,
+                                         input_core_dims)
+        args = [np.broadcast_to(arg, shape, subok=True)
+                for arg, shape in zip(args, input_shapes)]
+
+        outputs = None
+        otypes = self.otypes
+        nout = len(output_core_dims)
+
+        for index in np.ndindex(*broadcast_shape):
+            results = func(*(arg[index] for arg in args))
+
+            n_results = len(results) if isinstance(results, tuple) else 1
+
+            if nout != n_results:
+                raise ValueError(
+                    'wrong number of outputs from pyfunc: expected %r, got %r'
+                    % (nout, n_results))
+
+            if nout == 1:
+                results = (results,)
+
+            if outputs is None:
+                for result, core_dims in zip(results, output_core_dims):
+                    _update_dim_sizes(dim_sizes, result, core_dims)
+
+                output_shapes = _calculate_shapes(
+                    broadcast_shape, dim_sizes, output_core_dims)
+
+                if otypes is None:
+                    otypes = [asarray(result).dtype for result in results]
+
+                outputs = tuple(np.empty(shape, dtype=dtype)
+                                for shape, dtype in zip(output_shapes, otypes))
+
+            for output, result in zip(outputs, results):
+                output[index] = result
+
+        return outputs[0] if nout == 1 else outputs
 
 
 def cov(m, y=None, rowvar=True, bias=False, ddof=None, fweights=None,
diff --git a/numpy/lib/tests/test_function_base.py b/numpy/lib/tests/test_function_base.py
index 4535c1e7febc..b842cde8d64b 100644
--- a/numpy/lib/tests/test_function_base.py
+++ b/numpy/lib/tests/test_function_base.py
@@ -1,5 +1,6 @@
 from __future__ import division, absolute_import, print_function
 
+import operator
 import warnings
 import sys
 
@@ -1013,8 +1014,12 @@ def foo():
 
     def test_assigning_docstring(self):
         def foo(x):
+            """Original documentation"""
             return x
 
+        f = vectorize(foo)
+        assert_equal(f.__doc__, foo.__doc__)
+
         doc = "Provided documentation"
         f = vectorize(foo, doc=doc)
         assert_equal(f.__doc__, doc)
@@ -1069,6 +1074,129 @@ def test_otypes(self):
         x = np.arange(5)
         assert_array_equal(f(x), x)
 
+    def test_parse_gufunc_signature(self):
+        assert_equal(nfb._parse_gufunc_signature('(x)->()'), ([('x',)], [()]))
+        assert_equal(nfb._parse_gufunc_signature('(x,y)->()'),
+                     ([('x', 'y')], [()]))
+        assert_equal(nfb._parse_gufunc_signature('(x),(y)->()'),
+                     ([('x',), ('y',)], [()]))
+        assert_equal(nfb._parse_gufunc_signature('(x)->(y)'),
+                     ([('x',)], [('y',)]))
+        assert_equal(nfb._parse_gufunc_signature('(x)->(y),()'),
+                     ([('x',)], [('y',), ()]))
+        assert_equal(nfb._parse_gufunc_signature('(),(a,b,c),(d)->(d,e)'),
+                     ([(), ('a', 'b', 'c'), ('d',)], [('d', 'e')]))
+        with assert_raises(ValueError):
+            nfb._parse_gufunc_signature('(x)(y)->()')
+        with assert_raises(ValueError):
+            nfb._parse_gufunc_signature('(x),(y)->')
+        with assert_raises(ValueError):
+            nfb._parse_gufunc_signature('((x))->(x)')
+
+    def test_signature_simple(self):
+        def addsubtract(a, b):
+            if a > b:
+                return a - b
+            else:
+                return a + b
+
+        f = vectorize(addsubtract, signature='(),()->()')
+        r = f([0, 3, 6, 9], [1, 3, 5, 7])
+        assert_array_equal(r, [1, 6, 1, 2])
+
+    def test_siganture_mean_last(self):
+        def mean(a):
+            return a.mean()
+
+        f = vectorize(mean, signature='(n)->()')
+        r = f([[1, 3], [2, 4]])
+        assert_array_equal(r, [2, 3])
+
+    def test_siganture_center(self):
+        def center(a):
+            return a - a.mean()
+
+        f = vectorize(center, signature='(n)->(n)')
+        r = f([[1, 3], [2, 4]])
+        assert_array_equal(r, [[-1, 1], [-1, 1]])
+
+    def test_siganture_two_outputs(self):
+        f = vectorize(lambda x: (x, x), signature='()->(),()')
+        r = f([1, 2, 3])
+        assert_(isinstance(r, tuple) and len(r) == 2)
+        assert_array_equal(r[0], [1, 2, 3])
+        assert_array_equal(r[1], [1, 2, 3])
+
+    def test_siganture_outer(self):
+        f = vectorize(np.outer, signature='(a),(b)->(a,b)')
+        r = f([1, 2], [1, 2, 3])
+        assert_array_equal(r, [[1, 2, 3], [2, 4, 6]])
+
+        r = f([[[1, 2]]], [1, 2, 3])
+        assert_array_equal(r, [[[[1, 2, 3], [2, 4, 6]]]])
+
+        r = f([[1, 0], [2, 0]], [1, 2, 3])
+        assert_array_equal(r, [[[1, 2, 3], [0, 0, 0]],
+                               [[2, 4, 6], [0, 0, 0]]])
+
+        r = f([1, 2], [[1, 2, 3], [0, 0, 0]])
+        assert_array_equal(r, [[[1, 2, 3], [2, 4, 6]],
+                               [[0, 0, 0], [0, 0, 0]]])
+
+    def test_siganture_computed_size(self):
+        f = vectorize(lambda x: x[:-1], signature='(n)->(m)')
+        r = f([1, 2, 3])
+        assert_array_equal(r, [1, 2])
+
+        r = f([[1, 2, 3], [2, 3, 4]])
+        assert_array_equal(r, [[1, 2], [2, 3]])
+
+    def test_signature_excluded(self):
+
+        def foo(a, b=1):
+            return a + b
+
+        f = vectorize(foo, signature='()->()', excluded={'b'})
+        assert_array_equal(f([1, 2, 3]), [2, 3, 4])
+        assert_array_equal(f([1, 2, 3], b=0), [1, 2, 3])
+
+    def test_signature_otypes(self):
+        f = vectorize(lambda x: x, signature='(n)->(n)', otypes=['float64'])
+        r = f([1, 2, 3])
+        assert_equal(r.dtype, np.dtype('float64'))
+        assert_array_equal(r, [1, 2, 3])
+
+    def test_signature_invalid_inputs(self):
+        f = vectorize(operator.add, signature='(n),(n)->(n)')
+        with assert_raises_regex(TypeError, 'wrong number of positional'):
+            f([1, 2])
+        with assert_raises_regex(
+                ValueError, 'does not have enough dimensions'):
+            f(1, 2)
+        with assert_raises_regex(
+                ValueError, 'inconsistent size for core dimension'):
+            f([1, 2], [1, 2, 3])
+
+        f = vectorize(operator.add, signature='()->()')
+        with assert_raises_regex(TypeError, 'wrong number of positional'):
+            f(1, 2)
+
+    def test_signature_invalid_outputs(self):
+
+        f = vectorize(lambda x: x[:-1], signature='(n)->(n)')
+        with assert_raises_regex(
+                ValueError, 'inconsistent size for core dimension'):
+            f([1, 2, 3])
+
+        f = vectorize(lambda x: x, signature='()->(),()')
+        with assert_raises_regex(ValueError, 'wrong number of outputs'):
+            f(1)
+
+        f = vectorize(lambda x: (x, x), signature='()->()')
+        with assert_raises_regex(ValueError, 'wrong number of outputs'):
+            f([1, 2])
+
+
 
 class TestDigitize(TestCase):
 


Code review comments:
diff_hunk: @@ -2242,17 +2243,116 @@ def disp(mesg, device=None, linefeed=True):
     return
 
 
+# See http://docs.scipy.org/doc/numpy/reference/c-api.generalized-ufuncs.html
+_DIMENSION_NAME = r'\w+'
+_CORE_DIMENSION_LIST = '(?:%s(?:,%s)*)?' % (_DIMENSION_NAME, _DIMENSION_NAME)
path: numpy/lib/function_base.py
text: @user1:
Here and below: being relative new to python, I got used to new-style formats immediately, and always found the old-style ones a bit clunky. Would it be an idea to try to use new-style?


@author:
Sure, that's a little better. Neither is especially readable for building regular expressions, to be honest.