Skip to content
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

API,ENH: Change definition of complex sign #25441

Merged
merged 1 commit into from Jan 10, 2024

Conversation

mhvk
Copy link
Contributor

@mhvk mhvk commented Dec 21, 2023

This may be controversial, but the old definition of sign for complex numbers is really useless, and if we don't change it in 2.0, when will we? Note how the code in geomspace is substantially simplified by using the new definition!

From the release note included:

Following the API Array standard, the complex sign is now calculated as z / |z| (instead of the rather less logical case where the sign of the real part was taken, unless the real part was zero, in which case the sign of the imaginary part was returned). Like for real numbers, zero is returned if z==0.

EDIT: after discussion on the list, the consensus seemed to be that redefining sign was a good idea, but that the case for copysign was not strong. So, that is now removed from this PR.
With this, it has become possible to extend np.copysign(x1, x2) to complex numbers, since it can now generally return |x1| * sign(x2) with the sign as defined above (with no special treatment for zero).

@charris
Copy link
Member

charris commented Dec 21, 2023

This needs a release note.

@charris
Copy link
Member

charris commented Dec 21, 2023

And maybe a note on the mailing list.

@mhvk
Copy link
Contributor Author

mhvk commented Dec 21, 2023

@charris - oops, had forgotten to include the changelog entry, now corrected (git commit -a doesn't work for new files, rightly, but I keep getting bitten by that...). I also sent a note to the mailing list (though it needs approval since it has been a /long/ time since I last posted...)


log_start = _nx.log10(start)
log_stop = _nx.log10(stop)
result = logspace(log_start, log_stop, num=num,
endpoint=endpoint, base=10.0, dtype=dtype)
endpoint=endpoint, base=10.0, dtype=dt)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

note to reviewers: this was a small bug exposed by changing to an in-place multiplication with out_sign below.

@mhvk mhvk requested a review from mtsokol December 21, 2023 17:51
Copy link
Member

@mtsokol mtsokol left a comment

Choose a reason for hiding this comment

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

As far as I'm able to review it, looks good to me! Could we have a small additional test_copysign_complex() next to test_sign_complex()?

@ngoldbaum
Copy link
Member

I saw your email to the list and spent some time trying to find open source usages of np.sign that this would break. Long story short, I didn't see anything obvious, but it's hard to tell for sure. I did find a number of workarounds in other libraries (e.g. tensorflow has one).

It's not really something that this would break, but I did find that numba explicitly emulates numpy's behavior: https://github.com/numba/numba/blob/28e68e58c2268ac022d384b51417ab14d819814a/numba/np/npyfuncs.py#L579

So maybe someone should raise an issue with numba if this is merged?

@mhvk
Copy link
Contributor Author

mhvk commented Dec 22, 2023

@mtsokol - thanks for the review. Asking for tests is always good - it smoked out some undesirable behaviour (e.g., real being inf while imag being nan). So, I now use the definition of sign properly inside copysign as well (taking care of the special case of zero).

@rgommers
Copy link
Member

rgommers commented Dec 22, 2023

Thanks @mhvk! This does look like an improvement indeed. I think it also caught a gap in the array API test suite, or this would have shown up as a failure (maybe you can check this @mtsokol?).

I ran the SciPy test suite against this branch, and it showed a single error (I double checked, it's reproducible) in interpolate.interpn with the pchip method:

_________________________________ TestInterpN.test_complex[pchip] _________________________________
scipy/interpolate/tests/test_rgi.py:864: in test_complex
    assert_allclose(v1, v2)
        method     = 'pchip'
        points     = (array([0.5, 2. , 3. , 4. , 5.5, 6. ]), array([0.5, 2. , 3. , 4. , 5.5, 6. ]))
        sample     = array([[1. , 1. ],
       [2.3, 3.3],
       [5.3, 1.2],
       [0.5, 4. ],
       [3.3, 5. ],
       [1.2, 1. ],
       [3. , 3. ]])
        self       = <scipy.interpolate.tests.test_rgi.TestInterpN object at 0x7fc13b97b040>
        v1         = array([1.62962963-3.25925926j, 1.56572151-3.13144303j,
       1.77612073-3.55224146j, 2.        -4.j        ,
       1.18862863-2.37725726j, 1.62962963-3.25925926j,
       3.        -6.j        ])
        v2         = array([1.62962963-3.25925926j, 1.56572151-3.13144303j,
       1.77904436-3.55808873j, 2.        -4.j        ,
       1.18862863-2.37725726j, 1.62962963-3.25925926j,
       3.        -6.j        ])
        v2i        = array([-3.25925926, -3.13144303, -3.55808873, -4.        , -2.37725726,
       -3.25925926, -6.        ])
        v2r        = array([1.62962963, 1.56572151, 1.77904436, 2.        , 1.18862863,
       1.62962963, 3.        ])
        values     = array([[1.-2.j, 2.-4.j, 1.-2.j, 2.-4.j, 1.-2.j, 1.-2.j],
       [1.-2.j, 2.-4.j, 1.-2.j, 2.-4.j, 1.-2.j, 1.-2.j],
    ....j],
       [1.-2.j, 2.-4.j, 1.-2.j, 2.-4.j, 1.-2.j, 1.-2.j],
       [1.-2.j, 2.-4.j, 2.-4.j, 2.-4.j, 1.-2.j, 1.-2.j]])
        x          = array([0.5, 2. , 3. , 4. , 5.5, 6. ])
        y          = array([0.5, 2. , 3. , 4. , 5.5, 6. ])
