8000 BUG: Fix crash on 0d return value in apply_along_axis by eric-wieser · Pull Request #8441 · numpy/numpy · GitHub
[go: up one dir, main page]

Skip to content

BUG: Fix crash on 0d return value in apply_along_axis #8441

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Feb 12, 2017

Conversation

eric-wieser
Copy link
Member
@eric-wieser eric-wieser commented Jan 2, 2017

Also:
ENH: Support arbitrary dimensionality of return value
MAINT: remove special casing

Now supports

>>> data = np.arange(6).reshape(2, 3)
>>> data
array([[0, 1, 2],
       [3, 4, 5]])
>>> np.apply_along_axis(np.diag, -1, data)  #would previously crash
array([[[0, 0, 0],
        [0, 1, 0],
        [0, 0, 2]],

       [[3, 0, 0],
        [0, 4, 0],
        [0, 0, 5]]])
>>> np.apply_along_axis(lambda x: np.array(np.sum(x)), 0, data)  #would previously crash
array([3, 5, 7])

Addresses discussion from #8363

# save the first result
outarr_view[tuple(ind)] = res
k = 1
while k < Ntot:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really likely to be faster than using ndindex here?

inarr_view = transpose(arr, dims_in[:axis] + dims_in[axis+1:] + [axis])

# indices
inds = ndindex(inarr_view.shape[:nd-1])
Copy link
Member Author
@eric-wieser eric-wieser Jan 2, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, most of this function could probably be replaced with a carefully constructed nditer that does the transposes for us, but that's probably not going to increase performance much anyway

@eric-wieser
Copy link
Member Author
eric-wieser commented Jan 3, 2017

Right now, this happens:

>>> evil = lambda x: 1 if x.all() else [2, 3]
# silent broadcasting
>>> apply_along_axis(evil, 0, np.array([[False, True, True]]))))
array([[2, 1, 1],
       [3, 1, 1]])
# same function, different order
>>> apply_along_axis(evil, 0, np.array([[True, False, True]]))))
ValueError: setting an array element with a sequence.

Would it be better if both raised ValueError: shape must be the same on each invocation, at the expense of a check on every iteration?

if not isinstance(res, matrix):
outarr = res.__array_prepare__(outarr)
if outshape != outarr.shape:
raise ValueError('__array_prepare__ should not change the shape of the resultant array')
Copy link
Member Author
@eric-wieser eric-wieser Jan 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this assumption (__array_prepare__(x).shape == x.shape) supposed to be valid? How do we deal with matrix violating it elsewhere?

)

result = apply_along_axis(double, 1, m)
assert isinstance(result, np.matrix)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that assert shouldn't be u 10000 sed at all, use np.testing.assert_ instead (applies to multiple lines below as well)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my defense, those other lines were not part of this patch. Do you want me to fix all cases of assert in this file anyway?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or should this be self.assertIsInstance?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to assert_(isinstance(...)), because that's what other tests do. Switching to self.assertIsInstance can be discussed elsewhere

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my defense, those other lines were not part of this patch. Do you want me to fix all cases of assert in this file anyway?

if you could, that'd be useful

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.assertIsInstance seems fine to use if you prefer that, it has a sane implementation in both unittest and unittest2.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rgommers: I've patched the other ones in that file

@eric-wieser
Copy link
Member Author
eric-wieser commented Jan 20, 2017

Ugh, this needs a patch in ma.apply_along_axis for consistency as well. Can that wait for a separate PR?

outarr = zeros(outshape, res.dtype)
# matrices call reshape in __array_prepare__, and this is apparently ok!
if not isinstance(res, matrix):
outarr = res.__array_prepare__(outarr)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can someone comment on whether this is actually correct, or if it can just be omitted altogether

__array_prepare__ seems to always do something screwy for builtin array subclasses. It's not clear to me what the contract entails, and whether I'm violating it, or np.matrix and np.ma.MaskedArray are

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mhvk may know

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think MaskedConstant.__array_prepare__ was broken - fixed in #8508. I think a corollary of this is that np.ma.apply_along_axis can basically defer now.

Copy link
Member
@shoyer shoyer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like the approach here, thanks for tackling this clean-up!

