Include `math.sign`

The question here is whether the sign function should distinguish +0 and -0. This is a question about IEEE floats, not about mathematics.

Maybe sign(+0) == +0 and sign(-0) == -0 is the right call. I’m not an IEEE float specialist, so I don’t know…

1 Like

@jagerber Thanks for opening the discussion here, and for the research into previous discussions.

One thing that seems to be largely missing from those previous discussions is example use-cases. It would be really helpful to have a body of examples of Python code that could potentially be improved by the existence of a sign function - those examples could then be used to help evaluate trade-offs between possible (re)solutions. Did you have particular use-cases in mind when you opened this discussion?

I’ll kick off the example use-cases with this one, where I needed a sign function as part of a robust point-in-polyhedron test that did something sensible for points exactly on the boundary of a polyhedron (so classifying into inside / outside / on the surface).

Aside from the above example, I’ve personally rarely felt the need for math.sign, and don’t recall often needing numpy.sign, either - in almost all the use-cases I’ve encountered, the decision has been binary (e.g., negative vs. non-negative, or sign bit set versus sign bit clear, or more rarely positive vs. non-positive) rather than ternary. I think the only place I’ve recently needed a sign function was to provide an old-style three-way comparison function for a sort, and that was in JavaScript rather than Python.

If Python did grow a sign function, I’d expect it to:

  • have an integer return value of -1, 0 or 1 (not a float return value, since I’d want to be able to use sign in integer-only computations without those being “contaminated” by floats, and similarly in a computation involving fractions.Fraction instances I wouldn’t want to end up trying to mix Fractions and floats just because sign returned a float)
  • be polymorphic: that is, it should work for Fraction, Decimal, and user-defined types in addition to int and float, and should not raise for ints or Fractions or Decimals that just happen to be larger than the largest finite float. Support for user-defined types would probably entail having a __sign__ magic method.
  • return 0 for zeros, regardless of the “sign” of those zeros
  • raise ValueError on NaNs
  • raise TypeError on complex numbers - for all the discussion above about complex inputs, this feels like a YAGNI to me.

Note: one related possible math module enhancement that seems entirely uncontroversial to me would be adding math.signbit as a partner to math.copysign. That would be intended specifically for floats (though would accept other types convertible to float in exactly the same way that most of the math module libm function wrappers do), would distinguish between positive and negative zero, would respect the sign bit of a NaN, and would match C’s signbit macro (as well as NumPy’s signbit function). In other words, signbit(x) would be exactly equivalent to 1 if math.copysign(1.0, x) < 0 else 0.

13 Likes

My last word in this thread: @Rosuav please actually read what I wrote earlier. And no, I do not misunderstand the purpose of floats. Firstly, I do not use the word “float” to refer to real numbers, as defined in mathematics. By floats I am referring to the values of the Python float data type, and said they are represent/implement real numbers - not all obviously, but a subset, and with finite precision for the fractional parts. What is objectionable about this? You may consult the Python documentation on floating point numbers, or the IEEE floating point standard.

On “Ints”, as you call them, do you mean integers/whole numbers or do you mean Python ints? I was referring to the second implementing/representing the former, with arbitrary precision. I am distinguishing between Python numeric data types and the mathematical types they meant to implement. Where is the confusion?

And I didn’t say anything about “… expectations of floats based on reals that it gets more questionable”. I am not sure what that refers to.

1 Like

The only thing that’s objectionable is when you then use real numbers to place expectations on floats. Sure, they’re a subset of reals… but so are ints, and so are fractions.Fractions, and so are nearly all other numbers in Python that aren’t complexes. Of COURSE they’re a subset of reals. It’s true, but uselessly so.

1 Like

“the” implies uniqueness, which is not the case. There isn’t a unique definition of the function sign.

This seems too restrictive especially if there is to be a __sign__ method which many Python libraries would want to define for complex inputs. I understand why you want this because it allows the value to be an integer always but I would say that sign should be defined wherever abs is defined and that __sign__ and __abs__ can be complementary special methods.

Usually the value of sign(0) does not matter in context as long as it is well defined unlike 0/abs(0). The typical case is a formula like sign(f(x))*g(x) where f(x)=0 implies that g(x)=0 just like in the defining equation sign(x)*abs(x) == x.