/home/rgommers/mambaforge/envs/scipy-dev/lib/python3.10/contextlib.py:79: in inner
    return func(*args, **kwds)
E   AssertionError: 
E   Not equal to tolerance rtol=1e-07, atol=0
E   
E   Mismatched elements: 1 / 7 (14.3%)
E   Max absolute difference among violations: 0.00653744
E   Max relative difference among violations: 0.00164337
E    ACTUAL: array([1.62963 -3.259259j, 1.565722-3.131443j, 1.776121-3.552241j,
E          2.      -4.j      , 1.188629-2.377257j, 1.62963 -3.259259j,
E          3.      -6.j      ])
E    DESIRED: array([1.62963 -3.259259j, 1.565722-3.131443j, 1.779044-3.558089j,
E          2.      -4.j      , 1.188629-2.377257j, 1.62963 -3.259259j,
E          3.      -6.j      ])
        args       = (<function assert_allclose.<locals>.compare at 0x7fc1338632e0>, array([1.62962963-3.25925926j, 1.56572151-3.13144303j,...08873j, 2.        -4.j        ,
       1.18862863-2.37725726j, 1.62962963-3.25925926j,
       3.        -6.j        ]))
        func       = <function assert_array_compare at 0x7fc13f6ae5f0>
        kwds       = {'equal_nan': True, 'err_msg': '', 'header': 'Not equal to tolerance rtol=1e-07, atol=0', 'strict': False, ...}
        self       = <contextlib._GeneratorContextManager object at 0x7fc13f6a7340>

There are a handful of np.sign usages in the implementation:
https://github.com/scipy/scipy/blob/8cd805980852386324e18507a38380d18f3be4ef/scipy/interpolate/_cubic.py#L253-L289

It's hard to tell quickly what is going on there. It could be an unstable test - if the behavior of sign(0+x.j) changes and it's used for finding derivatives there, it may result in a slightly different answer.

(just seeing the new commit, will retest EDIT: indeed, no difference)

@mhvk
Copy link
Contributor Author

mhvk commented Dec 22, 2023

@ngoldbaum - thanks for checking! And a good point about numba; I raised an issue there: numba/numba#9376

About scipy, my latest change is not likely to resolve the issue since it only deals with infinities and NaN. I'll try to see if I can figure out what is going on as well.

@mhvk
Copy link
Contributor Author

mhvk commented Dec 22, 2023

p.s. Second push was just a linter issue, so no need to test that again! (EDIT: and third push an uncaptured invalid value error on FreeBSD).

@mhvk
Copy link
Contributor Author

mhvk commented Dec 22, 2023

It is weird, the scipy function explicitly is for real values only according to its docstring. Maybe complex y and real x is OK, as I guess one can just interpolate real and imag separately... ... ... indeed, this is what is being tested.

To be honest, I think it is basically luck that that test passed with the existing definition of np.sign. I think this happened mostly because the test has values - 2j*values for the complex values, which guarantees that the derivatives change sign at the same location. ... testing ... yes, that is correct, after one also changes values to not just be a linear ramp (really!). Anyway, I'll raise an issue at scipy.

@mhvk
Copy link
Contributor Author

mhvk commented Dec 22, 2023

See scipy/scipy#19739 for an issue about the failing complex interpolation (and how it can be made to fail also with the present definition of np.sign).

