1
2

More than 1 year has passed since last update.

【四捨五入】Python3の個人的つまずきポイント その3

Last updated at Posted at 2023-03-18

初めに

使用Version:Python 3.11.2

四捨五入

結論から述べるとround関数ではなく Decimal.quantize() を使うようにします。

120.5 を一の桁まで四捨五入する場合
from decimal import *  # あるいは右記のように記述する from decimal import Decimal, ROUND_HALF_UP

# 121 が返る
Decimal("120.5").quantize(Decimal("1"), rounding=ROUND_HALF_UP)
# 120 が返る
round(120.5)

「もともとround関数が用意されているのだからこれを使えばいいじゃないか」と私も最初考えていましたが、そもそもround関数は四捨五入するのではなく偶数丸め(銀行丸め)をする関数になります。

paizaスキルチェックなど競技プログラミングの場において、四捨五入のためにround関数を使ってしまうとテストケースを通した際に不正解となってしまうことがあります。

decimalモジュール

decimalモジュールは十進数を正確に表現するためのPython3標準ライブラリです。
https://docs.python.org/ja/3/library/decimal.html

decimalモジュールで四捨五入するための手順は次の通りです。

  1. Decimal型のオブジェクトを作る
    • Decimal(<文字列>)で宣言します。
    • 注意点として、数値型を渡すと正しい精度での計算ができません。必ず文字列型で渡します。
  2. quantizeメソッドで四捨五入する
    • 手順1のDecimal型オブジェクト に対して実行します。
    • Decimal(<文字列>).quantize(有効桁、rounding=ROUND_HALF_UP)
      • 有効桁も Decimal型オブジェクト で表現します。
      • 例えば一の桁までならDecimal("1")、小数点第一位までならDecimal("0.1")と記載します。

今回は四捨五入をするので丸めモードにROUND_HALF_UPを指定しています。
設定によっては、切り上げや切り捨て処理に変えることも可能です。
https://docs.python.org/ja/3/library/decimal.html#rounding-modes

round関数の仕様

偶数丸め(銀行丸め)について簡単に説明してみます。
基本的には四捨五入と同じ処理をしますが、最終桁が5のとき四捨五入の結果と異なる場合があります。
なぜなら偶数丸めは 結果の最終桁が偶数になるように丸め処理をするためです。

たとえば0.11.0を一の位まで丸めてみる場合。
行に「実行する関数」、列に「丸め対象の値」、各セルに「関数実行結果の値」を記載します。
image.png

  • 0.5のときだけ関数によって結果が異なっています。
    • Decimal.quantize()を使うことで正しく四捨五入できることが分かります。
    • このときround関数は01を返しますが、今回は偶数である0になります。

もうひとつ、1.12.0の場合も見てみます。
image.png

  • 1.5は最終桁が5ですが今度は実行結果が同じになりました。
    • round関数は12を返しますが、今回は偶数である2になります。
    • このようにround関数を使っても多くの場合で四捨五入時と同様の結果を返すため、この偶数丸めの仕様についてはプログラマーにとってなかなか気づきにくい部分かもしれません。

実験

いくつかテストケースを用意してみました。

test.py
from decimal import *
import unittest