Part of the usefulness of sign(x) as a function comes from the fact that it is well defined at 0 and part of it is that it allows using a single formula for things that would otherwise need to use if/else, piecewise, mask arrays etc.

Examples of numpy’s np.sign in use can be seen at grep.app.

2 Likes

Good and helpful question. Yes, I did have a use case in mind when I opened this discussion. I’m working on a PyPi number formatting package (inspired by your suggestion) which is almost an extension of what the python format-specification mini-language (FSML) can do for floats/decimals (almost because it does more, but there are a few things the python formatter does that were intentionally left out). In any case, currently the formatter has similar behavior to the [ +-] sign symbol selection option of the python FSML.

I recently came to think harder about how my formatter behaves when it needs to format the sign of 0 and nan. I think it’s at least worth considering that the + format option combined with 0.0 returns " 0.0" instead of +0.0. For nan the current behavior is that nan is always formatetd to "nan" but when I started looking into this I learned that nan has a sign bit and started wondering if my formatter should include the sign on nan similar to how the Decimal object includes the sign. Anyways, my takeaway is that I should NOT include the sign, but I had to go through a lot of research, including my linked research on this topic, to come to that conclusion. A documented stdlib sign function could have short circuited this research I ended up doing.

So I do think that this case of formatting numbers is especially suited towards specifically wanting a ternary sign function. The package is also meant to have no third party dependencies so I also specifically don’t want e.g. numpy. An objection to my use case is that I’m going to have code that switches cases based on the output of sign(x) and that case-switching code is basically going to be as complex as the code for sign that is being proposed, so it may not even save lines of code in my case. Nonetheless, if sign were available I would use it for (my perception of) improved semantics/readability.

Your “point-in-polyhedron” example reminded me of something similar I was helping with about generating a solid mesh of a 3D sphere. The sphere is parametrized in polar coordinates and you need to detect when you’ve wrapped the polar angle to pi or azimuthal angle to 2 pi. This example is a little less convincing, I’d need to take a close look at the code again to determine if a ternary sign would have helped it or not, at first glance I think taking the difference, rounding anything “close-to-zero” to zero and using a ternary or binary (not sure which) sign would have helped in that code. Of course this code has numpy as a dependency and performance matters, so it would have preferrednp.sign to math.sign. Not the best example in my opinion but sharing it for inspiration.


All of your suggestions for the expected sign function behavior are very helpful thank you.

I definitely agree with all of the suggestions, except for this one which I am (and probably others in this thread are) curious to hear more about:

A lot of the controversy in this thread has been about whether sign(float("nan")) should return nan or raise an exception (leaving out the suggestion that I think has been agreed to be rejected about it returning None). Can you elaborate why you prefer raising an exception to returning a nan like numpy.sign does?

To be clear I don’t really have much preference for or against either, just trying to collect pros/cons.


Yeah that seems helpful for use cases where someone really wants to read but not actually copy the sign bit. It would also probably cover most use cases for a sign function (e.g. non-zero, non-nan inputs). I’ve been trying to emphasize utility for beginner/naive users and I don’t think signbit fully satisfies that. It would be preferable to have some kind of sign detecting function that, frankly, allows users to detect the sign of numbers without needing to hear about what a float is or that it has a sign bit.

I think this is where some of the tension I’m detecting is coming from. I’m wanting a dead-simple function that is a quality-of-life improvement for people who don’t want to care about/want to push under the rug the finer details of floating point arithmetic, but it seems like the math module has specifically evolved as a tool (or out of a tool) to help users who do specifically care about the low-level finer points of floating point arithmetic. Just an observation from me. I’d like to hear others’ take on this observation.


Given the math modules explicit documentation that it doesn’t handle complex input it seems like math.sign() should not handle complex input. That said, the discussion about __sign__ and __abs__ makes me wonder:

what about sign() as a built-in function just like abs()?

I would then expect built-in sign() function to give z/|z| for all input except 0 on which it returns 0. Following the above suggestion, for int/float/fraction/Decimal z, perhaps sign(z) would return an int but for complex z it would of course return complex. My question is would float(0j) return 0j or 0?