outarr = zeros(outshape, res.dtype)
# matrices call reshape in __array_prepare__, and this is apparently ok!
if not isinstance(res, matrix):
outarr = res.__array_prepare__(outarr)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mhvk may know

# save the first result
outarr_view[ind0] = res
for ind in inds:
outarr_view[ind] = asanyarray(func1d(inarr_view[ind], *args, **kwargs))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recognize that this works to update the view inplace, but it still makes me a little nervous. It would be more direct and obviously correct to do the transposing afterwards.

This would change the memory layout of the result array. Transposing afterwards would ensure that array is written with contiguous writes, which could result in a marginal performance improvement (at least for this function).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, my goal here was to leave the memory layout of the result array unaffected when compared to the old version.

In particular, the old code would always give a contiguous output, something that may have been relied upon

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we reserve the right to change the memory layout of arrays returned by NumPy functions unless it is documented as part of the API. Users who truly rely on contiguous output should be using np.ascontiguousarray.

Copy link
Member Author
@eric-wieser eric-wieser Jan 20, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess transposing afterwards would fix the above issue. If you don't think a contiguous output is beneficial, then I'll move the transpose

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have done it before (e.g. even indexing I did it). If it is obvious/possible, then "the same as before" should be preferred most of the time I would say.

Copy link
Member Author
@eric-wieser eric-wieser Jan 20, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd argue that the correctness is more obvious this way around, because otherwise the inverse transpose order is needed, which is more troublesome to generate

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mostly like transposing afterwards more. I don't care so much about the memory layout.

Copy link
Member Author
@eric-wieser eric-wieser Jan 20, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I think you're right, it is simpler - the transpose order becomes dims_out[:axis] + dims_out[-res.ndim:] + dims_out[axis+1:] (more complicated than that), which better conveys the insertion of the added axes

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the transpose happen before or after the __array_wrap__?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I'm sold on your suggestion, but for a different reason: when res.__array_prepare__ is called, the axes of res (a single output) and the final output are paired as if they were broadcasted, which seems desirable for any kind of axis metadata copying


# arr, with the iteration axis at the end
dims_in = list(range(nd))
inarr_view = transpose(arr, dims_in[:axis] + dims_in[axis+1:] + [axis])
Copy link
Member Author
@eric-wieser eric-wieser Jan 20, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes that transpose always returns a view (and not sometimes a copy). is this acceptable?

(edit: updated mailing list url, which was dead. Not sure it points to the right thing any longer. https://mail.python.org/pipermail/numpy-discussion/2013-June/066822.html might be what I meant)

Copy link
Member Author
@eric-wieser eric-wieser Jan 20, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, only the output transpose does, which only matters if we use __array_prepare__

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As @seberg writes in that mailing list discussion, transpose on base numpy arrays always returns a view, never a copy.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this might indeed break in surprising ways for ndarray subclasses

Copy link
Member Author
@eric-wieser eric-wieser Jan 20, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shoyer: Would an acceptable compromise be to not call __array_prepare__ if transpose is a copy?

@eric-wieser
Copy link
Member Author

@shoyer: Transposed moved, which now gives this masked_array support, leading into #8511

arr = asanyarray(arr)
nd = arr.ndim
if axis < 0:
axis += nd
if (axis >= nd):
if axis >= nd:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while we're at it, also check that axis is not still negative?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea of killing this altogether, and delegating parsing axis to moveaxis

Copy link
Member Author
@eric-wieser eric-wieser Jan 22, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alarmingly, I can't even find a precedent where numpy checks for this anywhere else within shapebase

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eric-wieser - when you add the changelog, could you also move this ch 179B eck up to before axis is possibly changed and check for too-negative as well? That way, the error message gives the actual input rather than something that's already changed. I.e.,

if axis < -nd or axis >= nd:
    raise ...

if axis < 0:
    axis += nd

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mhvk: Done

return outarr

# arr, with the iteration axis at the end
in_dims = list(range(nd))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I looked at this first, I thought you might consider using the (relatively new) np.moveaxis, i.e.,

inarr_view = moveaxis(inarr, axis, -1)

This will not be any faster (as it just sets up a transpose, like here), but is perhaps clearer. As a side benefit, you can remove above the checks on the validity of axis, as moveaxis does those anyway.

However, looking further down, it is not obvious moveaxis would work there, since res.ndim could in principle be >1-d (for which your code adds support, which I think is very nice!). But one could steal a bit from the moveaxis code and write here

in_permute = [n for n in range(nd) if n != axis] + [axis]
inarr_view = transpose(arr, in_permute)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, good point. moveaxis works with multiple axes too, so should work in both cases.

Copy link
Member
@shoyer shoyer Jan 21, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For restoring axes, you could maybe write something like:

moveaxis(pre_arr, [i + nd - 1 for i in range(res.ndim)],
         [i + axis for i in range(res.ndim)])

I think this is a slight improvement in clarity over building up the permutation axes for transpose directly.

Copy link
Member Author
@eric-wieser eric-wieser Jan 22, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think moveaxis should learn a shorthand for dest such that this would work:

moveaxis(pre_arr, [i + nd - 1 for i in range(res.ndim)], axis)

Ie, if dest is a scalar, move all the source axes to that location, in the order they were passed in src

Copy link
Member Author
@eric-wieser eric-wieser Feb 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shoyer: Based on @mhvk's revised opinion, are you happy for this to remain as it is? Any further pain-points blocking this being merged?

inarr_view = transpose(arr, in_dims[:axis] + in_dims[axis+1:] + [axis])

# compute indices for the iteration axes
inds = ndindex(inarr_view.shape[:nd-1])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really like using the iterator here. Much cleaner!

# remove the requested axis, and add the new ones on the end.
# laid out so that each write is contiguous.
# for a tuple index inds, pre_arr[inds] = func1d(inarr_view[inds])
pre_shape = arr.shape[:axis] + arr.shape[axis+1:] + res.shape
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you can just use the shape of inarr_view, i.e.,

pre_shape = inarr_view.shape + res.shape

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!


# permutation of axes such that out = pre_arr.transpose(pre_permute)
pre_dims = list(range(pre_arr.ndim))
pre_permute = pre_dims[:axis] + list(roll(pre_dims[axis:], res.ndim))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a bit faster (no list->ndarray->list) and clearer (to me at least):

if res.ndim > 0:
   pre_permute = pre_dims[:axis] + pre_dims[-res.ndim:] + pre_dims[axis+red.ndim:]

(you'd need to rename pre_dims to pre_permute if you want the transpose at the end even for res.ndim=0)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that line was inspired by a talk extolling the virtues of std::rotate.

Had that before, but without the if - as you correctly spot, it fails when res.ndim == 0. Not a huge fan of special casing that.

Either way, I reckon moveaxis will do the job here too.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or how about just

pre_permute = pre_dims[:axis] + pre_dims[nd-res.ndim:] + pre_dims[axis+red.ndim:]

Adding the nd there fixes the error when res.ndim= 0

pre_arr = res.__array_wrap__(pre_arr)

# finally, rotate the inserted axes back to where they belong
return transpose(pre_arr, pre_permute)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For speed for the more common case (and required with my comment above):

return pre_arr if res.ndim == 0 else transpose(pre_arr, pre_permute)

outarr = outarr.squeeze(axis)
return outarr
# matrices have to be transposed first, because they collapse dimensions!
out_arr = transpose(pre_arr, pre_permute)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above

result = apply_along_axis(double, 0, m)
assert isinstance(result, np.matrix)
assert_(isinstance(result, np.matrix))
assert_array_equal(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just on one line?

assert_array_equal(result, expected)

assert_array_equal(
result, np.matrix([[0, 2], [4, 6]])
result, expected
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

@mhvk
Copy link
Contributor
mhvk commented Jan 21, 2017

@eric-wieser - this is very nice indeed, I like the generality, and the care for subclasses. My comments are all small, but hopefully make this a little better still.

@eric-wieser
Copy link
Member Author

I'll do another round of clean up tomorrow to address those

@mhvk
Copy link
Contributor
mhvk commented Jan 21, 2017

I'll do another round of clean up tomorrow to address those

I think I'd go for your approach rather than moveaxis as it is not all that obviously handier in passing in multiple axes.

@@ -103,11 +103,10 @@ def apply_along_axis(func1d, axis, arr, *args, **kwargs):
% (axis, nd))

# arr, with the iteration axis at the end
in_dims = list(range(nd))
inarr_view = transpose(arr, in_dims[:axis] + in_dims[axis+1:] + [axis])
inarr_view = moveaxis(arr, axis, -1)
Copy link
Member Author
@eric-wieser eric-wieser Jan 22, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beginning to think this would be a fair bit slower, due to the error checking in moveaxis, and isn't much clearer.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh, how about np.r_[:axis, axis+1:nd, axis]

@eric-wieser eric-wieser force-pushed the apply_along_axis-nd branch 2 times, most recently from 060c5e7 to 7939511 Compare January 22, 2017 03:35
@eric-wieser
Copy link
Member Author
eric-wieser commented Jan 22, 2017

Keep changing my mind on this one. I think it's best left without moveaxis. It doesn't add much to clarity in the second case, and I think there's a benefit to having both transposes written in the same style. Also, building the axis lists directly should be a lot faster than incurring list comprehensions, then the type conversion and error checking that occurs in moveaxis.

np.r_ added some minor conciseness, but at the expense of some performance

I agree that the roll was a little cryptic in the second case.

I'd rather not spend too much more time bikeshedding this.

@mhvk
Copy link
Contributor
mhvk commented Jan 22, 2017

@eric-wieser - sorry for having brought up moveaxis when I had also concluded that in the end it was not as good an idea -- it works well for the first case, not so well for the second.


def test_axis_insertion(self, cls=np.ndarray):
a = np.arange(18).reshape((6, 3))
res = apply_along_axis(lambda x: np.diag(x).view(cls), 0, a)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading over these tests one last time, it occurs to me that we should be more comprehensive for testing insertion of new axes.

Right now you only test inserting at the start, but that doesn't really exercise the permutation logic in a comprehensive way. Let's also test inserting in the middle and at the end. Also, diag is axes order invariant, so none of the tests verify that new axes are inserted in the right order.

Copy link
Member Author
@eric-wieser eric-wieser Feb 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe outer product with its reverse would be better than diag then?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the outer product is still invariant to transposes. Something like x[:, np.newaxis] - x[np.newaxis, :] would work, though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On re-reading: yes, outer product with its reverse, would be fine

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You didn't miss it the first time, I did indeed suggest a plain outer product beforehand, and realized my mistake. This is now done too.

res = apply_along_axis(lambda x: np.diag(x).view(cls), 0, a)
assert_(isinstance(res, cls))
assert_equal(res.ndim, 3)
assert_array_equal(res[:,:,0], np.diag(a[:,0]).view(cls))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A cleaner way to check this sort of thing is to construct the desired result array and just call assert_array_equal (e.g., np.stack([np.diag(a[:,i]) for i in range(3)], axis=-1).view(cls)). Otherwise it's easy to overlook one part of the equality check (e.g., shape, in this case).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shoyer: I was hoping I could be done with this, but you are definitely right. I'll fix those things up soon

Copy link
Member Author
@eric-wieser eric-wieser Feb 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, done. (except for masked arrays, where I don't think assert_equal does the right thing)

Copy link
Member
@shoyer shoyer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Will merge this shortly if no one else has further comments.

@shoyer
Copy link
Member
shoyer commented Feb 10, 2017

@eric-wieser could you please clean up the git history a little bit? Thanks

@eric-wieser
Copy link
Member Author

@shoyer: Fair, I'll give that a go over the weekend.

Also:
ENH: Support arbitrary dimensionality of return value
MAINT: remove special casing
.transpose does not specify that it must return a view, so subclasses
(like np.ma.array) could otherwise break this.

This exposes some more need for matrix special casing.
Note that this is not a full subsitute for np.ma.apply_along_axis,
as that allows functions to return a mix of np.ma.masked and scalars
Copied from the implementation in core.shape_base.stack
@eric-wieser
Copy link
Member Author
eric-wieser commented Feb 11, 2017

@shoyer: Down from 13 commits to 8 commits. Look ok?

I've confirmed that the final commit of these squashes is identical to the result of rebasing this branch on master. (possibly ignoring merges in the release notes)

@shoyer shoyer merged commit 51a8240 into numpy:master Feb 12, 2017
@shoyer
Copy link
Member
shoyer commented Feb 12, 2017

I would usually squash down to one commit, but this is fine.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants
0