丸め処理の罠

クロスワープの大鷲です。

今回は小ネタです。

私が開発しているシステムの一つに、部内の経理システムがあります。
当社の MODD を利用しているお客様向けに、毎月のご利用料金を計算し、請求書を作成するツールです。

とあるバグの話

先日、その経理システムに関して、社内から「金額が 1 円異なる」という報告が寄せられました。
税込金額の計算などで、端数の丸め処理をすることは多々あります。
本来、四捨五入になるべきところが、切り捨てになっていたのでした。
単刀直入に言ってしまうとバグなのですが、もちろん、事前に気付いて、お客様には正しい金額で請求させて頂いています。
原因は、Math.Round メソッドの使い方を間違えていたことでした。

Math.Round を使って正しく四捨五入する方法

Math.Round メソッドには多数のオーバーロードがありますが、整数に丸めるならばこの 2 つになります。
Math.Round メソッド (Decimal) (System)
Math.Round メソッド (Decimal, MidpointRounding) (System)

今回のバグの原因は前者のメソッドを使っていたことで、四捨五入を意図するなら後者の形式を使わなければならなかったのです。

MidpointRounding

正しく四捨五入するために必要な MidpointRounding は以下のような列挙体です。
MidpointRounding 列挙体 (System)

  • AwayFromZero
  • ToEven

という 2 つの値があります。
これは例えば 2.5 のような、2 つの整数のちょうど中間の値を丸めるときに、ゼロから遠い方(AwayFromZero の 場合、3)に丸めるか、直近の偶数(ToEven の場合、2)に丸めるかを指定するものです。
四捨五入は 2.5 ならば 3 に丸めるので、AwayFromZero を指定しなければなりませんが、MidpointRounding を指定しない形式の場合は ToEven が既定の動作なのです。
「四捨五入」と言わずに「ゼロから遠い方」という言い方をするのは、負の数の場合の処理を明確にするためだと思います。

なお、中間値でない場合(2.4 とか 2.6 とか)は、近い方の整数に丸められますので、MidpointRounding に何を指定しても関係ありません。

再近接偶数丸め

では、なぜ既定の動作は ToEven なのでしょうか。
ToEven の動作は、Wikipedia の端数処理の記事に「最近接偶数丸め」として紹介されています。
なお、最近接偶数丸めには「偶数丸め」「最近接丸め」「銀行丸め」などの多数の通称があります。以下では単に「偶数丸め」と呼ぶことにします。
さて、Wikipedia の記事によると、数値の丸め方を規定した JIS Z 8401 において、「(偶数丸めは四捨五入より)望ましい」とされているとのことです。
が、JISC のサイトで規格を見てもそのようには書かれていませんでした。*1
まぁ、.NET Framework の仕様を策定するのに、JIS 規格に「望ましい」と書かれていたからそうした、ということはないと思いますが。

偶数丸めの方が良いわけ

なぜ偶数丸めの方が良いのでしょうか。

複数の数を丸めたものを合計するという処理を考えます。*2
ここでは、元になるデータが偏りなくランダムに分布していると仮定しましょう。
この場合、0.1~0.4 を丸めたときに発生する誤差(値を小さくする方に働く)と、0.6~0.9 を丸めたときに発生する誤差(値を大きくする方向に働く)は、仮定からおよそ同数程度存在するということになりますので、相殺し合って、合計値の誤差は小さくなります。

問題は 0.5 の場合です。
これはちょうど中間値なので、本来はどっちつかずな値のはずなのです。
四捨五入では、それを切り上げる方向に丸めます。
これによって増えてしまった分を相殺する相手がいないので、誤差が蓄積してしまうわけです。*3
偶数丸めの場合、0.5 も、整数部が偶数のもの(切り捨て)と奇数のもの(切り上げ)で相殺し合うので、誤差が少なくなるのです。

具体例で見てみましょう。
0.0 ~ 2.0 まで、0.1 刻みのデータを、丸めてから合計してみます。

元の値 偶数丸め 偶数丸めの誤差 四捨五入 四捨五入の誤差
0.0 0 0 0 0
0.1 0 -0.1 0 -0.1
0.2 0 -0.2 0 -0.2
0.3 0 -0.3 0 -0.3
0.4 0 -0.4 0 -0.4
0.5 0 -0.5 1 +0.5
0.6 1 +0.4 1 +0.4
0.7 1 +0.3 1 +0.3
0.8 1 +0.2 1 +0.2
0.9 1 +0.1 1 +0.1
1.0 1 0 1 0
1.1 1 -0.1 1 -0.1
1.2 1 -0.2 1 -0.2
1.3 1 -0.3 1 -0.3
1.4 1 -0.4 1 -0.4
1.5 2 +0.5 2 +0.5
1.6 2 +0.4 2 +0.4
1.7 2 +0.3 2 +0.3
1.8 2 +0.2 2 +0.2
1.9 2 +0.1 2 +0.1
2.0 2 0 2 0
21.0 21.0 0 22.0 1.0

最後の行は合計行です。
それぞれ、.5 を境に、.1 ~ .4 と .6 ~ .9 の誤差が相殺し合うのがわかるとおもいます。
偶数丸めの場合、さらに 0.5 の場合と 1.5 の場合の誤差も相殺し合っています。
四捨五入の場合は、両方とも切り上げになるため、誤差が消えずに増えてしまっていますね。

なお、こうした丸め処理は、いずれも元データが偏りなく分布しているという前提に立っています。
偶数丸めも、整数部が偶数のデータと奇数のデータは、大きな目で見ればだいたい同数程度存在することが前提です。
例えば、小数部が 0.1 ~ 0.4 のデータが、0.6 ~ 0.9 のデータに比べて非常に多いというような偏ったデータの場合は、それ専用の丸め処理をした方が良いでしょう。

まとめ

四捨五入を意図する場合は、MidpointRounding.AwayFromZero を忘れないようにしましょう。

*1:「規則 A(注:偶数丸めのこと)には、(中略)丸めによる誤差が最小になるという特別な利点がある」とは書かれています。

*2:「足してから丸めろよ」と思われるかもしれませんが、銀行の口座の残高などのように、終わりがないデータは、先に円単位に丸めてから足すしかありませんよね。

*3:しかも 0.5 は誤差値が最大なのです。