The tension is really that choosing to use a module like math is how you control the domain, types, and underlying implementation of a particular calculation. This is the basic design embodied by having math and cmath as distinct modules but also the way that Python’s third party ecosystem has evolved as well. It might seem like the math module is just a place where mathematical functions go so that you can do from math import sign but that is not usually what it means for a function to be in the math module rather than somewhere else.

One possible general approach for controlling the domain of a calculation is to have polymorphic functions like abs so that the type/domain of the output are determined by the type of the input:

In [14]: abs(2)
Out[14]: 2

In [15]: abs(2.0)
Out[15]: 2.0

In [16]: abs(decimal.Decimal(2))
Out[16]: Decimal('2')

In [17]: abs(mpmath.mpf(2))
Out[17]: mpf('2.0')

In [18]: abs(np.array([2, 3]))
Out[18]: array([2, 3])

In [19]: abs(np.array([2.0, 3.0]))
Out[19]: array([2., 3.])

In this model the type of the object is determined when it is created and then the behaviour of a function like abs is determined by that type (via its __abs__ method but most users don’t need to know that).

Another model is that you have a module/namespace for a set of functions where every function in that module will coerce other types to its own type and then work with that:

In [22]: math.sqrt(2)
Out[22]: 1.4142135623730951

In [23]: cmath.sqrt(2)
Out[23]: (1.4142135623730951+0j)

In [24]: mpmath.sqrt(2)
Out[24]: mpf('1.4142135623730951')

In [25]: np.sqrt(2)
Out[25]: np.float64(1.4142135623730951)

In [26]: sympy.sqrt(2)
Out[26]: √2

In [27]: np.sqrt([2,3])
Out[27]: array([1.41421356, 1.73205081])

Now each module like np, mpmath, sympy, math or cmath (there are many more) has mostly the same functions with mostly the same names. The inputs to those functions will be coerced to the module’s own types and all calculations will be done with those.

If you restrict to numpy then numpy distinguishes whether or not to use complex numbers by whether or not the inputs are complex e.g.:

In [35]: np.sqrt(np.array([-1, -2]))
<ipython-input-35-c1e6a6fe0aa1>:1: RuntimeWarning: invalid value encountered in sqrt
  np.sqrt(np.array([-1, -2]))
Out[35]: array([nan, nan])

In [36]: np.sqrt(np.array([-1, -2+0j]))
Out[36]: array([0.+1.j        , 0.+1.41421356j])

So we use the same function np.sqrt but get different outputs depending on the types of the inputs. It is also possible to control the type to be used explicitly:

In [38]: np.sqrt(np.array([-1, -2]), dtype=np.complex128)
Out[38]: array([0.+1.j        , 0.+1.41421356j])

This means that rather than having many different sqrt functions for users there can be one function np.sqrt as long as you only ever want the types that are directly supported by numpy.

If you are used to using say Matlab then it might seem surprising that there is a need to have many different sqrt functions. In Julia there is only one sqrt function but it is overloaded for different types:

julia> sqrt(-1)
ERROR: DomainError with -1.0:

julia> sqrt(-1+0im)
0.0 + 1.0im

julia> sqrt.([1,4,9])
3-element Vector{Float64}:
 1.0
 2.0
 3.0

This is handled through multiple dispatch which makes it easy for libraries to provide compatible implementations of different types that can be used through the same polymorphic functions.

The math and cmath modules are designed to follow the module/namespace approach rather than the polymorphic approach. This means that you can use functions like math.sqrt etc to control the domain of a particular calculation: for 64 bit floating point with individual numbers then the math module is the obvious choice. On the other hand if you want to use higher precision you could use mpmath.sqrt or for exact calculations sympy.sqrt or for arrays of 64-bit floats np.sqrt and so on. By using the functions from the math module like math.sqrt you are making a particular choice among these different options.

This has become blurred over time though because the math module has picked up functions like gcd or factorial which are for integers only or functions like floor or prod which are polymorphic. The question then is why did these functions get added to the math module and I think that the answer is just because there is no other obvious place for them. On the one hand a module named “math” seems like the obvious place for gcd but on the other hand it conflicts with the meaning and purpose originally designated for the math.* namespace.

It would be better if all functions in the math module were polymorphic and there was no need to have so many different sqrt functions across different libraries and modules. A module whose purpose was only for 64 bit floating point should be called something like math_f64. Then people who are interested in the finer points of floating point could use the functions from math_f64 but most Python users could use math.sqrt in combination with whatever integers, floats, decimals, arrays, symbolic expressions etc they want.

