Почему в python нет функции для математического округления?

Рейтинг: 5Ответов: 3Опубликовано: 03.03.2025

Для обыкновенного математического округления приходится задействовать сторонний модуль и 2 функции:

from decimal import Decimal, ROUND_HALF_UP

d = Decimal(5.445).quantize(Decimal("1.00"), ROUND_HALF_UP)

Почему не добавят во встроенный интерпретатор функцию типа round только для математического, а не банковского округления? И есть ли варианты округлять проще с точки зрения производительности и читабельности кода?

Ответы

▲ 4Принят

Потому что не получится обойтись типом float. Вот это не работает, назависимо от того что вы напишете внутри функции:

def perfect_round(x: float) -> float:
    ...

Ставим задачу: требуется функция округления вещественного числа до двух знаков после запятой. Если третий знак ровно 5, округляем по направлению от нуля. Задача должна быть решена с минимальным привлечением библиотек.

Первое что нужно решить: как представить тип для аргумента и результата. float – естественный ответ.

Уже в выборе типа кроется проблема. Берём код с Decimal и адаптируем под задачу:

from decimal import Decimal, ROUND_HALF_UP


def perfect_round(x):
    return float(Decimal(x).quantize(Decimal('1.00'), ROUND_HALF_UP))


print(perfect_round(5.465))

Что-то пошло не так. Идеальное округление не идеально: 5.465 округлилось вниз, а должно вверх, к 5.47:

$ python perfect_round.py
5.46

Потому что 5.465 не представимо в формате float точно. Самое близкое значение – 5.464999999999999857891452847979962825775146484375. А так как эта дробь строго меньше чем 5.465, то округлена она должна быть вниз.

Вторая, меньшая неприятность – результат тоже не представим во float точно. На печати вы видите круглое 5.46, в памяти компьютера лежит 5.45999999999999996447286321199499070644378662109375.

Мы ещё программировать не начали, а уже проблемы: трудно/невозможно на float написать округление до двух знаков, которое ведёт себя ожидаемо для пользователя.

Так как проблемы вызваны выбором float нужно вернуться и изменить выбор. Вспоминаем что мы хотим максимально простое решение. В наборе стандартных типов Python остался только один простой тип, который поддерживает дробные числа. Это строки. То есть, надо писать:

def perfect_round(x: str) -> str:
    ...

Теперь вы понимаете почему функции для округления нет в стандартной библиотеке?

▲ 9

Почему не добавят во встроенный интерпретатор функцию типа round только для математического, а не банковского округления?

Трудно сказать, возможно потому, что распространённые процессоры (x86/ARM) поддерживают 4 типа округления плавающей точки, как и традиционные языки/ОС (Fortran-2003/C17/C++/POSIX):

  • FE_DOWNWARD (roundTowardNegative или floor());
  • FE_TONEAREST (roundTiesToEven или nearbyint(), округление по-умолчанию, rint());
  • FE_TOWARDZERO (roundTowardZero или int());
  • FE_UPWARD (roundTowardPositive или ceil());
  • И только Fortran-2018 и C23 описали: FE_TONEARESTFROMZERO (roundTiesToAway или round()).

Правда, это же не помешало же изначально иметь функцию round() в C/C++/POSIX? Ну, да, не одна команда, как rint(), но некоторым нравится, да и нужно ж иногда.

И есть ли варианты округлять проще с точки зрения производительности и читабельности кода?

А какие у нас есть ещё варианты?

Стандартный, для float (без поддержки math.inf и math.nan):

import math
def math_roundTiesToAway(x):
    return math.trunc(x + math.copysign(0.5, x))

Для массивов и типов NumPy:

import numpy as np
def np_roundTiesToAway(x):
    return np.trunc(x + np.copysign(0.5, x))

В смысле, читаемости (т.е. отсутствию необходимости соображать в математике):

import ctypes
import sys

if sys.platform.startswith("darwin"):
    libc = ctypes.CDLL("libSystem.B.dylib")
elif sys.platform.startswith('win'):
    libc = ctypes.cdll.msvcrt
else:
    libc = ctypes.CDLL("libc.so.6")

libc.round.argtypes = [ctypes.c_double]
libc.round.restype = ctypes.c_double
roundTiesToAway = libc.round

