Include `math.sign`

OTOH, the implementation could resort to that only if conversion to float failed. Then suppress that exception and work instead from the basic chain of “compere to 0” comparisons that essentially define the result, via PyObject_RichCompareBool()

SupportsFloat can still be an enforced precondition, but needn’t constrain the implementation to use float operations in all cases. Most times ints, Fractions, etc would take the “all float” path, but wouldn’t blow up in “too large to convert to float” cases. They’re all happy with comparing to int 0.

I don’t care that it would be slower in such cases. Far better that, e.g.,

sign(1 << 2000)

take “extra” nanoseconds than that the attempt kill the program.

However, that doesn’t address another kind of case where we just get a dead wrong result.

>>> float(fractions.Fraction(1, 1 << 2000))
0.0

That is, “too small to convert to float” silently underflows to 0.0, so sign() will return 0.0 instead of the correct 1.0. A float view of the world is poorly suited to functions with jump discontinuities.

numpy.sign() gets that case right:

>>> numpy.sign(fractions.Fraction(1, 1 << 2000))
1

and I’m increasingly uncomfortable with that “we” don’t seem to care. To the extent possible, the desired semantics should drive the implementation, not the reverse.

5 Likes

Please don’t use those functions as examples. See above.

1 Like

There’s a “neat” answer to this problem that’s better than relying on __float__, but it also means raising on nan for consistent behavior across types.

This relies on the fact that the standard library numeric types all construct a zero when constructed without arguments, and can all losslessly accept 1, -1, or 0 as a single argument


T = TypeVar("T", int, float, Fraction, Decimal)

def sign(x: T) -> T:
    typ = type(x)
    zero = typ()
    if x > zero:  return typ(1)
    if x < zero:  return typ(-1)
    if x == zero:  return typ(0)
    raise ValueError
1 Like

Fleshing that out some:

[EDIT: “optimized” a bit - now resorts to “slow code” only if converting to float lost “non-zeroness”]
[EDIT: swapped bodies in innermost if/else, and rearranged to make it obvious that catching OverflowError is only intended in a single statement]

def sign(x):
    # insert however "complain unless SupportsFloat holds" is spelled
    use_floats = True
    if type(x) is not float:
        try:
            y = float(x)
        except OverflowError:
            use_floats = False
        else:
            if y:
                x = y
            else:
                if x:
                    # true sign lost to underflow
                    use_floats = False
                else:
                    x = y # non-zeroness preserved

    if use_floats:
        # code as now for x, which is of type float
    else:
        # code explicitly conparing x to int 0, and x is not a float
1 Like

I guess with validation for supported types, since there’s no good way to duck-type this version, you could have nan passthrough if it’s decided that’s agreeable behavior.

T = TypeVar("T", int, float, Fraction, Decimal)

def sign(x: T) -> T:
    typ = type(x)
    if typ not in {float, int, Fraction, Decimal}:
        raise TypeError(
            f"Unsupported type: {typ:!r}, expected one of"
            "float, int, fractions.Fraction, or decimal.Decimal"
        )
    zero = typ()
    if x > zero:  return typ(1)
    if x < zero:  return typ(-1)
    if x == zero:  return typ(0)
    return x  # only possible for these types with float nan
1 Like

Shouldn’t that be if not x:?
Now it looks underflow is concluded if x and y are falsey.