I very regularly see Python users who are confused by the differences between e.g. math.sqrt, numpy.sqrt, sympy.sqrt, etc and are using or mixing these incorrectly. I think the original reason that people are consistently advised to use import numpy as np and then use np.sqrt everywhere is precisely to avoid confusing these different sqrt etc functions that all have the same names but are not interchangeable. Putting np. everywhere makes formulas harder to read though as well as having an actual runtime cost. If there was only one sqrt function there would be no need to include a module prefix everywhere and you could just use from math import sqrt, cos, sin, ....

The lack of polymorphic functions for things like sin, sqrt etc mean that e.g.:

In [10]: a = np.array([sympy.sqrt(2)])

In [11]: a
Out[11]: array([sqrt(2)], dtype=object)

In [12]: np.sqrt(a)
...
AttributeError: 'Pow' object has no attribute 'sqrt'

Here NumPy does not know that it could use sympy.sqrt. Instead it looks for a .sqrt() method but outside of NumPy’s imagination there is no convention that such methods should exist. A polymorphic sqrt function is exactly what is needed here. Alternatively methods could work a bit but the core types like int, float etc would need to have the methods as well (e.g. (1).sqrt()) and the method approach doesn’t scale up when you start thinking about wanting more and more mathematical functions as well as binary, ternary etc.

On the other hand if we want to follow the module/namespace approach to controlling the domain of the calculation then we need the different modules to be consistent. You need to be able to do something like this:

for lib in [math, cmath, numpy, sympy, ...]:
     print(lib.exp(2*lib.sign(x)))

In this model the idea is that you switch domains by using a different module as the namespace for your mathematical functions like sin, cos, etc. This is approximately what has grown organically in the Python ecosystem in the absence of a stdlib polymorphic math module. The problem here though is that you need all of these lib.* namespaces to be consistent.

Many problems were seen with this across the different array libraries that mimic the np.* interface which has led to the array API standardisation effort whose sign function is referenced above. Now each array library needs to make sure that they have hundreds of different functions with consistent names and signatures but there is at least a coordinated spec to work to and the warts of old API have been worked out of it.

Every small difference like the fact that some “math modules” have a sign function but others do not is a problem if the module namespaces are supposed to be the way to switch between domains or implementations for a calculation. This makes it very difficult e.g. for SymPy to just switch between using 64 bit floating point vs mpmath vs numpy etc when evaluating an expression numerically. The analogous operations for Symbolics.jl in Julia are far easier because it is all based on polymorphic functions. Likewise Julia users are far more likely to understand how to switch the domain or implementation of any calculation of their own when it might be useful to do so and Julia libraries are more likely to support this because it is easy etc.

It may be as “shallow” as this: Mark wants sign() to return an int, and NaN is not an int.

Which is A-OK by me. I never, ever intend to compare a NaN to another float (apart from the ancient ugly trick that x == x is False iff x is a NaN), and there is nothing reliable about a NaN implementation’s sign bit anyway, so I think sign(NaN) would be doing me a favor by refusing to make up a senseless result. If I want the raw bits (and sometimes, but rarely, I do), I know how to get at them.

@oscarbenjamin That is a long interesting discussion of the history of the different design philosophies. How do you boil that discussion down to what you would like to see for a sign() function in the standard library? Is the upshot that you are advocating that it should be defined on complex input because it would conform to the array API better?


The first sentence in that math module documentation:

This module provides access to the mathematical functions defined by the C standard.

I think this combined with the last few posts really reveals to me that the math module is not quite what I thought it was I thought math was a repository for helpful pure math domain functions. However, it seems math is more than anything else, perhaps, a reflection of the C libraries it was modeled after and, in fact, yes, has some pure math domain functions, but also has some utilities for float inspection/manipulation. I would say this is more understandable in C where programmers must be more regularly aware of memory and underlying data structures. But in python it feels… antiquated(?) to combine these things.

It would feel more natural to me to have two modules, math for pure math and math_f64 (or similar, like Oscar suggested) for float oriented utilities.

