Include `math.sign`

The Idea

The idea is to add a sign function to the math module. The behavior for finite, non-zero floats/ints/decimals is obvious. Likewise for ± inf.

The Utility

I think the utility of a sign function is pretty obvious. It’s one of the most basic arithmetic operations. My main uses cases involve mathematically oriented code that needs to switch behavior based on the sign of some input.

Current Workarounds

The current workarounds are

  • Use some combination of x>0, x>=0, x<0, x<=0, to check for the sign of x. This may give surprising behavior if x is ±nan or ±0.
  • One can hack a more thorough sign function using math.copysign. Basically sign(x) = copysign(1, x). This option always returns ±1.0. For ±0 it returns ±1.0 and for ±nan it returns ±1.0.
  • If numpy is already a dependency one can use numpy.sign. np.sign returns 0 for ±0 inputs and returns nan for ±nan inputs.
  • There are likely others

Edge cases

There are two edge cases. ±0 and ±nan.

Ideas for how to handle the edge cases:

  1. Always return the result of math.copysign(x, y). With this form ±0 returns ±1.0 respectively and ±nan also returns ±1.0 (note that historically on some systems math.copysign(1, float("nan")) would return -1.0 and vice-versa for -nan, but on my system, Windows with Python 3.9 for the moment, the expected thing is returned). On this choice the sign function would always return ±1.0.
  2. Sometimes return 0.0. For this choice sign(float("0")) and sign(float("-0")) would both return 0.0. Possibly the same would hold for ±nan.
  3. Sometimes return nan. For this choice sign(float("nan")) and sign(float("-nan")) would both return nan. Maybe the same would apply to sign(0)
  4. Sometimes return -nan for this case sign(float("nan")) would return nan but sign(float("-nan")) would return -nan.

The np.sign behavior described above is a combination of the second and third behaviors. Note that np.sign accepts complex input. For this idea I would first propose that math.sign have the same behavior as np.sign for all float (and decimal) inputs but that math.sign not support complex input. However, I’m interested in discussions about alternative behaviors.

Prior Discussion

It seems this was discussed around 2007 - 2009

The first google hit links to a stackoverflow question (2009). The main answer links to a rejected patch (2007) which included math.sign and claims it was rejected because of lack of agreement on the edge cases citing this discussion (2008). The discussion specifically worries about handling of ±0.

Here is a issue (2009) showing that, for some systems and some versions of python copysign gives surprising results for nan. That is some combinations surprisingly gave

copysign(1.0, float("nan"))
# -1.0
copysign(1.0, float("-nan"))
# 1.0

While some expectedly gave

copysign(1.0, float("nan"))
# 1.0
copysign(1.0, float("-nan"))
# -1.0

On my current system and python version I get the expected behavior. I don’t know if that is because this behavior has been cleaned up in later versions of python or if it’s by luck on my system.

Here is a rejected issue (2016) requesting the repr for float("-nan") include a minus sign. The upshot of that discussion, citing stackoverflow answer (2014), was that “it is almost always a bug to attach meaning to the ‘sign bit’ of a NaN datum.” and, by this reasoning, float("-nan") does the right thing by excluding the minus sign form the repr.

Response to the previous discussion

Agreement couldn’t be reached on edge cases back in the late 2000’s, but it looks like there wasn’t much discussion. I also don’t think any of the discussion was looking at the whole picture simultaneously in the way I’m trying to do here. That is, the discussions all seem to focus one specific edge case or another. Maybe agreement could be reached now. Also, numpy was able to reach agreement, so maybe python and math can reach agreement now also.

Tangentially, I want to respond to the 2016 discussion about repr(float("-nan")) not including the minus sign. I’ll point out that repr(Decimal("-nan")) is -NaN, including the decimal point. So there is an inconsistency here that could use resolution and help resolve confusion. The separate idea here is that neither or both should include the minus sign. I’d suggest what might be most consistent would be for neither to include the minus and sign(float("nan")) and sign(float("-nan")) both return nan. This would strengthen the general stance taken by python that the sign of nan should be ignored. It will still be accessible through copysign, but it could be documented there that significance shouldn’t be attached to the that sign. Note that some of the discussion above cite official references on floats saying things about the references being ambiguous about the significance of the sign of nan, but I’m not familiar with those references.

To summarize a little bit: Yes, it’s weird that the float specification supports ±0 and kind of allows ±nan. It doesn’t feel like that strange fact should preclude having a math.sign function. It seems like a solvable problem. Rejecting math.sign because of these edge cases feels like throwing the baby out with the bathwater.

Who would maintain this?

I expect that sign would be maintained by the current maintainers of math. This “python experts” page indicates that that would be these people

mdickinson, rhettinger, stutzbach^

I have not contacted any of them directly about this suggestion yet. This is my first time suggesting this publicly.

A simple python implementation could look like

def sign(x, /):
    if isnan(x):
        return float("nan")
    if x == 0:
        return 0.0
    return copysign(1, x)

