Arredondamento Python 3.x até a metade

8

Eu sei que as perguntas sobre arredondamento em python já foram feitas várias vezes, mas as respostas não me ajudaram. Estou procurando um método que está arredondando um número flutuante pela metade e retorna um número flutuante. O método também deve aceitar um parâmetro que define a casa decimal para a qual arredondar. Eu escrevi um método que implementa esse tipo de arredondamento. No entanto, acho que não parece nada elegante.

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)

Eu não gosto disso, que eu tenho que converter float em uma string (para evitar imprecisão de ponto flutuante) e depois trabalhar com o módulo decimal . Você tem soluções melhores?

Edit: Como apontado nas respostas abaixo, a solução para o meu problema não é tão óbvia quanto o arredondamento correto requer uma representação correta dos números em primeiro lugar, e esse não é o caso do float. Então, eu esperaria que o seguinte 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 difere do código acima apenas pelo fato de que o número flutuador é convertida diretamente em um número decimal e não para uma string primeiro) para retornar 2,18 quando usado como este: round_half_up(2.175, 2)Mas não porque Decimal(2.175)vai voltar Decimal('2.17499999999999982236431605997495353221893310546875'), a forma como o flutuador número é representado pelo computador. Surpreendentemente, o primeiro código retorna 2,18 porque o número flutuante é convertido em string primeiro. Parece que a função str () conduz um arredondamento implícito para o número que inicialmente deveria ser arredondado. Portanto, existem dois arredondamentos. Mesmo que esse seja o resultado que eu esperaria, é tecnicamente errado.

IcesHay
fonte
@IcesHay Não sei se entendi o que você quer dizer arredondando o número pela metade. Você pode nos dar alguns exemplos?
Quênia
@Kenivia O que quero dizer com isso é que você arredonda a casa decimal relevante de 0-4 para baixo e de 5-9 para cima: Se arredondar para 0 casas decimais: 2,4 = 2; 2,5 = 3; 3.5 = 4, etc
IcesHay

Respostas:

7

Surpreendentemente é difícil fazer o arredondamento , porque você precisa lidar com os cálculos de ponto flutuante com muito cuidado. Se você está procurando uma solução elegante (curta, fácil de entender), o que você gosta é um bom ponto de partida. Para estar correto, você deve substituir decimal.Decimal(str(number))pela criação do decimal a partir do próprio número, o que fornecerá uma versão decimal de sua representação exata:

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

Decimal(str(number))efetivamente arredonda duas vezes , pois a formatação do float na representação de sequência executa seu próprio arredondamento. Isso ocorre porque str(float value)não tentará imprimir a representação decimal completa do flutuador, ele imprimirá apenas dígitos suficientes para garantir que você recupere o mesmo flutuador se passar esses dígitos exatos para o floatconstrutor.

Se você deseja manter o arredondamento correto, mas evite depender do decimalmódulo grande e complexo , certamente poderá fazê-lo, mas ainda precisará de alguma maneira de implementar a aritmética exata necessária para o arredondamento correto. Por exemplo, você pode usar frações :

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

Observe que a única parte complicada no código acima é a conversão precisa de um ponto flutuante em uma fração e que pode ser descarregada no as_integer_ratio()método float, que é o que os decimais e as frações fazem internamente. Portanto, se você realmente deseja remover a dependência fractions, pode reduzir a aritmética fracionária para aritmética de número inteiro puro; você permanece na mesma contagem de linhas à custa de alguma legibilidade:

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)

Observe que testar essas funções destaca as aproximações realizadas ao criar números de ponto flutuante. Por exemplo, print(round_half_up(2.175, 2))imprime 2.17porque o número decimal 2.175não pode ser representado exatamente em binário, portanto, é substituído por uma aproximação que passa a ser um pouco menor que o decimal 2.175. A função recebe esse valor, o acha menor que a fração real correspondente ao decimal 2.175 e decide arredondá-lo para baixo . Isso não é uma peculiaridade da implementação; o comportamento deriva das propriedades dos números de ponto flutuante e também está presente no roundbuilt-in do Python 3 e 2 .