Nonetheless, it seems like math now serves these two purposes. I think in a pure-math domain library that provides various math functions to users a sign() function is just totally benign and expected (see all the other programming languages), so in-as-much as the math module at least partly covers that purpose I think sign() should be included. If the math module didn’t have the historical ties to the more-float-conscious C libraries I don’t think this request would be controversial at all…


By the way, a better implementation I’ve come across recently that should appear in this thread if it doesn’t already:

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

Returns 1 for positive floats, -1 for negative floats, 0 for 0. nan gets returned as 0 I think so that would need to be special cased to a ValueError and complex input does raise a TypeError but not the right one so that would need to be cleaned up.


Use cases:

I’m a bit flummoxed at the idea that there aren’t sufficient use cases to include sign in the standard library. I think the most convincing use case (which Oscar brought up earlier) is that we sometimes model mathematical functions in python. In this case we might make use of functions like math.cos or math.exp. Regularly those mathematical functions we’re modeling might include the sign/sgn function.

I agree that there are probably more use cases for a binary comparison than ternary, and yes such a comparison can be built pretty easily using lesser/greater than comparisons. Nonetheless, for someone with a heavy math background they will think “I want the sign function here”. They won’t first think “I need to write a function that does comparison to zero”.

1 Like

Firstly the broader ecosystem needs to be considered rather than just focusing on the stdlib in isolation. If the model is that different modules provide functions with the same names and consistent mathematical definitions but different types then that model should be followed consistently. It is too late for the stdlib to try to define what the expected domain, behaviour or name of a sign function should be because the rest of the Python ecosystem has already established these things and now the stdlib should be consistent with that.

Secondly the idea that the math module is only for 64 bit real floating point functions is not really true any more. If someone wants to argue that some function does not belong in the math module unless it is only for 64 bit floats and/or is one of the functions from the C standard then they are overlooking the other functions that already break this convention. There is either a need to have something separate from the math module where those functions should go or we should just accept that the math module can have functions that do not meet those criteria. The inclusion of gcd, prod etc already implies the latter choice so the statement of the top of the math module docs that it “provides access to the mathematical functions defined by the C standard” is already out of date.

Thirdly I think that using polymorphic functions would be a much better design in general rather than having separate modules like math, cmath etc but that can be a topic for another thread.

1 Like

The math module is written in C. It was discussed to extend this to some Python functions when math.isclose() was added (it could have been used for other types, notably Decimal). Of course, any decision can be revisited, but this particular one is well suited to a C implementation anyway.

I think this is the crux of the proposal – nicely stated. I was on the fence, but when you put it this way, I’m +1 :slight_smile:

doesn’t copysign satisfy the “people who do work with the technical details of floating point” ?

I think the use case here is for the folks that aren’t that familiar with FP – and as the OP posited above, less experienced people are likely to write a quickie function that seems to work, but will break when handed an edge case (e.g. NaN) – and breaking is far worse than a reasonable default behavior.

I don’t think that’s the case – it started as a wrapper around libmath, and it has evolved to grow other useful things, mostly for floats. math.isclose() is an example of a function quite specifically for use by folks that are not FP experts.

Practicality beats purity – the way Python does polymorphism like that is through the dunder protocols e.g. __abs__ ,__round__, etc. In fact Mark suggested maybe a __sign__ dunder. But we really can’t add a dunder of every math function!

The other option is for the polymath module functions to know about all the data types they might need to work with – that might be doable if you limit it to the stdlib ones: float, int, Decimal, Fraction, complex, but a bit painful to write (and maintain?)

Which leads to the array interface-like suggestion – seems fine to me, we kinda have that with math and cmath already.

And numpy was originally designed (when it was Numeric) to be a mostly drop-in replacement for math :-). Interesting that you brought up sqrt – isn’t np.sqrt the same as math.sqrt for scalars?

Honest question: What is this reasonable default behavior, and how can we know that for the most likely situations? Are people going to generally assume that sign only ever returns (-1,1) (i.e. did they forget about 0)? Are they going to expect ValueError? Are they going to expect nan?

If they don’t expect those, there is no reasonable default behavior, and it doesn’t matter if our implementation breaks their code or if their implementation breaks.

IMO, we can’t provide something that will cover the edge cases well enough, so we shouldn’t try and just provide a recipe in the math module, pointing out these edge cases.