# 四捨五入する関数(有効桁ごとに用意)
my_round_a = lambda n:Decimal(str(n)).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
my_round_b = lambda n:Decimal(str(n)).quantize(Decimal('0.1'), rounding=ROUND_HALF_UP)
my_round_c = lambda n:Decimal(str(n)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
my_round_d = lambda n:Decimal(str(n)).quantize(Decimal('0.001'), rounding=ROUND_HALF_UP)

# テストケースの作成
class testMyRound(unittest.TestCase):
    def setUp(self):
        pass

    def test_round_1(self):
        # 0.5
        self.assertEqual(round(0.5), 0)
        self.assertEqual(my_round_a(0.5), Decimal('1'))
        # 1.5
        self.assertEqual(round(1.5), 2)
        self.assertEqual(my_round_a(1.5), Decimal('2'))
        # 2.5
        self.assertEqual(round(2.5), 2)
        self.assertEqual(my_round_a(2.5), Decimal('3'))

    def test_round_2(self):
        # 1.5000を一の桁まで四捨五入
        self.assertEqual(round(1.5000), 2)
        self.assertEqual(round(1.5000, 0), 2.0)
        self.assertEqual(my_round_a(1.5000), Decimal('2'))
        # 1.4500を小数点第一位まで四捨五入
        self.assertEqual(round(1.4500, 1), 1.4)
        self.assertEqual(my_round_b(1.4500), Decimal('1.5'))
        # 1.5450を小数点第二位まで四捨五入
        self.assertEqual(round(1.5450, 2), 1.54)
        self.assertEqual(my_round_c(1.5450), Decimal('1.55'))
        # 1.5545を小数点第三位まで四捨五入
        self.assertEqual(round(1.5545, 3), 1.554)
        self.assertEqual(my_round_d(1.5545), Decimal('1.555'))

    def test_round_3(self):
        # 2.5000を一の桁まで四捨五入
        self.assertEqual(round(2.5000), 2)
        self.assertEqual(round(2.5000, 0), 2.0)
        self.assertEqual(my_round_a(2.5000), Decimal('3'))
        # 2.5500を小数点第一位まで四捨五入
        self.assertEqual(round(2.5500, 1), 2.5)
        self.assertEqual(my_round_b(2.5500), Decimal('2.6'))
        # 2.5550を小数点第二位まで四捨五入
        self.assertEqual(round(2.5550, 2), 2.56)
        self.assertEqual(my_round_c(2.5550), Decimal('2.56'))
        # 2.5555を小数点第三位まで四捨五入
        self.assertEqual(round(2.5555, 3), 2.555)
        self.assertEqual(my_round_d(2.5555), Decimal('2.556'))

    def tearDown(self):
        pass

if __name__ == "__main__":
    suite = unittest.TestSuite()
    suite.addTest(unittest.TestLoader().loadTestsFromTestCase(testMyRound))
    unittest.TextTestRunner(verbosity=2).run(suite)
実行結果
test_round_1 (__main__.testMyRound) ... ok
test_round_2 (__main__.testMyRound) ... ok
test_round_3 (__main__.testMyRound) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK

画像にまとめると次の通りです。
赤文字はround関数と実行結果が異なることを示していますが、いずれもDecimal.quantize()を使ったほうが正しく四捨五入できていることが確認できます。
image.png
image.png
image.png

テストケースtest_round_3ではround関数で正しく偶数丸めできていません。
本現象の原因は公式ドキュメントにも記載されており、floatが浮動小数点数を正確に表せないためです。

https://docs.python.org/ja/3/library/functions.html?highlight=round#round
浮動小数点数に対する round() の振る舞いは意外なものかもしれません: 例えば、 round(2.675, 2) は予想通りの 2.68 ではなく 2.67 を与えます。これはバグではありません: これはほとんどの小数が浮動小数点数で正確に表せないことの結果です。

参考記事

decimalモジュールを使わない方法

Python3の組み込み関数だけでも四捨五入することはできます。
(コメント欄にて情報いただきました。ありがとうございます。)

# 書き方その1
"""
Args:
   x: 四捨五入対象の値
   n: 桁の指定(round関数と同様)
"""
round2 = lambda x,n=0:(1 if x >= 0 else -1) * int(abs(x) * 10**n + 0.5)/10**n

# 書き方その2
def round2(x,n=0):
   if x>=0:
      int(x * 10**n + 0.5)/10**n
   else
      int(x * 10**n - 0.5)/10**n

負の数の四捨五入はどうするのか?については一度絶対値について四捨五入してから符号を付けるそうです。
これはJIS Z 8401規格で定められています。

  1. 絶対値を取得する
  2. 10の累乗倍する
  3. 0.5を足す
  4. int関数で端数を切り捨てする
  5. 10の累乗倍で割る
  6. 適切な符号を付ける

(1 if x >= 0 else -1)部分はnumpy.sign関数と類似した処理になります。

1
2
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2