Obviously typing bells and whistles etc. could be put on. Documentation would of course be required.

I hope the implementation is simple enough that the continuing maintenance burden would be low, but I don’t have personal experience with stdlib maintenance. I would volunteer to help with the initial work and documentation.

Closing

It seems a shame such a simple and expected feature was blocked by some non-critical edge cases. I fully expect the conclusion of this idea might just very quickly be: “This was discussed previously and rejected, that old decision stands”. Nonetheless, I’m curious about some renewed discussion.

8 Likes

IMO it’s a small quality-of-life improvement to have it in the stdlib, and following numpy semantics seems like a good way to avoid confusion.

1 Like

Would you add cmath.sign() as well? The mathematical definition there is z/abs(z) for z != 0, does that have other edge cases than the real version?

Previous proposals for math.sign:

https://mail.python.org/pipermail/python-ideas/2010-April/007136.html

1 Like

Ah, thank you for that more thorough history. I struggle to look up some of the old discussions.

I personally see less need for cmath.sign() since you’re not using switching code cases on the output of the sign of a complex number. There is already cmath.phase() which already gives you the information you probably would want out of cmath.sign() anyways. So I wouldn’t say I’m personally advocating for cmath.sign() in addition to math.sign().

So that would be a unit vector with the same polar angle as the original value, would be the rationale ?
It could also be envisioned as math.sign(z.real) + 1j*math.sign(z.imag) though.


More broadly, I don’t think “sign” carries the meaning sufficiently, as I would first be surprised to find such a function, but then expect it to return either a string containing “+” or “-”, or some sentinel object if it were not the math module. And Having something called “sign” return 4 possible values feels kinda weird to me.
I think the explanation of what the function does holds in its implementation, copysign(1, x), and that in addition, the handling of the special cases may vary so much between contexts of use that it would best be left to people wanting to use it - maybe most uses would rather have an exception raised, for example, or return 0.
Unless the performance gain of implementing this in C reveals to be gigantic (which would deserve a proof), I don’t think what finally amounts to little more than syntactic sugar is worth being integrated.

In the way of prior art, Rust has both signum and copysign, and implements signum as

pub fn signum(self) -> f64 {
    if self.is_nan() { Self::NAN } else { 1.0_f64.copysign(self) }
}

In Rust’s case, signum actually came before copysign (v1.0.0 vs v1.35.0).

FWIW, Rust’s analog calls this cis, not sign/signum.

This is what I disagree with. The handling of special cases is tricky. First someone has to realize that 0 is a special case. Then someone has to realize that nan is a special case and nan is basically a huge mess to learn about and most people probably don’t need to know about it. An overly naive implementation of sign that someone might write (I would be surprised if this hasn’t been written many times “in-the-wild”) would be:

def sign(x):
    if x > 0:
        return 1.0
    if x < 0:
        return -1.0

This will return None for x==0 or isnan(x), but whoever wrote the function probably doesn’t realize that. This will likely cause headaches down the road when these edge cases are hit. Now whoever wrote the code needs to spend time thinking about these edge cases.

The point of including math.sign() would be to save users from ever having to think about these edge cases, unless for some reason the edge cases really matter for this user in such a way that math.sign() doesn’t suffice for them and they need to learn about them anyways.

See the above discussion on edge cases. The value-add of math.sign() is to save sign() writers the time of having to think about, learn about, and decide about the edge cases.

3 Likes

An alternate less naive but still problematic sign implementation

def sign(x):
    if x >= 0:
        return 1.0
    else:
        return -1.0

Now this version has decided that sign(0) = 1.0.

“Is this a good choice? It seems like a better choice than -1.0, right? Maybe it sign(0) should be 0.0? It would be nice if I didn’t need to spend time making this decision!”

For this one we have, probably unintentionally, sign(float("nan")) = -1.0. That seems odd, will that matter?

IMO, it would make sense to be consistent with numpy:

?np.sign
...
The `sign` function returns ``-1 if x < 0, 0 if x==0, 1 if x > 0``.  nan
is returned for nan inputs.

For complex inputs, the `sign` function returns
``sign(x.real) + 0j if x.real != 0 else sign(x.imag) + 0j``.

complex(nan, 0) is returned for complex nan inputs.

Anything else would lead to confusion.

1 Like

I agree that consistency with numpy is important, but I don’t like how numpy.sign accepts complex inputs. It’s hard for me to see any use case where numpy.sign would be useful for complex numbers. It is also inconsistent with the sgn(z) = z/|z| which is sometimes defined mathematically.

So I would say consistency with numpy isn’t enough for me to buy that math.sign() should accept complex numbers. I’d need also a justification for why the numpy behavior on complex numbers is valuable. Otherwise, as I said above, I think math.sign() should not support complex input. I suggest complex input to math.sign() raise an exception (though I need to check if that behavior would be consistent with how other things are handled in the math module).

These are not equivalent because cis(pi/2) = i whereas sign(pi/2) = 1.