If we really want to add a function, we should copy np.sign’s handling. Not because it’s the best or the most logical, but because it’s the most common. (I personally dislike that it can return nan, I would expect a ValueError, but that does against numpys design in general)

I agree with you that you can’t add a dunder for every math function.

I, personally, think the most versatile approach would be to register the function with singledispatch, and then register it with all the standard library types. Then, users can still register their types if they want.

Yes. My point was that it’s also sufficient for anyone wanting a sign function who needs something more than the sort of one-liner they could write themselves. But see below.

That’s fair. But the sort of developers wanting a sign function are also unlikely to encounter special floating point values like NaN and signed zeroes. So the risk of breaking is, IMO, extremely low. The one exception is that data science packages like Pandas use NaN (rightly or wrongly) for missing values. In that context, sign(nan) needs to equal nan to preserve the “missing value” meaning.

Which leaves me with the view:

  1. I no longer object to sign being added to the math library.
  2. It should have the semantics sign(x) = math.nan if math.isnan(x) else 0.0 if x == 0 else -1.0 if x < 0 else 1.0 (note the x == 0 check should pick up signed zeroes).
  3. It should return a float, to allow for the NaN return value.
  4. It should definitely not handle complex numbers, or anything not convertible to float. If you want a sign function for complex numbers, it should go in cmath. This function is not the place to start a fight to merge math and cmath. That debate should be a separate question.
3 Likes

and

[I am a major numpy user, so like this in principle, but …]

numpy has two things that are distinct from pure Python:

  1. vectorized computations
  2. single data type arrays.

Because of (1) – “normal” operations should not raise due to the value of the inputs: if one item in an array is NaN, then sign(an_array) shouldn’t raise. So numpy propagates NaNs and other special values instead of raising.

Because of (2), sign() can’t return a None or any other type that isn’t a float.

But does the stdlib have to follow those rules? I don’t think so – it already doesn’t with, e.g. divide by zero – Python raises, numpy (by default) returns inf and provides a warning.

So I think t’s fine for math.sign() to return ints, and None, or raise, if that’s the better API for Python.

But no need for gratuitous differences – it should have the same behavior around -0 and 0, for instance.

But pandas (and other data science packages) are built on numpy, and soon, on the “array api” brought up on this thread. The stdlib can (and should) do what best fits with pure Python, and not worry about what pandas et. al. do.

Final process note: I haven’t looked in the archives, but I’ll take the OP’s word for it that this was blocked in the past due to a lack of consensus around the edge cases.

That means that consensus wasn’t reached, not that it couldn’t be reached. Consensus is hard – but it can be done. I’ve seen a number of PEPs get derailed by that challenge, and others (all the successful ones) meet the challenge – and it’s never easy.

In short – this is still worth discussion, and I think it can be successful if someone has the persistence and consensus building skills to do it.

2 Likes

I don’t see a need to revisit that decision. C seems fine (I just don’t know much about C and the C implementation of Python so it’s more opaque to me personally).

Here are the possible handlings of the edge cases that have been discussed and I can imagine. (*) indicates that I find that handling “reasonable”.

  • 0 always returns the same thing as +0.0 (*) (All items below assume this one but one could alternatively imagine e.g. sign(0) == 0 while sign("-0.0") == float("+0.0") or float("-0.0").)
  • ±0.0 returns 0 (*)
  • ±0.0 returns 1 (Not consistent with mathematical signum function)
  • ±0.0 returns ±1 (Not consistent with mathematical signum function)
  • ±0.0 returns 0.0 (*)
  • ±0.0 returns ±0.0 (*)
  • ±0.0 returns 1.0 (Not consistent with mathematical signum function)
  • ±0.0 returns ±1.0 (Not consistent with mathematical signum function)
  • ±0.0 returns None (This makes the output type of sign varied, and the sign(0.0) is otherwise conventionally defined)
  • ±nan raises an exception (*)
  • ±nan returns 0 (not being a number, nan shouldn’t have a sign)
  • ±nan returns 1 (not being a number, nan shouldn’t have a sign)
  • ±nan returns ±1 (not being a number, nan shouldn’t have a sign)
  • ±nan returns 0.0 (not being a number, nan shouldn’t have a sign)
  • ±nan returns ±0.0 (not being a number, nan shouldn’t have a sign)
  • ±nan returns 1.0 (not being a number, nan shouldn’t have a sign)
  • ±nan returns ±1.0 (not being a number, nan shouldn’t have a sign)
  • ±nan returns nan (*)
  • ±nan returns None (this makes the output type of sign varied)