user4815162342
fonte
O problema d = Decimal(number).quantize(...)é que não há representação exata dos números flutuantes. Assim Decimal(2.175), você fornecerá decimal ('2.17499999 ...') (e, portanto, o arredondamento estará incorreto) enquanto Decimal('2.175')estiver decimal ('2.175'). É por isso que estou convertendo para string primeiro.
IcesHay
@IcesHay É verdade que não há representação exata de um número como 0,1 (ou seja, a fração 1/10) no binário porque seu denominador não é uma potência de 2. Mas uma vez que você tenha um flutuador Python real, a aproximação já aconteceu e o número que você tem na memória o faz ter uma representação exata, que é fornecido pelo as_integer_ratio()(no caso de 0,1, que seria 3602879701896397/36028797018963968). O decimal.Decimal(float)construtor usa essa representação e o arredondamento subsequente será correto e executado exatamente.
user4815162342
Você tem certeza sobre isso? Porque testei a função round_half_up(2.175, 2)e ela retornou 2,17, o que está incorreto. Talvez haja algo mais que eu esteja fazendo errado?
IcesHay
@IcesHay É exatamente isso, 2.175 é aproximado quando lido como um float, em um número exatamente representável como a fração 2448832297382707/1125899906842624, que é um pouco menor que 2.175. (Você pode testar isso com o '%.20f' % 2.175que avalia '2.17499999999999982236'.) Por exemplo, no Python 2.7, que usa arredondamento para metade, round (2.175) retorna 2.17, e esse tipo de resultado é documentado como uma limitação do ponto flutuante.
user4815162342
1
@IcesHay Não é assim que o ponto flutuante funciona. Depois que uma string é lida em ponto flutuante, o número original (que distingue "2.175" de "2.174999999999 ...") é irremediavelmente perdido. Sua pergunta é sobre a implementação da metade dos números, que é essa resposta. Parece que seu problema real é algo totalmente diferente, porque agora acontece que mesmo o Python 2 round(que implementa a metade da metade) também não é bom o suficiente. Edite a pergunta para especificar o caso de uso real - talvez ele seja satisfeito evitando flutuações e usando decimais.
user4815162342
1

Eu não gosto disso, que eu tenho que converter float em uma string (para evitar imprecisão de ponto flutuante) e depois trabalhar com o módulo decimal. Você tem soluções melhores?

Sim; use Decimalpara representar seus números em todo o programa, se você precisar representar exatamente números como 2.675 e tê-los arredondados para 2,68 em vez de 2,67.

Não há outro caminho. O número do ponto flutuante que é mostrado na tela como 2.675 não é o número real 2.675; de fato, é um pouco menor que 2.675, razão pela qual é arredondado para 2,67:

>>> 2.675 - 2
0.6749999999999998

Ele é exibido apenas na forma de sequência, '2.675'porque é a sequência mais curta float(s) == 2.6749999999999998. Observe que essa representação mais longa (com muitos 9s) também não é exata.

Como você escreve sua função de arredondamento, não é possível my_round(2.675, 2)arredondar para cima 2.68e também para my_round(2 + 0.6749999999999998, 2)arredondar para 2.67; porque as entradas são realmente o mesmo número de ponto flutuante.

Portanto, se seu número 2.675 for convertido em um float e vice-versa, você já perdeu as informações sobre se deve arredondar para cima ou para baixo. A solução não é fazê-lo flutuar em primeiro lugar.

kaya3
fonte
-2

Depois de muito tempo tentando produzir uma função elegante de uma linha, acabei obtendo algo comparável a um dicionário em tamanho.

Eu diria que a maneira mais simples de fazer isso é apenas

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

eu reconheceria que isso não é preciso em todos os casos, mas deve funcionar se você quiser apenas uma solução rápida e simples.

Quênia
fonte
Obrigado pelo seu esforço. Sua solução pode ser simples, mas é uma saída fácil e pouco prática. Só para esclarecer mais uma vez: estou procurando uma solução, que é arredondar corretamente os valores flutuantes "pela metade" para qualquer casa decimal. Estou curioso para saber se alguém teve um problema semelhante e tem uma solução elegante e eficaz para isso.
IcesHay
2
Esta solução está incorreta; por exemplo, round_half_up(1.499999999, 0)retorna 2,0 em vez de 1,0.
user4815162342
@ user4815162342 não tão redonda normalmente arredonda 1,5 para baixo, razão pela qual op esta pergunta em primeiro lugar
Kenivia
@ user4815162342 mas sim, não é preciso nesses casos. mas se você quiser que 1,5 seja arredondado, acho que essa é uma solução rápida e agradável. Grande resposta de sua parte btw :)
Kenivia
1
Obrigado. Se você tivesse me perguntado ontem, eu diria que o arredondamento de 1,499999999 para 2,0 estava incorreto tanto em geral quanto no OP em particular. Porém, após a última rodada de discussão com o OP nos comentários à minha resposta, não tenho mais certeza e estou começando a pensar que a pergunta não é respondida em sua forma atual. Sob essa luz, estou revogando meu voto negativo.
user4815162342