1 Like
...>pip install csignum-fast
...>python
Python 3.13.5 (tags/v3.13.5:6cb20a2, Jun 11 2025, 16:15:46) [MSC v.1943 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> from fractions import Fraction
>>> from signum import sign
>>> print(sign(Fraction(1, 1 << 2000)))
1
>>>

Not that sign(False) is useful, but wouldn’t this violate LSP?

Please be specific. I can’t guess what you have in mind by “that”.

Could well be that the bodies of the innermost if/else should be swapped. The problem case is if the original x is non-zero, but the coerced-to-float y ix zero. So, ya, if x and not y: should trigger that.

I appreciate that :smiley: ..And it’s what I’d expect from an implementation that doesn’t try to force things into float’s view of the world.

However, while I don’t much care about speed here, others will, and PyObject_RichCompareBool() is much costlier than direct C-level comparison of machine doubles. An entire call to a float-only sign() almost certainly runs faster than invoking PyObject_RichCompareBool() even once.

2 Likes

Nothing about the function promises it accepts subtypes of the allowed types.

LSP violations typically refer to modifying a subtype such that it is incompatible with the supertype. This is entirely seperate from functions that intentionally only support exact known types with known semantics.

This is the point where one feels a bit sad that we lost the method.

This is what I was thinking about, but reversing the logic. But I suppose this way it’s faster for most common uses :slight_smile:

OT: Even if this one (excuse me) seems to me very weird, I love Fortran, and many data scientists still love it.

I remember I followed an optional Fortran course at uni, and my teacher had her eyes shining only talking about the fact Fortran supports the complex type out-of-the-box! :smiley:

I don’t understand why Fortran never tried to reach the power of C while maintaining its expressiveness and ease to read, until it was too late.

1 Like

Context: talking about a possible sign implementation that doesn’t allow bool arguments.

It’s certainly surprising that a function accepting an int won’t accept an argument of a subtype of int. However, to my surprise, numpy’s sign does not! But you’ll have to ask someone else what _UFuncNoLoopError means - it’s gibberish to me.

>>> numpy.sign(False)
Traceback (most recent call last):
    ....
numpy._core._exceptions._UFuncNoLoopError: ufunc 'sign' did not contain a loop with signature matching types <class 'numpy.dtypes.BoolDType'> -> None
1 Like

Yes, and underflow cannot be captured even if the sign function tries alternatives only if the conversion to float has failed.

If one wants a type-generic function, I think that it should go to the type, maybe as a method or using a dunder (like __builtins__.abs(x) just calls type(x).__abs__(x)). Some people here wanted sign(x) * abs(x) to be the same as x. Integers, floats, complex numbers, fractionals and decimals have been discussed. What about arrays? Numpy arrays allow for that

>>> x = np.array([1,-2,3])
>>> abs(x)      # Numpy already provides an __abs__ dunder!
array([1, 2, 3])
>>> np.abs(x)
array([1, 2, 3])
>>> np.sign(x)
array([ 1, -1,  1])
>>> np.sign(x) * np.abs(x)
array([ 1, -2,  3])

None of the supposedly general implementations that is based on __lt__, __gt__ and __eq__ allows for this.

Instead, those implementations then also check for dubious types where x>0 and x<0 could be both true, like @acolesnicov’s “Gold Edition” code does. Since one of the goals of this forum is to allow people to learn about Python (including CPython’s C-API), I also want to comment a bit about the “The “Ternary Logic” Challenge”, specifically lines 128-132 in his signum.cpp file:

    gt = PyObject_RichCompareBool(x, Py_zero, Py_GT) + 1; /* 0: Error; 1: False; 2: True */
    stat_idx = gt;

    lt = PyObject_RichCompareBool(x, Py_zero, Py_LT) + 1;
    res = (long)gt - lt; /* Result, if nothing special */

This code is buggy. If PyObject_RichCompareBool(x, Py_zero, Py_GT) returns -1, an error occurred and an Exception has been set. Usually, you want to cleanup and return NULLin this case. If you want to proceed and call again into the C-API you have to clear the exception first. The code as is could easily lead to a crash.

After handling those cases correctly, the code is something like

    int gt = PyObject_RichCompareBool(x, Py_zero, Py_GT);
    if (gt<0) return NULL;
    int lt = PyObject_RichCompareBool(x, Py_zero, Py_LT);
    if (lt<0) return NULL;
    int eq = PyObject_RichCompareBool(x, Py_zero, Py_EQ);
    if (eq<0) return NULL;
    // The following if now superseeds the switch statement and the bitshift operations...
    if (gt+lt+eq==1) {
        return PyLong_FromLong(gt-lt);
    }
    else {
        // Error case (including nan)
   }

I appreciate the feedback, Tim!

I wanted Type Independence – I got it. Not a single line of my code was written specifically to support your sign(Fraction(1, 1 << 2000)) case. It passed out of the box because my implementation relies on semantics, not representations.

Heavy calls of Rich Comparison and possibility to catch an inappropriate argument, which makes Boom! in C++ code – it’s the price. We know it, and many people are willing to pay.

csignum-fast v1.2.2 was installed 2508 times during its 3-day life (Jan 5-7). Users were informed, and they took what they wanted. Like me.

Oh yes, many thanks for a new guy in my test suite!

I did a small benchmark to compare csignum-fast with a simple python function to get the sign of a floating point number. That’s my use case. For this case, the following code as proposed in this thread is enough:

def py_sign(x):
    if x<0:
        return -1
    if x>0:
        return 1
    if x==0:
        return 0
    return float("nan")

I now compared this with the C version using the following script:

Code for benchmark
import random
import time
import signum

N = 10
n = 1_000_000

def myrand():
    if random.random() < .1:
        return random.choice([float("inf"), float("-inf"), float("nan")])
    return (random.random()-.5)*2000

def py_sign(x):
    if x<0:
        return -1
    if x>0:
        return 1
    if x==0:
        return 0
    return float("nan")

signum_results = []
pysign_results = []

for r in range(N):
    randarray = [myrand() for i in range(n)]

    t1 = time.time()
    sgnarray = [signum.sign(x) for x in randarray]
    t2 = time.time()
    signum_results.append(t2-t1)

    t1 = time.time()
    sgnarray = [py_sign(x) for x in randarray]
    t2 = time.time()
    pysign_results.append(t2-t1)

print(f"Benchmark, repeat={N}, size={n}") 
print(f"signum.sign:\n  Minimum time (secs): {min(signum_results)}\n  Maximum time (secs): {max(signum_results)}")
print(f"py_sign:    \n  Minimum time (secs): {min(pysign_results)}\n  Maximum time (secs): {max(pysign_results)}")

The results on Python 3.13 were

Benchmark, repeat=10, size=1000000
signum.sign:
  Minimum time (secs): 0.14314985275268555
  Maximum time (secs): 0.14851927757263184
py_sign:    
  Minimum time (secs): 0.11554980278015137
  Maximum time (secs): 0.12014079093933105

and in Python 3.15.0a2+:

Benchmark, repeat=10, size=1000000
signum.sign:
  Minimum time (secs): 0.11632442474365234
  Maximum time (secs): 0.12333846092224121
py_sign:    
  Minimum time (secs): 0.10029959678649902
  Maximum time (secs): 0.10451006889343262

So, calling into C for such simple things does actually not pay off. It is actually better to have the code do what you want without too much boilerplate.

2 Likes

The question is what are the “desired semantics”? I think it is perfectly reasonable that the desired semantics are that this is a function that operates with floats and converts inputs to floats as part of a consistent model of many functions in the math module. That is not just easy to implement but efficient, well defined, well-typed, and easy to understand and reason about with predictable failure modes. Those properties should be part of the semantics that are desired.

Note that:

If the implementation is hard to explain, it’s a bad idea.

In today’s money I would also augment that with

If the type annotations are hard to explain, it’s probably a bad idea.

and then note which of the functions in the math module has type annotations that are easy to explain: the float functions and the int functions. These are the annotations for math.fsum in typeshed:

def fsum(seq: Iterable[_SupportsFloatOrIndex], /) -> float: ...

You might expect that math.prod would have something similar but actually it is:

_PositiveInteger: TypeAlias = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]
_NegativeInteger: TypeAlias = Literal[-1, -2, -3, -4, -5, -6, -7, -8, -9, -10, -11, -12, -13, -14, -15, -16, -17, -18, -19, -20]
_LiteralInteger = _PositiveInteger | _NegativeInteger | Literal[0]  # noqa: Y026  # TODO: Use TypeAlias once mypy bugs are fixed