So that leaves

  • 0 always returns the same thing as +0.0 (*)
  • +/-0 returns 0
  • ±0.0 returns 0.0 (*)
  • ±0.0 returns ±0.0 (*)
  • ±nan raises an exception (*)
  • ±nan returns nan (*)

As all being definitely reasonable. There is a question about which of these is optimal, that is, which will be most useful for the most # of users of the next e.g. 10 years. That question is almost certainly impossible to answer, but as long as we go with any of these “reasonable” choices I think that would be acceptable. I don’t think we should hold ourselves to the standard of the “optimal” choice otherwise a choice won’t be made. This is the sort of stuff where we make a choice and explain why we made that choice in, e.g. the rejected ideas section of a PEP or changelog note.


FWIW, at this point in the discussion, I’m shifting towards designing the sign function as a “pure python” thing as if numpy etc. didn’t exist. I say this because this function will likely be used outside of the context of numpy, pandas etc, because in those cases users could just use numpy.sign(). To that end, I think simple is best and I’m on board with Mark’s suggestion. sign() at least accepts input castable to float, always returns an int (no ± information on returned 0) and raises an exception for nan input.

copysign is the float oriented sign function which remembers ALL the sign bit information about a float, even when you may not want it to (±0.0 or ±nan) while sign would be the float naive sign function which will never surprisingly reveal to users that there is a technical difference between +-0.0 and +-nan.

I also agree with all of this. @oscarbenjamin raises interesting points about polymorphism, conformance etc, but I do think those questions are out of scope for this specific (and otherwise very narrow) idea.

Relatedly:

I agree with this doubt. A stdlib workflow is going to be different than a numpy workflow so it’s not necessary to make the same design choices. That said, the argument for generic consistency just to minimize confusion is compelling.


I think it’s worth mentioning that, in addition to no clear consensus on edge cases, the other big blocker was the maintainers not being convinced that there are important enough use cases for a ternary sign function. In the discussion they found use cases for the binary copysign more compelling.


To those more experienced with this idea processing process: Where are we with this idea? What are the productive next steps towards accepting or rejecting this idea? Do we continue hammering on and re-summarizing some of the discussion points in this thread (mainly “how should edge cases be handled” and “are the use cases compelling”)?

2 Likes

You need at least one core developer who’s willing to support the idea. I’m lukewarm on it, so I won’t support it myself, maybe Mark will. You also need someone who will implement it (my C skills are rusty, so I won’t offer on that one either). Your chances of getting approval are much lower if you don’t have an implementation.

My personal feeling is that this is a small enough change that it could be submitted as a PR to the CPython repo, but others may think it needs a PEP, in which case, see PEP 1 for more details on the PEP process.

As the PR or PEP author, you would have the final say on what the specific behaviour should be, and whether you consider the arguments in favour of the proposal compelling. When you think it’s ready, you submit the PR/PEP for approval, and wait for the result. Ultimately, you need to remember that this is your proposal, and follow your instincts.

1 Like

Thanks for this information and guidance. It seems like a PR including tests, docs etc. would give something concrete for people to look at and may win lukewarm people over from 0 or +0 to +1. I have little to no personal experience with the CPython PR/PEP process but I also imagine this is small enough to not require a PEP. I’d expect a PEP if a handful of functions were being added or a design philosophy change wrt e.g. polymorphism was being made.

I’ve been mulling over the idea of putting together a PR. I looked over the relevant C code that sets up the math module. Basically it is far out of my expertise even to think about how to add a simple function and would require a lot of learning on my part. I’m personally interested in learning more about how CPython works under the hood, but it would take me a long time.

Adding the non-C stuff (e.g. documentation and tests) is probably within my capabilities, so I could open a PR and start work on that, with an open call for help with a C-implementation.

As precedent, note that math.isclose was proposed in a PEP (PEP 485 – A Function for testing approximate equality | peps.python.org). I don’t have any strong opinion on math.sign, but it seems there is a lot of disagreement over what the function should do in various cases. A PEP could be a good way to summarize that discussion and justify the design decisions.

3 Likes