@ev-br
Copy link
Contributor

ev-br commented Dec 22, 2023

Just to duplicate what is said on the scipy issue: the scipy test is wrong and it's safe to ignore it for this PR.

@mtsokol
Copy link
Member

mtsokol commented Jan 2, 2024

This does look like an improvement indeed. I think it also caught a gap in the array API test suite, or this would have shown up as a failure (maybe you can check this @mtsokol?).

@rgommers It looks like array-api-tests in test_sign uses Python's math.copysign as a reference implementation, which discards the imaginary part (I think it does something close to np.complex(...).astype(float)) and behaves as np.sign for complex currently.

Neither math nor cmath modules have a sign function so I think the only way is to provide a custom Python implementation as a reference here (use the new formula directly). I can update array-api-tests if it's Ok.

The array-api-tests CI stage doesn't fail here because test_sign lives in test_operators_and_elementwise_functions which is disabled due to the missing copy keyword in np.asarray.

One thing - I noticed that torch has a torch.sign which raises an error for the complex input and points to torch.sgn. I wonder when it would make sense to unify torch.sign and torch.sgn.

@rgommers
Copy link
Member

rgommers commented Jan 2, 2024

I can update array-api-tests if it's Ok.

That'd be great, thanks!

One thing - I noticed that torch has a torch.sign which raises an error for the complex input and points to torch.sgn. I wonder when it would make sense to unify torch.sign and torch.sgn.

That indeed seems like an obvious improvement. And it should be backwards compatible. It's very well possible that torch.sign doesn't support complex because they wanted to avoid the behavior mismatch with numpy.sign in numpy 1.xx

Following the API Array standard, the complex sign is now calculated as
``z / |z|`` (instead of the rather less logical case where the sign of
the real part was taken, unless the real part was zero, in which case
the sign of the imaginary part was returned).  Like for real numbers,
zero is returned if ``z==0``.
@mhvk
Copy link
Contributor Author

mhvk commented Jan 4, 2024

OK, following discussion on the mailing list, it seemed there was fair consensus on changing the definition of complex sign, but not on using it in copysign - so here I removed that (if somehow it turns out to be really wanted after all, I can put it back in a different PR).

Anyway, I think this is ready for final review...

@mhvk mhvk changed the title API,ENH: Change definition of complex sign and use it in copysign API,ENH: Change definition of complex sign Jan 8, 2024
@mtsokol
Copy link
Member

mtsokol commented Jan 10, 2024

I can update array-api-tests if it's Ok.

That'd be great, thanks!

FYI data-apis/array-api-tests#227 got merged today.

@mhvk
Copy link
Contributor Author

mhvk commented Jan 10, 2024

@mtsokol approved. Does anyone else want to have a look? Or can this redefinition of sign for complex be merged?

out = np.zeros(a.shape, a.dtype)
tgt = np.array([
1., -1., 1j, -1j,
] + [complex(np.nan, np.nan)] * 5 + [
Copy link
Member

Choose a reason for hiding this comment

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

Is it perhaps confusing that this is the case:

In [1]: np.sign(np.nan)
Out[1]: np.float64(nan)

In [2]: np.sign(complex(np.nan))
Out[2]: np.complex128(nan+nanj)

Perhaps if the real or imaginary part is equal to zero, we should return 0 instead of NaN for the real or imaginary part of the result.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure, my sense would be that when either component is NaN, it is best to propagate that to both; it also keeps the result closest to the definition of z/|z| (for |z}->0,±inf, one can argue about meaningful limits, but less so for NaN).

Copy link
Member

Choose a reason for hiding this comment

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

Thanks, on second thought I agree that having only a single kind of nan result here probably makes sense. I hope I never have a technical problem where complex NaN is something I need to worry about...

@ngoldbaum
Copy link
Member

This got overwhelming support on the mailing list, let's pull this in. Thank you @mhvk!

@ngoldbaum ngoldbaum merged commit 220f0ab into numpy:main Jan 10, 2024
63 checks passed
@mhvk mhvk deleted the redefine-complex-sign branch January 10, 2024 20:23
mhvk pushed a commit that referenced this pull request Jan 29, 2024
See gh-25644, which showed that numpy 1.26 did not take the
shorted logarithmic spiral. Coincidentally, though, this was
already fixed by gh-25441, so this commit just adds a regression
test.
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.

None yet

6 participants