Но он длинный и, для float, не слишком шустрый. Зато, как и np_roundTiesToAway(), с math.inf и math.nan дружит, всё согласно IEEE-754.

Есть варианты с Cython, они, и шустрее, и используют round() из С (из libc), т.е. надёжнее.

x = 2.675
a0 = np.double(x)
a1000 = np.full(1000, a0)
q = Decimal("1.")
%timeit math_roundTiesToAway(x)
%timeit float(math_roundTiesToAway(x))
%timeit np_roundTiesToAway(a0)
%timeit np_roundTiesToAway(a1000)
%timeit roundTiesToAway(x)
%timeit Decimal(x).quantize(q, ROUND_HALF_UP)
99.6 ns ± 2.89 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)
130 ns ± 11.7 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)
1.73 μs ± 22.4 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)
3.81 μs ± 96.2 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
348 ns ± 13.2 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)
868 ns ± 9.77 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

Поскольку math_roundTiesToAway(x) (math.trunc(x)) имеет тип int, добавлена оценка для float(math_roundTiesToAway(x)).

P.S.

Хотя с округлением к целому всё неплохо. Однако, что касается округления до N-цифр после запятой, то постановка задачи начинает выглядеть неоднозначной.

Если аргумент имеет тип float, то, так же как и при использовании стандартной функции round(), можно получить немного неожиданные результаты, вызванные двоичным представлением аргумента с ограниченной точностью:

x x.hex() ≈x ndigits decimal_roundTiesToAway math_roundTiesToAway
0.15 0x1.3333333333333p-3 ≈0.14999999999999999 1 0.1 0.2
0.145 0x1.28f5c28f5c28fp-3 ≈0.14499999999999999 2 0.14 0.14
2.675 0x1.5666666666666p+1 ≈2.6749999999999998 2 2.67 2.68
2.0115 0x1.0178d4fdf3b64p+1 ≈2.0114999999999998 3 2.011 2.011
0.0000005 0x1.0c6f7a0b5ed8dp-21 ≈4.9999999999999998e-7 6 0.000000 1e-06

Если цели применения roundTiesToAway() сугубо вычислительные/математические, к примеру, равномерное распределение младших цифр, или уменьшение вероятности появления 0 вдвое, то это не составляет проблемы. Всё строго формально корректно и все цели достигнуты.

Поскольку для типа Decimal умножение на степень 10 - точное, таких неожиданностей больше, почти все. Ошибки округления при операции math.fma(x, q, math.copysign(0.5, x)) немного уменьшают вероятность таких неожиданностей, но не исключают их полностью.

Однако, если цель получить красивый результат или удовлетворить каким-то иным требованиям, то постановку задачи можно видоизменить, т.е. округлять вверх если граница половины младшей цифры попадает в диапазон [x - 𝛿, x + 𝛿], где 𝛿 больше 0, но меньше math.ulp(x) (т.е. если число i..i,f..f50..0 равняется x с машинной точность, то следует применить правило десятичного округления).

import decimal

_drtta_q0 = decimal.Decimal("1")

def decimal_roundTiesToAway(x, ndigits=0, pretty=False,
                            pretty_delta_x = float.fromhex('0x1.8p-1')  # 0.75ulp
                           ):
    """
    decimal_roundTiesToAway(x, n) - округлённое до n-цифр машинное представление x
    str(decimal_roundTiesToAway(x, n, pretty=True)) - округлённое до n-цифр str(x)
    """
    dx = decimal.Decimal(x)
    if not ndigits:
        return dx.quantize(_drtta_q0, decimal.ROUND_HALF_UP)
    # assert math.ulp(x)/math.fabs(dx - dx.next_toward(0)) >= 2, \
    #     "Default context precision too small"
    q = decimal.Decimal(f"1e{-ndigits}")
    if not pretty or isinstance(x, str) or isinstance(x, decimal.Decimal):
        return dx.quantize(q, decimal.ROUND_HALF_UP)
    return (dx + 
            decimal.Decimal(math.copysign(pretty_delta_x*math.ulp(x), x))
           ).quantize(q, decimal.ROUND_HALF_UP)
import math

try:
    from math import fma as lc_fma  # Python 3.13 и выше
except ImportError:
    import ctypes
    import sys

    if sys.platform.startswith("darwin"):
        _libc = ctypes.CDLL("libSystem.B.dylib")
    elif sys.platform.startswith('win'):
        _libc = ctypes.cdll.msvcrt
    else:
        _libc = ctypes.CDLL("libc.so.6")
        
    _libc.fma.argtypes = [ctypes.c_double, ctypes.c_double, ctypes.c_double]
    _libc.fma.restype = ctypes.c_double
    lc_fma = _libc.fma

def math_roundTiesToAway(x, ndigits=0, pretty=False,
                         pretty_delta_x = float.fromhex('0x1.p-1')  # 0.5ulp
                        ):
    """
    math_roundTiesToAway(x, n) - округлённое до n-цифр машинное представление x
    str(math_roundTiesToAway(x, n, pretty=True)) - округлённое до n-цифр str(x)
    """
    if not ndigits:
        return math.trunc(x + math.copysign(0.5, x))
    if ndigits < 0:
        q = float(10**-ndigits)
        return math.trunc(x/q + math.copysign(0.5, x))*q
    q = float(10**ndigits)
    if not pretty:
        return math.trunc(lc_fma(x, q, math.copysign(0.5, x)))/q
    xl = math.copysign(pretty_delta_x*math.ulp(x), x)
    h = x + xl
    l = xl - (h - x)
    x, xl = h, l
    # assert x == x + xl  and  abs(xl) < math.ulp(x)
    ql = float(10**ndigits - int(q))
    # assert q == (q + ql)  and  abs(ql) < math.ulp(q)
    h = x*q
    l = lc_fma(x, q, -h)
    l += ql*x + xl*q 
    xq = h + l
    xql = l - (xq - h)
    # assert xq == (xq + xql)  and  abs(xql) < math.ulp(xq)
    a05 = math.copysign(0.5, x)
    h = xq + a05
    t = h - xq
    l = (xq - (h - t)) + (a05 - t)
    l += xql
    xqa05 = h + l
    xqa05l = l - (xqa05 - h)
    # assert xqa05 == (xqa05 + xqa05l)  and  abs(xqa05l) < math.ulp(xqa05)
    txqa05 = math.trunc(xqa05)
    if txqa05 == xqa05:
        txqa05 += math.floor(xqa05l) if 0 < xqa05 else math.ceil(xqa05l)
    return txqa05/q
print(f"{decimal_roundTiesToAway(2.0115, 3)=}")
%timeit decimal_roundTiesToAway(2.0115, 3)
print(f"{math_roundTiesToAway(2.0115, 3)=}")
%timeit math_roundTiesToAway(2.0115, 3)
decimal_roundTiesToAway(2.0115, 3)=Decimal('2.011')
1.47 μs ± 15.9 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)
math_roundTiesToAway(2.0115, 3)=2.011
267 ns ± 2.42 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)
print(f"{decimal_roundTiesToAway(2.0115, 3, pretty=True)=}")
%timeit decimal_roundTiesToAway(2.0115, 3, pretty=True)
print(f"{math_roundTiesToAway(2.0115, 3, pretty=True)=}")
%timeit math_roundTiesToAway(2.0115, 3, pretty=True)
decimal_roundTiesToAway(2.0115, 3, pretty=True)=Decimal('2.012')
2.42 μs ± 74.8 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
math_roundTiesToAway(2.0115, 3, pretty=True)=2.012
835 ns ± 13 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)
▲ 2

Добавлю уточнение для себя и других пользователей. Как я понял из ответа Stanislav Volodarskiy, для отображения ожидаемых значений при математическом округлении необходимо еще дополнительное преобразование округляемого числа в тип str, после чего уже можно преобразовывать полученное значение из Decimal во float для дальнейших вычислений с float.

from decimal import Decimal, ROUND_HALF_UP

def perfect_round(x):
    return Decimal(x).quantize(Decimal('1.00'), ROUND_HALF_UP)

print(float(perfect_round('5.465'))) # 5.47

В то время как без строкового преобразования получится округление вниз:

print(float(perfect_round(5.465))) # 5.46