Pythonの丸め処理について調べてみた

2020.06.15

データアナリティクス事業本部@札幌の佐藤です。

Pythonでの実装で四捨五入に関する実装をされたことはありますでしょうか。
今回は意外に面倒くさい四捨五入についての内容となります。

主に以下に関連する内容です。

組み込み関数 - round()
15. 浮動小数点演算、その問題と制限

四捨五入を考えたときにまず思いつくのがPythonに標準で存在するround()だと思います。
ただし、round() で実装するときには1点考える必要があります。
いわゆる銀行丸めというものになります。

round(12.5)

で小数点第1位を四捨五入したいと思って実装した時に、この実装ではうまくいきません。

>>> round(12.5)
12

想像では「13」になるべきですので、「12」は結果が異なりますね。
ちなみに「13」を四捨五入を想像通りの挙動になります。

>>> round(13.5)
14

なぜ銀行丸めになっているかというとPythonはIEEE-754を採用しているため、丸めへの考え型が通常の四捨五入とは異なっているということになります。

今日 (2000年11月) のマシンは、ほとんどすべて IEEE-754 浮動小数点演算を使用しており、ほとんどすべてのプラットフォームでは Python の浮動小数点を IEEE-754 における "倍精度(double precision)" に対応付けます。
15.1. 表現エラー

そのためPython固有ということではなく、IEEE-754準拠であれば同様の丸めの方針になると思います。

四捨五入したい場合

四捨五入を行いたい場合はdecimalモジュールを使用することになります。

>>> from decimal import Decimal, ROUND_HALF_UP
>>> Decimal(12.5).quantize(Decimal('0'), rounding=ROUND_HALF_UP)
Decimal('13')

これで解決できるのですが、decimalモジュールで四捨五入する際に気を付けることがあります。
それは四捨五入が想定通り行われないケースがあるということです。

Decimal(0.9295).quantize(Decimal('0.001'), rounding=ROUND_HALF_UP)

この場合、0.9295を小数点第4位で四捨五入しようとしているため、想定される結果は「0.93」になると思います。
ですが実際の結果は

>>> from decimal import Decimal, ROUND_HALF_UP
>>> Decimal(0.9295).quantize(Decimal('0.001'), rounding=ROUND_HALF_UP)
Decimal('0.929')

0.929になりました。
小数点第4位がうまく効いていないような結果になりました。

>>> Decimal(0.9296).quantize(Decimal('0.001'), rounding=ROUND_HALF_UP)
Decimal('0.930')

0.9296の場合はうまく四捨五入されていますね。

この違いは「15. 浮動小数点演算、その問題と制限」に書かれている近似の問題となります。

浮動小数点数は、計算機ハードウェアの中では、基数を 2 とする (2進法の) 分数として表現されています。例えば、小数
0.125
は、 1/10 + 2/100 + 5/1000 という値を持ちますが、これと同様に、2 進法の分数
0.001
は 0/2 + 0/4 + 1/8 という値になります。これら二つの分数は同じ値を持っていますが、ただ一つ、最初の分数は基数 10 で記述されており、二番目の分数は基数 2 で記述されていることが違います。
残念なことに、ほとんどの小数は 2 進法の分数として正確に表わすことができません。その結果、一般に、入力した 10 進の浮動小数点数は、 2 進法の浮動小数点数で近似された後、実際にマシンに記憶されます。

試しに0.001を例として、2進数に変換して10進数で表示した場合どうなるかを見ます。

>>> import decimal
>>> decimal.Decimal(0.001)
Decimal('0.001000000000000000020816681711721685132943093776702880859375')

この結果の通り、0.001と書いたものが、2進数→10進数になった場合に書いた内容と同じ0.001ではないケースがあります。

これはdecimalモジュールにも書いています。

value を float にする場合、二進浮動小数点数値が損失なく正確に等価な Decimal に変換されます。この変換はしばしば 53 桁以上の精度を要求します。例えば、 Decimal(float('1.1')) は Decimal('1.100000000000000088817841970012523233890533447265625') に変換されます。
decimal --- 十進固定及び浮動小数点数の算術演算 Decimal オブジェクト

先ほどの0.9295も、実際変換されると0.9295ではないということになります。

>>> import decimal
>>> decimal.Decimal(0.9295)
Decimal('0.92949999999999999289457264239899814128875732421875')

「0.92949…」だったために四捨五入されなかったということになります。

四捨五入する場合

decimalモジュールに記載がある通り、float型ではなくstr型にすることで回避できます。
ですので、実装時にはstr型にキャストしたほうが良いと思います。

>>> import decimal
>>> decimal.Decimal('0.9295')
Decimal('0.9295')
>>> from decimal import Decimal, ROUND_HALF_UP
>>> Decimal('0.9295').quantize(Decimal('0.001'), rounding=ROUND_HALF_UP)
Decimal('0.930')

丸めの規則

上まで四捨五入としてROUND_HALF_UPを使用していましたが、他にも丸めの規則がありますので、最後に紹介します。

decimal --- 十進固定及び浮動小数点数の算術演算 丸めモード

ROUND_CEILING

Infinity方向、正の無限大方向に丸めるということなので、正の方向に丸めます。
負の方向には丸まりません。

>>> Decimal('0.611').quantize(Decimal('0.01'), rounding=ROUND_CEILING)
Decimal('0.62')
>>> Decimal('-0.611').quantize(Decimal('0.01'), rounding=ROUND_CEILING)
Decimal('-0.61')

これは四捨五入ではないので、丸めたい小数点以下の値が0より大きいと丸められます。

>>> Decimal('0.610000001').quantize(Decimal('0.01'), rounding=ROUND_CEILING)
Decimal('0.62')

ROUND_FLOOR

-Infinity方向、負の無限大方向に丸めるということなので、ROUND_CEILINGの逆の丸めとなります。

>>> Decimal('0.611').quantize(Decimal('0.01'), rounding=ROUND_FLOOR)
Decimal('0.611')
>>> Decimal('-0.611').quantize(Decimal('0.01'), rounding=ROUND_FLOOR)
Decimal('-0.62')

ROUND_UP

ゼロから遠い方向に丸めます。ROUND_CEILINGROUND_FLOORを足したような挙動になります。

>>> Decimal('0.611').quantize(Decimal('0.01'), rounding=ROUND_UP)
Decimal('0.62')
>>> Decimal('-0.611').quantize(Decimal('0.01'), rounding=ROUND_UP)
Decimal('-0.62')

ROUND_DOWN

ゼロ方向に丸めます。丸めたい小数点以下が0より値が大きくても落とされます。

>>> Decimal('0.611').quantize(Decimal('0.01'), rounding=ROUND_DOWN)
Decimal('0.62')
>>> Decimal('-0.611').quantize(Decimal('0.01'), rounding=ROUND_DOWN)
Decimal('-0.62')

ROUND_HALF_UP

これは上で書いた四捨五入になります。

>>> Decimal('0.614').quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
Decimal('0.61')
>>> Decimal('0.615').quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
Decimal('0.62')
>>> Decimal('-0.614').quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
Decimal('-0.61')
>>> Decimal('-0.615').quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
Decimal('-0.62')

ROUND_HALF_DOWN

」近い方に、引き分けはゼロ方向に向けて丸める」と書いてありますが、端的にいうと五捨六入です。

>>> Decimal('0.615').quantize(Decimal('0.01'), rounding=ROUND_HALF_DOWN)
Decimal('0.61')
>>> Decimal('0.616').quantize(Decimal('0.01'), rounding=ROUND_HALF_DOWN)
Decimal('0.62')
>>> Decimal('-0.615').quantize(Decimal('0.01'), rounding=ROUND_HALF_DOWN)
Decimal('-0.61')
>>> Decimal('-0.616').quantize(Decimal('0.01'), rounding=ROUND_HALF_DOWN)
Decimal('-0.62')

ROUND_HALF_EVEN

「近い方に、引き分けは偶数整数方向に向けて丸める」とちょっとわかりにくく書いてますが、最初に説明した偶数方向への丸め(銀行丸め)と同じ挙動です。

>>> Decimal('0.625').quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN)
Decimal('0.62')
>>> Decimal('0.635').quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN)
Decimal('0.64')
>>> round(0.625, 2)
0.62

ROUND_05UP

「ゼロ方向に丸めた後の最後の桁が 0 または 5 ならばゼロから遠い方向に、そうでなければゼロ方向に丸めます。」と書いていますが、直感で分からない内容になっています。
丸めたい桁数が0か5であればROUND_UPと同じ挙動をします。0、5以外であればROUND_DOWNと同じ挙動になります。

>>> # 小数点第2位が0
>>> Decimal('0.601').quantize(Decimal('0.01'), rounding=ROUND_05UP)
Decimal('0.61')
>>> # 小数点第2位が1
>>> Decimal('0.611').quantize(Decimal('0.01'), rounding=ROUND_05UP)
Decimal('0.61')
>>> # 小数点第2位が2
>>> Decimal('0.621').quantize(Decimal('0.01'), rounding=ROUND_05UP)
Decimal('0.62')
>>> # 小数点第2位が3
>>> Decimal('0.631').quantize(Decimal('0.01'), rounding=ROUND_05UP)
Decimal('0.63')
>>> # 小数点第2位が4
>>> Decimal('0.641').quantize(Decimal('0.01'), rounding=ROUND_05UP)
Decimal('0.64')
>>> # 小数点第2位が5
>>> Decimal('0.651').quantize(Decimal('0.01'), rounding=ROUND_05UP)
Decimal('0.66')
>>> # 小数点第2位が6
>>> Decimal('0.661').quantize(Decimal('0.01'), rounding=ROUND_05UP)
Decimal('0.66')
>>> # 小数点第2位が7
>>> Decimal('0.671').quantize(Decimal('0.01'), rounding=ROUND_05UP)
Decimal('0.67')
>>> # 小数点第2位が8
>>> Decimal('0.681').quantize(Decimal('0.01'), rounding=ROUND_05UP)
Decimal('0.68')
>>> # 小数点第2位が9
>>> Decimal('0.691').quantize(Decimal('0.01'), rounding=ROUND_05UP)
Decimal('0.69')

負も同じです。

>>> # 小数点第2位が0
>>> Decimal('-0.601').quantize(Decimal('0.01'), rounding=ROUND_05UP)
Decimal('-0.61')
>>> # 小数点第2位が1
>>> Decimal('-0.611').quantize(Decimal('0.01'), rounding=ROUND_05UP)
Decimal('-0.61')
>>> # 小数点第2位が5
>>> Decimal('-0.651').quantize(Decimal('0.01'), rounding=ROUND_05UP)
Decimal('-0.66')