Rounding to significant figures - feature request for math library

Oh, I didn’t say anything about Fraction – I have literally never used them, so I could be off base here, but it strikes me as an odd type to use for the type of calculations you’d want to round to significant figures. Yes, by definition it can exactly represent any base 10 number, sure – but why are you using them if you don’t want exact number in other bases? or for that matter, exact number – if you only care about a few sig figs, Fraction seems an odd choice.

OK, thinking and playing with it a bit more. Given that using the formatting operation seems to be the best way to find the significant figures, a function that works for both float and Decimal is trivial:

def sigfigs(x, n):
    return type(x)(f"{x:.{n}g}")

Done. This doesn’t work for int, because int doesn’t know how to parse a string with an exponent:

----> 1 int('1e5')
ValueError: invalid literal for int() with base 10: '1e5'

As I said before, I don’t think supporting int is important, but if you want to, you can convert to a Decimal first, and then an int:

def sigfigs_decimal(x, n):
    return type(x)(Decimal(f"{x:.{n}g}"))

I don’t this there’s a loss here – the whole point is decimal digits.

This approach also allows custom classes, as they could do it by supporting the “g” format specifier, and being able to parse the exponential form as a string.

It does not work for Fraction – Fraction neither supports the g format specifier nor direct conversion to Decimal. Again, I don’t think that’s important, but if you wanted it, it would have to be special cased somehow.

I’ve tried these out with a few numbers and inf and NaN, and it all seems to work :slight_smile:

Which leaves: where to put it, and what to call it – prime candidates for bike-shedding.

Here’s a little test code:

import math
from decimal import Decimal

def sigfigs(x, n):
    return type(x)(f"{x:.{n}g}")

def sigfigs_decimal(x, n):
    return type(x)(Decimal(f"{x:.{n}g}"))


n = 3

values = ['123456789',
          '123456789e20',
          '123456789e-20',
          'nan',
          'inf',
          ]
for val in values:
    print("\nStarting with:", val)
    for typ in (float, Decimal):
        print("Using type:", typ)
        print("sigfigs:", repr(sigfigs(typ(val), 3)))
    for typ in (float, Decimal, int):
        print("Using type:", typ)
        # int can't represent NaN, Inf, or fractions.
        if not ((typ is int) and not math.isfinite(float(val)) or abs(float(val)) < 1):
            print("sigfigs_decimal:", repr(sigfigs_decimal(typ(Decimal(val)), 3)))

Blockquote
As I said before, I don’t think supporting int is important, but if you want to, you can convert to a Decimal first, and then an int:

Just a comment on this. I think your suggestion to convert int to Decimal (or float) is good. Actually integers might be important to people. Say for example you have a result 43,456,888 you want to 3 sig figs then you will want 43,500,000. It is possible the input may be provided as an int in this case (although more likely to be a float). So coercing an int it into something that works would be good I think rather than not supporting ints at all.

1 Like

Sure – but as you say, would that be stored as an int? I don’t think it’s likely. However, as Python tries to smooth the transition between int and float, folks will have ints in contexts where they may not be the most appropriate, so good to support it. But if there is a significant_figures function that takes a float, you could always convert, or simply pass the int in and have it cast for you (unless you want really, really big ints…) – it would just return a float.

I’m thinking of taking Paul’s suggesting and just make a PR and see what the core devs think – what do you all think of these two options:

  1. Two functions, one in math, and one in decimalmath.sig_figs() would return a float, decimal.sigfigs() would return a Decimal
  2. One function, in decimal, which would type cast the return value back to the type passed in. – so pass it a float, get a float back, pass it a Decimal, get a Decimal back. Pass it any type that has. ‘g’ formatter, and can parse a string like ‘123e4’, get that type back (for custom classes).

