Python 3.x redondeando a la mitad

8

Sé que las preguntas sobre el redondeo en Python ya se han hecho varias veces, pero las respuestas no me ayudaron. Estoy buscando un método que redondea un número flotante a la mitad y devuelve un número flotante. El método también debe aceptar un parámetro que defina el lugar decimal a redondear. Escribí un método que implementa este tipo de redondeo. Sin embargo, creo que no se ve elegante en absoluto.

def round_half_up(number, dec_places):
    s = str(number)

    d = decimal.Decimal(s).quantize(
        decimal.Decimal(10) ** -dec_places,
        rounding=decimal.ROUND_HALF_UP)

    return float(d)

No me gusta, que tengo que convertir flotante a una cadena (para evitar imprecisiones de coma flotante) y luego trabajar con el módulo decimal . ¿Tienes alguna solución mejor?

Editar: como se señala en las respuestas a continuación, la solución a mi problema no es tan obvia ya que el redondeo correcto requiere la representación correcta de los números en primer lugar y este no es el caso con el flotador. Así que esperaría que el siguiente código

def round_half_up(number, dec_places):

    d = decimal.Decimal(number).quantize(
        decimal.Decimal(10) ** -dec_places,
        rounding=decimal.ROUND_HALF_UP)

    return float(d)

(que difiere del código anterior solo por el hecho de que el número flotante se convierte directamente en un número decimal y no en una cadena primero) para devolver 2.18 cuando se usa de esta manera: round_half_up(2.175, 2)Pero no lo hace porque Decimal(2.175)regresará Decimal('2.17499999999999982236431605997495353221893310546875'), de la forma en que flota El número está representado por la computadora. Sorprendentemente, el primer código devuelve 2,18 porque el número flotante se convierte primero en una cadena. Parece que la función str () realiza un redondeo implícito al número que inicialmente debía redondearse. Entonces hay dos redondeos que tienen lugar. Aunque este es el resultado que esperaría, es técnicamente incorrecto.

IcesHay
fuente
@IcesHay No estoy seguro si entiendo lo que quieres decir al redondear el número a la mitad. ¿Puedes darnos algunos ejemplos?
Kenivia
@Kenivia Lo que quiero decir con eso es que redondeas el lugar decimal relevante de 0-4 hacia abajo y de 5-9 hacia arriba: Si redondeas a 0 lugares decimales: 2.4 = 2; 2.5 = 3; 3.5 = 4 etc.
IcesHay

Respuestas:

7

El redondeo es sorprendentemente difícil de hacer bien , porque debes manejar los cálculos de punto flotante con mucho cuidado. Si está buscando una solución elegante (corta, fácil de entender), lo que le gusta es un buen punto de partida. Para ser correcto, debe reemplazarlo decimal.Decimal(str(number))con la creación del decimal a partir del número mismo, lo que le dará una versión decimal de su representación exacta:

d = Decimal(number).quantize(...)...

Decimal(str(number))efectivamente se redondea dos veces , ya que formatear el flotador en la representación de cadena realiza su propio redondeo. Esto se debe a str(float value)que no intentará imprimir la representación decimal completa del flotante, solo imprimirá suficientes dígitos para garantizar que obtenga el mismo flotante si pasa esos dígitos exactos al floatconstructor.

Si desea conservar el redondeo correcto, pero evite depender del decimalmódulo grande y complejo , ciertamente puede hacerlo, pero aún necesitará alguna forma de implementar la aritmética exacta necesaria para el redondeo correcto. Por ejemplo, puedes usar fracciones :

import fractions, math

def round_half_up(number, dec_places=0):
    sign = math.copysign(1, number)
    number_exact = abs(fractions.Fraction(number))
    shifted = number_exact * 10**dec_places
    shifted_trunc = int(shifted)
    if shifted - shifted_trunc >= fractions.Fraction(1, 2):
        result = (shifted_trunc + 1) / 10**dec_places
    else:
        result = shifted_trunc / 10**dec_places
    return sign * float(result)

assert round_half_up(1.49) == 1
assert round_half_up(1.5) == 2
assert round_half_up(1.51) == 2
assert round_half_up(2.49) == 2
assert round_half_up(2.5) == 3
assert round_half_up(2.51) == 3

Tenga en cuenta que la única parte difícil en el código anterior es la conversión precisa de un punto flotante a una fracción, y que puede descargarse al as_integer_ratio()método flotante, que es lo que hacen internamente los decimales y las fracciones. Entonces, si realmente desea eliminar la dependencia fractions, puede reducir la aritmética fraccional a aritmética entera pura; permanece dentro del mismo recuento de líneas a expensas de cierta legibilidad:

def round_half_up(number, dec_places=0):
    sign = math.copysign(1, number)
    exact = abs(number).as_integer_ratio()
    shifted = (exact[0] * 10**dec_places), exact[1]
    shifted_trunc = shifted[0] // shifted[1]
    difference = (shifted[0] - shifted_trunc * shifted[1]), shifted[1]
    if difference[0] * 2 >= difference[1]:  # difference >= 1/2
        shifted_trunc += 1
    return sign * (shifted_trunc / 10**dec_places)

Tenga en cuenta que probar estas funciones resalta las aproximaciones realizadas al crear números de punto flotante. Por ejemplo, se print(round_half_up(2.175, 2))imprime 2.17porque el número decimal 2.175no se puede representar exactamente en binario, por lo que se reemplaza por una aproximación que es ligeramente más pequeña que el decimal 2.175. La función recibe ese valor, lo encuentra más pequeño que la fracción real correspondiente al decimal 2.175 y decide redondearlo hacia abajo . Esto no es una peculiaridad de la implementación; el comportamiento se deriva de las propiedades de los números de coma flotante y también está presente en la roundversión incorporada de Python 3 y 2 .

usuario4815162342
fuente
El problema con d = Decimal(number).quantize(...)es que no hay una representación exacta de números flotantes. Entonces Decimal(2.175)le dará Decimal ('2.17499999 ...') (y por lo tanto el redondeo será incorrecto) mientras que Decimal('2.175')es Decimal ('2.175'). Es por eso que estoy convirtiendo a cadena primero.
IcesHay
@IcesHay Es cierto que no hay una representación exacta de un número como 0.1 (es decir, la fracción 1/10) en binario porque su denominador no es una potencia de 2. Pero una vez que tiene un flotador de Python real, la aproximación ya ha sucedido y el número que tiene en la memoria hace tener una representación exacta, es proporcionada por as_integer_ratio()(en el caso de 0,1, que sería 3602879701896397/36028797018963968). El decimal.Decimal(float)constructor usa esa representación y el redondeo posterior será correcto y se realizará exactamente.
user4815162342
¿Estás seguro de eso? Porque probé la función round_half_up(2.175, 2)y me devolvió 2.17, lo cual es incorrecto. ¿Quizás hay algo más que estoy haciendo mal?
IcesHay
@IcesHay Eso es exactamente lo que sucede, 2.175 se aproxima cuando se lee como flotante, en un número exactamente representable como la fracción 2448832297382707/1125899906842624, que es ligeramente más pequeño que 2.175. (Puede probar esto con lo '%.20f' % 2.175que se evalúa '2.17499999999999982236'). Por ejemplo, en Python 2.7, que usa redondeo a medias, round (2.175) devuelve 2.17, y este tipo de resultado se documenta como una limitación de punto flotante.
user4815162342
1
@IcesHay Así no es como funciona el punto flotante. Una vez que se lee una cadena en coma flotante, el número original (que distinguiría "2.175" de "2.174999999999 ...") se pierde irremediablemente. Su pregunta es sobre la implementación del redondeo de números, que es lo que proporciona esta respuesta. Parece que su problema real es algo completamente diferente, porque ahora resulta que incluso Python 2 round(que implementa el redondeo a la mitad) tampoco es lo suficientemente bueno. Edite la pregunta para especificar el caso de uso real; tal vez se satisfaga evitando flotadores y utilizando decimales.
user4815162342
1

No me gusta, que tengo que convertir flotante a una cadena (para evitar imprecisiones de coma flotante) y luego trabajar con el módulo decimal. ¿Tienes alguna solución mejor?

Si; use Decimalpara representar sus números en todo su programa, si necesita representar números como 2.675 exactamente y redondearlos a 2.68 en lugar de 2.67.

No hay otra manera. El número de coma flotante que se muestra en la pantalla como 2.675 no es el número real 2.675; de hecho, es muy ligeramente inferior a 2.675, por lo que se redondea a 2.67:

>>> 2.675 - 2
0.6749999999999998

Solo se muestra en forma de cadena '2.675'porque es la cadena más corta como esa float(s) == 2.6749999999999998. Tenga en cuenta que esta representación más larga (con muchos 9s) tampoco es exacta.

Independientemente de cómo escriba su función de redondeo, no es posible my_round(2.675, 2)redondear hacia arriba 2.68y tampoco my_round(2 + 0.6749999999999998, 2)redondear hacia abajo 2.67; porque las entradas son en realidad el mismo número de coma flotante.

Entonces, si su número 2.675 alguna vez se convierte en un flotador y regresa, ya ha perdido la información sobre si debe redondearse hacia arriba o hacia abajo. La solución no es hacer que flote en primer lugar.

kaya3
fuente
-2

Después de intentar durante mucho tiempo producir una elegante función de una línea, terminé obteniendo algo comparable a un diccionario de tamaño.

Yo diría que la forma más sencilla de hacer esto es simplemente

def round_half_up(inp,dec_places):
    return round(inp+0.0000001,dec_places)

Reconocería que esto no es exacto en todos los casos, pero debería funcionar si solo desea una solución rápida y simple.

Kenivia
fuente
Gracias por su esfuerzo. Su solución puede ser simple, pero es una salida fácil y no muy práctica. Solo para aclarar una vez más: estoy buscando una solución, que es redondear los valores flotantes correctamente "a la mitad" a cualquier decimal. Tengo curiosidad por saber si alguien más tuvo un problema similar y tiene una solución elegante y efectiva para esto.
IcesHay
2
Esta solución es incorrecta; por ejemplo, round_half_up(1.499999999, 0)devuelve 2.0 en lugar de 1.0.
user4815162342
@ user4815162342 no se redondea, normalmente redondea 1,5 hacia abajo, por lo que op hizo esta pregunta en primer lugar
Kenivia
@ user4815162342 pero sí, no es preciso en esos casos. pero si solo quieres redondear 1.5 hacia abajo, creo que esta es una buena solución rápida. Gran respuesta de tu parte por cierto :)
Kenivia
1
Gracias. Si me hubieras preguntado ayer, habría dicho que redondear 1.499999999 a 2.0 era incorrecto tanto en general como para el OP en particular. Pero después de la última ronda de discusión con el OP en los comentarios a mi respuesta, ya no estoy seguro, y estoy empezando a pensar que la pregunta no tiene respuesta en su forma actual. En ese sentido, estoy rescindiendo mi voto negativo.
user4815162342