_MultiplicableT1 = TypeVar("_MultiplicableT1", bound=SupportsMul[Any, Any])
_MultiplicableT2 = TypeVar("_MultiplicableT2", bound=SupportsMul[Any, Any])

class _SupportsProdWithNoDefaultGiven(SupportsMul[Any, Any], SupportsRMul[int, Any], Protocol): ...

_SupportsProdNoDefaultT = TypeVar("_SupportsProdNoDefaultT", bound=_SupportsProdWithNoDefaultGiven)

# This stub is based on the type stub for `builtins.sum`.
# Like `builtins.sum`, it cannot be precisely represented in a type stub
# without introducing many false positives.
# For more details on its limitations and false positives, see #13572.
# Instead, just like `builtins.sum`, we explicitly handle several useful cases.
@overload
def prod(iterable: Iterable[bool | _LiteralInteger], /, *, start: int = 1) -> int: ...  # type: ignore[overload-overlap]
@overload
def prod(iterable: Iterable[_SupportsProdNoDefaultT], /) -> _SupportsProdNoDefaultT | Literal[1]: ...
@overload
def prod(iterable: Iterable[_MultiplicableT1], /, *, start: _MultiplicableT2) -> _Multiplicable

Note that e.g.:

>>> math.prod([2, 3], start="foo")
'foofoofoofoofoofoo'

In practice it doesn’t matter that math.sign(tiny_fraction) returns zero just like math.sin(tiny_fraction) returns zero. I showed this example above because it is a use I have had for the sign function in practice:

def rescale(x):
    return sign(x)*log(1 + abs(x))

This function is useful for applying a logarithmic contraction to large values while (unlike log) working for both positive and negative values so it makes a useful axis scaling in a plot that has positive and negative values over a wide range of magnitudes. The function log(1 + abs(x)) is continuous at x = 0 but its slope flips sign making it not differentiable there. The multiplication by sign(x) perfectly cancels that flip in the sign so that rescale is both continuous and differentiable at x = 0. In so far as any floating point function is continuous the rescale function is continuous and would handle Fraction perfectly reasonably by converting it to float.

I showed before what happens with arb’s sign (sgn) function around x = 0 and I imagine that some would have thought that the behaviour is not useful:

>>> import flint
>>> a3 = flint.arb('3 +/- 1e-20')
>>> a3
[3.00000000000000 +/- 1e-19]
>>> a3.sgn()
1.00000000000000
>>> a0 = flint.arb('0 +/- 1e-20')
>>> a0
[+/- 1.01e-20]
>>> a0.sgn()
[+/- 1.01]

Here a0.sgn() turns an interval of width 2e-20 into an interval of width 2 reflecting the discontinuity in the sign function at 0. However the rescale function is still continuous and locally linear at 0:

>>> a0.sgn()*(1 + abs(a0)).log()
[+/- 1.01e-20]

A math.sign function that converts to float would be able to handle rescale(a0) in a reasonable and understandable way:

>>> float(a0)
0.0

A generic implementation that assumes semantics for <, == and > would not:

>>> a0 > 0
False
>>> a0 == 0
False
>>> a0 < 0
False

It may not pay off. The code in csignum-fast effectively goes through the same motions as a pure Python implementation so in that case using C is much less likely to help.

Your timing script is not entirely fair since you use csignum.sign in the inner loop rather than using from csignum import sign. That is adding an extra dict lookup to every call. Correcting that and using the math.sign implementation I showed above gives (on this slower computer):

Benchmark, repeat=10, size=1000000
math.sign:
  Minimum time (secs): 0.09455609321594238
  Maximum time (secs): 0.1394643783569336
py_sign:    
  Minimum time (secs): 0.1877117156982422
  Maximum time (secs): 0.2095503807067871

The difference can also be significantly more if the type is not float e.g.:

In [4]: F = Fraction(0)

In [5]: %timeit sign(F)
358 ns ± 2.46 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

In [6]: %timeit py_sign(F)
2.01 μs ± 104 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)

If I wanted to have fast code, I would have used numpy rather than have a Python loop.

Again on 3.13, if I use np.sign(nprandarray), it is much faster than doing the loop:

np.sign excl:
  Minimum time (secs): 0.0025713443756103516
  Maximum time (secs): 0.003688335418701172

This would be a typical usecase. But if you insist that the conversion list -> np.array -> list is also counted, then

np.sign incl:
  Minimum time (secs): 0.053121089935302734
  Maximum time (secs): 0.060965538024902344

is still faster.

Code
$ cat py_sign.py 
#! /bin/env python

def sign(x):
    if x<0:
        return -1
    if x>0:
        return 1
    if x==0:
        return 0
    return float("nan")

$ cat test.py 
#! /bin/env python

import random
import time
import signum
import py_sign
import numpy as np

N = 10
n = 1_000_000

def myrand():
    if random.random() < .1:
        return random.choice([float("inf"), float("-inf"), float("nan")])
    return (random.random()-.5)*2000

signum_results = []
pysign_results = []
npsign1_results = []
npsign2_results = []

for r in range(N):
    randarray = [myrand() for i in range(n)]

    t1 = time.time()
    sgnarray = [signum.sign(x) for x in randarray]
    t2 = time.time()
    signum_results.append(t2-t1)

    t1 = time.time()
    sgnarray = [py_sign.sign(x) for x in randarray]
    t2 = time.time()
    pysign_results.append(t2-t1)

    nprandarray = np.array(randarray)
    t1 = time.time()
    sgnarray = np.sign(nprandarray)
    t2 = time.time()
    npsign1_results.append(t2-t1)

    t1 = time.time()
    sgnarray = np.sign(np.array(randarray)).tolist()
    t2 = time.time()
    npsign2_results.append(t2-t1)

print(f"Benchmark, repeat={N}, size={n}") 
print(f"signum.sign: \n  Minimum time (secs): {min(signum_results)}\n  Maximum time (secs): {max(signum_results)}")
print(f"py_sign:     \n  Minimum time (secs): {min(pysign_results)}\n  Maximum time (secs): {max(pysign_results)}")
print(f"np.sign excl:\n  Minimum time (secs): {min(npsign1_results)}\n  Maximum time (secs): {max(npsign1_results)}")
print(f"np.sign incl:\n  Minimum time (secs): {min(npsign2_results)}\n  Maximum time (secs): {max(npsign2_results)}")

$ python test.py 
Benchmark, repeat=10, size=1000000
signum.sign: 
  Minimum time (secs): 0.14931559562683105
  Maximum time (secs): 0.16748976707458496
py_sign:     
  Minimum time (secs): 0.12294149398803711
  Maximum time (secs): 0.1288158893585205
np.sign excl:
  Minimum time (secs): 0.002536773681640625
  Maximum time (secs): 0.004002809524536133
np.sign incl:
  Minimum time (secs): 0.0514528751373291
  Maximum time (secs): 0.06032919883728027