こんな人にオススメ
python3系で1.5を整数で四捨五入をするともちろん2が計算される。一方で、2.5を同じように整数で四捨五入すると3が計算されるかと思いきや2と計算される。
なんで3じゃないんだ?どうしてだよぉ!
ということで、今回はpyhon3系で四捨五入する際に一般的な四捨五入は異なる挙動を示すことに関して解説する。python2系の場合は以下のように一般的な四捨五入。
# Python 2.7.16 # python2系では今まで習ってきた四捨五入と同じ計算 print(round(1.5)) # 2.0 print(round(2.5)) # 3.0
しかし、python3系になると初めの疑問でも書いたように、2.5の計算結果が3ではなく2のまま。一体なぜなのだ。ちなみに「.5」ではない「.4」などは通常の四捨五入。
# Python 3.9.7 # python3系では1.5でも2.5でも四捨五入の値が2に print(round(1.5)) # 2 print(round(2.5)) # 2
キーワードは丸め。python環境は以下。
- Python 3.9.7
- numpy 1.21.2
作成したコード全文
python3系で四捨五入してみる
# .5がごとに並べた配列 tpl = (-3.0, -2.5, -2.0, -1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.)
まずは上のように0.5ずつ区切った配列を考える。これを以下の4種類の四捨五入で計算結果を見てみる。
- 標準搭載の
round
関数 numpy
のround
関数- 標準搭載の
round
関数で整数の桁指定 numpy
のround
関数で整数の桁指定
# python3系だと四捨五入の結果が偶数に集中する for val in tpl: r1 = round(val) # 標準の四捨五入 r2 = np.round(val) # numpyの四捨五入 r3 = round(val, 0) # 標準の四捨五入で0桁目の四捨五入を指定 r4 = np.round(val, 0) # numpyの四捨五入で0桁目の四捨五入を指定 print(f"{val:4}: {r1:2}, {r2:4}, {r3:4}, {r4:4}")
f-stringの:4
はprint
する際に4文字分、文字数を確保しておくということ。マイナス記号があったりなかったりする際に、文字の配置をキレイにしやすい。
この指定をしなくても問題ないけど、見づらくなるのでここで入れている。出力結果は以下。
# -3.0: -3, -3.0, -3.0, -3.0 # -2.5: -2, -2.0, -2.0, -2.0 # -2.0: -2, -2.0, -2.0, -2.0 # -1.5: -2, -2.0, -2.0, -2.0 # -1.0: -1, -1.0, -1.0, -1.0 # -0.5: 0, -0.0, -0.0, -0.0 # 0.0: 0, 0.0, 0.0, 0.0 # 0.5: 0, 0.0, 0.0, 0.0 # 1.0: 1, 1.0, 1.0, 1.0 # 1.5: 2, 2.0, 2.0, 2.0 # 2.0: 2, 2.0, 2.0, 2.0 # 2.5: 2, 2.0, 2.0, 2.0 # 3.0: 3, 3.0, 3.0, 3.0
やはり一般的な四捨五入とは異なる挙動を示している。しかも全ての四捨五入の計算方法で。どうやら-2
, 0
, 2
といった偶数の出力数が多いように感じる。
この偶数に向かうことが今回のキモとなる部分。偶数への丸め。
偶数への丸め(roung to even)
前章の出力で.5の値が偶数に向かっていることがわかった。これは俗にいう偶数への丸めというもので、Wikipediaでは以下のように書かれている。
なお、この偶数丸めは「銀行丸め」とも呼ばれているようだ。
偶数への丸め(round to even)[2][3]は、端数が0.5より小さいなら切り捨て、端数が0.5より大きいならは切り上げ、端数がちょうど0.5なら切り捨てと切り上げのうち結果が偶数となる方へ丸める(つまり偶数+0.5なら切り捨て、奇数+0.5なら切り上げとなる)。JIS Z 8401で規則Aとして定められていて、規則B(四捨五入)より「望ましい」とされている[1]。
端数0.5のデータが有限割合で存在する場合、四捨五入ではバイアスが発生するが、偶数への丸めではバイアスが無い。つまり、多数足し合わせても、丸め誤差が特定の側に偏って累積することがない。ただし、偶数+0.5は現れるが奇数+0.5は現れないデータのように分布に特殊な特徴がある場合は、バイアスが発生することがある。
要するに、ちょうど0.5の値が出た時は偶数側に四捨五入されるということ。だから1.5は2に行きつつ、2.5も2に行くことになる。また、3.5は4になるけど、4.5も4になる。
これを使うことで誤差が小さくなるようだ。なら小学校で教えてほしかった(今は習っているのか?学習指導要領とか見ていないからなんとも言えないが)。
負の数の四捨五入
では、負の数の四捨五入はどうなのか。多分だけど、小学校や中学校で負の数の四捨五入は習っていない。なんで習わないのかわからんが。
先程のWikipediaのページには以下のような記載がある。
一方、JIS Z 8401では、負数は絶対値として丸める(−1.5は−2へと丸められる)。
要するに、四捨五入したい値の絶対値をとって四捨五入し、その値に元の数値の符号を付加するということ。ややこしい。だから、「-2.5」の場合は以下のように計算される。
なお、ここでは四捨五入は偶数丸めではなく、一般的に考えられている四捨五入を使用する。
- -2.5: 元の数
- 2.5: 絶対値とった
- 3.0: 四捨五入
- -3.0: 符号を付加
ややこしい。
負の数も含めた一般的な四捨五入の関数を作成
ということで、負の数も含めて一般的な四捨五入を実行するための関数を作成。ただ四捨五入するだけではなく、ここでは好きな桁数での四捨五入もサポートした。手順は以下。
- 好きな桁数で四捨五入するために10桁数を計算
- 四捨五入したい値の絶対値を取る
- 絶対値を取った値×2をする
- ×2をした値に×10桁数
- ×2×10桁数に+1
- この値を2で切り捨てる
- 10桁数したので逆に10桁数
- 符号を付加
文章だけではイメージしにくいかもしれないので、実際に具体的な数値で上記の手順を見てみる。数直線の常に表示するとわかりやすい。
上記の手順を実際に実装した関数が以下。val
が四捨五入したい値でd
が指定する桁数。
# 桁数を指定して四捨五入する def rounding(val, d=0): # d=0は初期値 adjust = 10 ** d # 桁数指定時の調節用 abs_val = abs(val) val2 = abs_val * 2 order = val2 * adjust # 桁数指定時にはここで調節が入る val2_1 = order + 1 floor_val = val2_1 // 2 order2 = floor_val / adjust # 桁数指定の調節を戻す ans = np.sign(val) * order2 # 符号を付加 # # 1行でまとめると以下 # ans = np.sign(val) * ((abs(val) * 2 * adjust + 1) // 2 / adjust) return ans
結構ややこしい関数となっているが、最後の方に書いてあるように1行でまとめることも可能。その場合は見づらくなるので注意。
色んなパターンで自作四捨五入を試す
# 確認用の関数 def check(tpl, d, order=4): for val in tpl: ans = rounding(val, d=d) print(f"{val:{order}}: {ans:4}")
ということで、自作の四捨五入の関数を使って色んな配列を四捨五入してみる。確認用の関数は上のもので、シンプルに四捨五入して出力するだけ。
tpl
を四捨五入
print(tpl) # (-3.0, -2.5, -2.0, -1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0) check(tpl=tpl, d=0) # -3.0: -3.0 # -2.5: -3.0 # -2.0: -2.0 # -1.5: -2.0 # -1.0: -1.0 # -0.5: -1.0 # 0.0: 0.0 # 0.5: 1.0 # 1.0: 1.0 # 1.5: 2.0 # 2.0: 2.0 # 2.5: 3.0 # 3.0: 3.0
まずは既に定義したtpl
から。2.5が3.0になっているので、しっかりと一般的な四捨五入になっている。
.5がない値を四捨五入
tpl1 = (-2.1, -2.0, -1.9, -1.6, -1.5, -1.4, -0.6, -0.5, -0.4) check(tpl=tpl1, d=0) # -2.1: -2.0 # -2.0: -2.0 # -1.9: -2.0 # -1.6: -2.0 # -1.5: -2.0 # -1.4: -1.0 # -0.6: -1.0 # -0.5: -1.0 # -0.4: -0.0
もちろん.5ではない小数点でも同じように四捨五入できる。ちゃんと四捨五入できてる。こちらは負の数だったが、正の数も大丈夫。
tpl2 = (0, 0.4, 0.5, 0.6, 1.4, 1.5, 1.6, 1.9, 2.0, 2.1) check(tpl=tpl2, d=0) # 0: 0.0 # 0.4: 0.0 # 0.5: 1.0 # 0.6: 1.0 # 1.4: 1.0 # 1.5: 2.0 # 1.6: 2.0 # 1.9: 2.0 # 2.0: 2.0 # 2.1: 2.0
桁数を指定してみる
# 四捨五入の桁を1にしてみる tpl3 = (-1.999, -1.97, -1.46, -1.45, -1.44, 0, 1.44, 1.45, 1.46, 1.97, -1.999) check(tpl=tpl3, d=1, order=6) # -1.999: -2.0 # -1.97: -2.0 # -1.46: -1.5 # -1.45: -1.5 # -1.44: -1.4 # 0: 0.0 # 1.44: 1.4 # 1.45: 1.5 # 1.46: 1.5 # 1.97: 2.0 # -1.999: -2.0
作成したrounding
関数では四捨五入の桁数の指定ができるようになっている。ということで桁数指定してみる。ここでは1桁とした。
1桁の場合は小数第一位まで表示されるので、小数第二位が四捨五入されることになる。ちゃんと四捨五入されているから驚き。
桁数をいろいろ試してみる。
最後に特定の値で桁数を変えた時に四捨五入の値がどうなるのかを検証する。桁数はマイナスを指定すると整数値で四捨五入される。今回は-2から5までの数値で四捨五入を試す。
14.56789
val = 14.56789 for d in range(-2, 5): check(tpl=[val], d=d, order=0) # 14.56789: 0.0 # 14.56789: 10.0 # 14.56789: 15.0 # 14.56789: 14.6 # 14.56789: 14.57 # 14.56789: 14.568 # 14.56789: 14.5679
15.56789
val = 15.56789 for d in range(-2, 5): check(tpl=[val], d=d, order=0) # 15.56789: 0.0 # 15.56789: 20.0 # 15.56789: 16.0 # 15.56789: 15.6 # 15.56789: 15.57 # 15.56789: 15.568 # 15.56789: 15.5679
-14.56789
val = -14.56789 for d in range(-2, 5): check(tpl=[val], d=d, order=0) # -14.56789: -0.0 # -14.56789: -10.0 # -14.56789: -15.0 # -14.56789: -14.6 # -14.56789: -14.57 # -14.56789: -14.568 # -14.56789: -14.5679
-15.56789
val = -15.56789 for d in range(-2, 5): check(tpl=[val], d=d, order=0) # -15.56789: -0.0 # -15.56789: -20.0 # -15.56789: -16.0 # -15.56789: -15.6 # -15.56789: -15.57 # -15.56789: -15.568 # -15.56789: -15.5679
小さなトラップ
今回はpython3系の四捨五入が一般的に使われている四捨五入ではないことを確認しつつ、一般的な四捨五入の計算になるような関数を作成した。
偶数丸めの四捨五入に気づかなかった時は、研究で出した値がなぜ異なっているのかがわからなかった。見つけた時は疑問だらけだった。
今回の話はあまり知られていないというか四捨五入ってこんなもんだよね、と信じられているから周りにも広めてほしい。四捨五入にはトラップがあると。
関連記事
-
-
【python&~】数値にチルダ(~)をつけると値が+1されて負の数になる(ビット反転)
続きを見る
-
-
【python&csv読み込み】pythonを使ってcsvを読み込み
続きを見る
-
-
【python3&zip関数】python3系にてzipは一回使ったら消える
続きを見る
-
-
【inspect&引数名取得】defのパラメータ名を取り出す
続きを見る