On the subject of sign for complex numbers see recent discussion on the numpy mailing list about changing the convention used there.

Generally the idea when extending to the complex case is that abs(z)*sign(z) == z so sign(z) retains the information discarded by abs(z).

3 Likes

Here is a link to a relevant recent disussion on numpy forum: [Numpy-discussion] Change definition of complex sign (and use it in copysign)

Noted that numpy is considering changing to a more reasonable definition of np.sign() for complex numbers.

Still, math.sign() shouldn’t support complex numbers because I think the math module simply doesn’t deal with complex numbers. That is for cmath.

I consider whether cmath.sign() is exposed to be a separate discussion from whether math.sign() should be exposed. This is because the usages of two functions are different. Both math.sign() and cmath.sign() could be used when representing algebraic formulas in python. But I suspect that only math.sign() would be regularly used to trigger different branches of code. Users might want to trigger different branches of code depending on which of the 4 quadrants (or on which axes) a complex number z falls, but I would personally do that using cmath.phase().

math.sign() might also be used outside of contexts that are strictly mathematical in nature. For example finance code might treat a negative balance different than a positive one. Contrast that with cmath, where if you’re using cmath you’re probably doing someting more math heavy.

(I’m not necessarily opposed to cmath.sign() being defined as has been discussed, I just think it’s a separate discussion. I’d like to focus here on math.sign() if others agree that is reasonable.)

1 Like

I agree that math.sign should be compatible with NumPy—but not today’s Numpy. Rather, it should match the future of NumPy, which is the Array API. Therefore, I think it should follow this definition:

x = \begin{cases}0 & \textrm{if } x=0 \\ \frac{x}{\left|{x}\right|} & \textrm{otherwise.} \end{cases}

AFAIK, all of NumPy’s function definitions are being changed to be consistent with the Array API.

I think it’s better to have fewer functions in the API than more, so if we’re going to grow a math.sign, I don’t think we should also grow cmath.sign; I’d rather math.sign just work with the whole field.

1 Like

“Maybe it should be an exception ? Maybe it should be NaN ? Or maybe sign(NaN) should be 0.0 too ? Or None ?”
The chance that some people may be happy about the edge case choices you made for them, and that they don’t have to think about ; is equal to the risk that people would treat it differently and have to reimplement it from scratch.
“Explicit is better than implicit.”
I think the only solution which would not stifle user liberty would be either to raise different exceptions in the different edge cases, or to pass callbacks as arguments, both of which would be very slow and cumbersome and make for an ugly API.

I didn’t have time to write it first, but for me the solution is to do what the statistics and itertools modules do (and certainly many others) : include an utility code example. Write your 6-line implementation in the documentation of math.copysign, I think that would be great.
There would be less wider use than if it were included in the module, but at least it would be explicit.
As implemented in pure python, it would be slower than a pure C-implem, but if that’s important for you we still son’t have any element of proof, and in any case it’s faster than cathing exceptions or managing callbacks.

(As a sidenote, I just noticed that sign(10**1000) raises an exception because copysign doesn’t support it. You may think that having your sign support large ints would make it non-trivial, and therefore warrant it an existence alongside copysign, but I would retort that the proper solution here would be to fix copysign and have it fix your code in return.)


To the question of complex vs non-complex, I would agree with Justin that complex support should remain in cmath.
In general, I would consider copying numpy decisions to be a red flag, as it contains a truckload of non-pythonic decisions which should not make any contact with the stdlib (not supporting __bool__ being probably the worst). If numpy’s decision has good grounds, sure, but these grounds should be considered (as they are here) rather than copying for copying’s sake.

The mathematical sign functions for real and complex numbers, by definition, do not deal with nans. A faithful implementation of these in Python, in my view, is the simple implementation for numeric values, which in addition returns null for any non-numeric values.

I shoud clarify the last point: returning null for nans, i.e. non-numeric values, makes more sense, as the sign function is not defined to deal with these. Whereas returning nan would be wrong, in my view, as that says the sign of a nan is a nan, which doesn’t make sense.

1 Like

I find numpy’s choice for sign(0.0), sign(-0.0), sign(float('nan')), sign(float('-nan')) inadequate. It shouldn’t be imitated. It removes information. Discarding information is easy to do, if that is what is needed. There is no need for these utility functions to be making those choices for the users. That choice of sign(0.0) being 0.0 is Calculus inspired, Fourier analysis at best, but floats are not real numbers.

If it were up to me, I would also like a function to access NaN’s payload.

2 Likes

I just don’t think this is an option. From the very top of the math module documentation:

These functions cannot be used with complex numbers; use the functions of the same name from the cmath module if you require support for complex numbers. The distinction between functions which support complex numbers and those which don’t is made since most users do not want to learn quite as much mathematics as required to understand complex numbers. Receiving an exception instead of a complex result allows earlier detection of the unexpected complex number used as a parameter, so that the programmer can determine how and why it was generated in the first place.

If we want sgn(z) = z/|z| that needs to happen in cmath.

2 Likes