いものやま。

雑多な知識の寄せ集め

Pythonの四捨五入で四苦八苦した話。(その1)

Pythonの四捨五入は、実は普通の四捨五入になってない。

なので、普通の四捨五入をするにはちょっとコードを書く必要があったりする。 ただ、その過程でかなり苦労があったので、同じような苦労をする人がいないようにパッケージにまとめてPyPIに上げておいた。

使い方は簡単で、pip install saneroundでインストールしたあと、saneroundをインポートし(srとしておくと楽)、round()を呼び出すだけ:

import saneround as sr
sr.round(0.5)  # => 1.0

もちろん、負の数を指定してもOK:

sr.round(-0.5)  # => -1.0

第2引数に桁を指定すると、その桁になるように四捨五入する:

sr.round(1.255, 2)  # => 1.26
sr.round(1234.567, -2)  # => 1200.0

さて、結論としてはもうこのsaneroundを使ってねなんだけど、Pythonで四捨五入をしようとするとどういった苦労があるのかを以下では書いておきたい。

ビルトインのround()関数

Pythonだって一応はプログラミング言語の端くれなので、四捨五入をするround()関数くらいは標準で入ってる。 ということで、四捨五入しようと思ってこのround()関数に手を出してしまうと、大火傷する。

何が起こるのかは0.5を四捨五入してみれば一目瞭然で、0.5を四捨五入すれば当然1になるとみんな思うでしょ?

はい、

round(0.5)  # => 0

0ですね。

「あー、そういうことね、0.5未満じゃなくて0.5以下が下に丸められるのね、理解」

round(1.5)  # => 2

はぁ? 頭おかしいの???

・・・まぁ、Pythonが頭おかしいのはもう仕方ないとして、この仕様を知らずに使ってたりすると、大問題になる危険性がある。 そこだけは注意する必要がある。

なお、この仕様自体は「偶数丸め」と呼ばれるもので、境界にあたる数(0.5や1.5など)を四捨五入したときには、丸られた結果の最小桁が偶数になるように切り上げ/切り捨てするというもの。

たとえば、以下のような感じ:

round(0.5)  # => 0, 一の位が偶数
round(1.5)  # => 2, 一の位が偶数
round(1.25, 1)  # => 1.2, 小数第一位が偶数
round(1.35, 1)  # => 1.4, 小数第一位が偶数
round(25, -1)  # => 20, 十の位が偶数
round(35, -1)  # => 40, 十の位が偶数

なんでこんな変な仕様を標準にするかなぁと思うんだけど、まぁ仕方ない。 だってPythonだし。。。

「普通の四捨五入」の自作

ということで、普通の四捨五入をしようと思ったら、普通は自分でコードを書くことになる。 四捨五入はfloor()関数使えば実装できることはよく知られてるし。

たとえば、よくある単純な実装は以下のようなもの:

import math

def myround(number, ndigits=0):
    shift_amount = 10 ** ndigits
    shifted = number * shift_amount
    return math.floor(shifted + 0.5) / shift_amount

やっているのことは、

  1. 四捨五入したい桁が小数第一位にくるようにする
  2. 0.5を足すことで、小数第一位が0.5以上なら一の位が1増え、それ以外なら増えないようにする
  3. math.floor()で小数部分を切り捨て
  4. 元の桁に戻して返す

これで実際に動かしてみると、以下のようになる:

myround(0.4)  # => 0.0
myround(0.5)  # => 1.0, round()だと0.0
myround(1.4)  # => 1.0
myround(1.5)  # => 2.0
myround(1.14, 1)  # => 1.1
myround(1.15, 1)  # => 1.2, round()だとなぜか1.1(バグっぽい)
myround(1.24, 1)  # => 1.2
myround(1.25, 1)  # => 1.3, round()だと1.2
myround(14, -1)  # => 10.0
myround(15, -1)  # => 20.0
myround(24, -1)  # => 20.0
myround(25, -1)  # => 30.0, round()だと20

ちゃんと四捨五入できてて、偶数丸めもされてないじゃん、めでたしめでたし。 とは残念ながらならなくて、負の数を指定するとおかしくなる:

myround(-0.4)  # => 0.0
myround(-0.5)  # => 0.0, 理想は-1.0
myround(-0.6)  # => -1.0
myround(-1.4)  # => -1.0
myround(-1.5)  # => -1.0, 理想は-2.0
myround(-1.6)  # => -2.0

これは絶対値で見ていないのが原因。 なので、次のように直してやるといい:

def myround2(number, ndigits=0):
    sign = 1 if number >= 0 else -1
    shift_amount = 10 ** ndigits
    abs_shifted = sign * number * shift_amount
    return sign * math.floor(abs_shifted + 0.5) / shift_amount

符号を確認して、絶対値に直した上で四捨五入し、元の符号に戻してやる感じ。

これで動かすとこうなる:

myround2(0.4)  # => 0.0
myround2(0.5)  # => 1.0
myround2(-0.4)  # => 0.0
myround2(-0.5)  # => -1.0

完璧。


・・・で終わってくれればよかったんだけど、そうはならないのが残念なところ。 長くなってきたので続きは次回で。

今日はここまで!