NOTES:

  • I think the polymorphic one should go in decimal, rather than math, because math really is all about floats – and this is inherently a decimal action.

  • Any other possible home for it? I don’t see it as being important enough for a bulltin.

  • Any other name? is sig_figs as good as it gets? – I think anyone that is looking for significant figure rounding will recognize it, but maybe there’s a better option. (The one I found on PyPi is called signif() – which I don’t much like.

As a word of warning, making any substantial changes to the decimal module would be a hard sell: we’ve been very conservative with decimal module changes, with the primary aim of simply keeping it aligned with the specification. It’s not really a good home for new features.

But talking of decimal, there’s another can of worms lurking: rounding modes. The built-in round picks up the context rounding mode for Decimal types, so offers a way to use any of the eight rounding modes specified in the decimal module.

And once you’re dealing with rounding modes and polymorphism, a solution is substantially more than a one-liner, so now it seems much more reasonable to make a PyPI package.

Talking of which: for fun, I just pushed this to PyPI: rounders · PyPI

This was a toy package that I created for an internal-to-Enthought presentation some years ago. The presentation wasn’t really about rounding - it was about functools.singledispatch, extensibility, polymorphism, and duck-typing, but I used the rounding use-case to drive the discussion. (There may or may not have been a slide with the title “Python’s rounding sucks.” I plead the fifth.) I found some time over the last holiday break to polish it up a bit, and in light of this discussion I thought I might as well push it to PyPI. Install in the usual way with pip install rounders.

Quick example:

Python 3.10.9 (main, Dec  7 2022, 02:03:23) [Clang 13.0.0 (clang-1300.0.29.30)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from rounders import *
>>> round_to_figures(123456.789, 3)  # round-to-figures
123000.0
>>> round_to_figures(123456.789, 3, mode=TO_AWAY)  # various rounding modes supported
124000.0
>>> from decimal import Decimal
>>> round_to_figures(Decimal('123456.789'), 3)  # other numeric types
Decimal('1.23E+5')
>>> round_to_figures(123456879, 3)
123000000

It has other goodies, like drop-in replacements for math.floor and math.ceil that allow providing a number of digits to operate on:

>>> floor(3.14159, 4)
3.1415
>>> ceil(3.14159, 4)
3.1416
1 Like

@mdickinson:

I’m a bit confused – are you saying you think that a single function in decimal would be the best option, or are you not expressing a preference, but rather just pointing out issues with putting it the decimal module?

I would think that there’s a distinction between the Decimal type, which we should be very conservative in altering, and the decimal module, where adding a single function isn’t really messing with anything else, I don’t think I’m proposing a substantial change. But I’m not a core dev.

With regards to multiple rounding modes and other such features – that may be useful, but I really think a simple, does what many people want, function is a good thing for the stdlib – it’s in the spirit of the stdlib – does the basics, if you want something fancy, use a third party lib – this is true for a lot of the stdlib.

However, I’m not that familiar with Decimal, but if my proposed function, in its simple form, would do different things depending on the context in which it was called, I’m on the fence about whether that’s good or bad. On the one hand, if someone is setting the Decimal context, they probably have a reason, and it would be nice if that context was respected. On the other hand, this is supposed to be a simple, does-what-most-people-expect function – maybe it should enforce a local context, so it would always return the same thing.

By the way, I notice that Fraction now supports formatting:

https://discuss.python.org/t/support-fractions-in-string-formatting/21524/21

So the simple polymorphic version may work for Fraction, too :slight_smile:

1 Like

No, quite the reverse: the decimal module has a laser-like focus on the task of delivering the IBM Decimal Arithmetic specification in Python, and as such is highly cohesive; any change that detracts from that focus and cohesion would (I think) be unlikely to be accepted. I’d recommend looking into solutions that don’t touch the decimal module. (Or at least, solutions that don’t touch the public-facing API of the decimal module.)

Indeed it does. :slight_smile:

1 Like

And there’s the challenge – if in the math module, it would pretty much have to be float only. Certainly if I write the code, but also for similar reasons, math is about floats. It once was simply a wrapper about the C lib – it has expanded, so no longer a laser-like focus, but still not a grab-bag of math related functions, either.

However, I don’t think anyone is going to think this is worth a builtin – so where to put it? Any ideas? operator maybe?

Just use Mark’s module.

2 Likes

Well, yes, now that’s it’s published, I might do that. Though I really don’t like adding another dependency for one fairly simple function :frowning: