hnwの日記

RubyとPythonとC#のround関数のバグっぽい挙動について


(12/29 20:40追記)「(追記)なぜMySQLのdecimal型を例に使ったかについて」というセクションを追加しました。また、コメントを頂戴したので返信しました。



(12/29 21:30追記)C#について言えば「Math.Round メソッド (Double, Int32)」に内部実装がどうなっているか書いてあるので仕様通りであり、誤解しようが無いという情報を頂きました。ありがとうございます。そしてごめんなさい、確かにバグじゃないです!



(12/29 21:50追記Pythonのround関数のドキュメントにも誤差が入るかもしれないという記述があります。しかし、内部実装の紹介があった方がいつどういう誤差が入るかわかるので親切かなという気がします。また、浮動小数点数の性質上誤差が入るのは仕方が無いかのような記述に見えるのですが、浮動小数点数を使っていても誤差の入らない実装がありうるのではないか、というのが今回の記事で一番お伝えしたい内容です。



(12/29 22:00追記C#のMath.Roundメソッドは四捨五入でなく偶数丸めです。どちらでも同じ結果になる数を選んでいますが、念のため。



(12/31 16:00追記)本稿の補足の意味でサンプル実装を作って別記事「ぼくのかんがえたさいきょうのround関数」にまとめました。


RubyPythonC#のround関数について、小数点以下第n位までに丸める使い方は注意が必要、もしくはそれらのround関数にバグがあるんじゃないか、という話題です。


上記の言語のround関数は、小数点以下第何位までに丸めるかを引数で指定できます。丸め対象の数は浮動小数点数ですから、1.15などをピッタリ表現できないのは仕方ありません。とはいえ、例えば1.15(に一番近い、浮動小数点数で表現できる数)を小数点以下第1位までに丸めたら1.2(に一番近い数)になってほしいところです。実際、大抵の場合はそのような挙動になります。

$ ruby -e 'x=1.15; print x.round(1), "\n";'
1.2
$ python -c 'x=1.15; print round(x, 1),"\n";'
1.2


しかし、まれに期待と異なる丸め結果になることがあります。5.015を小数点以下第2位までに丸めてみましょう。

$ ruby -e 'x=5.015; print x.round(2), "\n";'
5.01
$ python -c 'x=5.015; print round(x, 2),"\n";'
5.01


5.02が期待する結果ですが、5.01に丸められてしまいました。ちなみにC#でも同じことが起こります。

using System;

class RoundTest
{
  static void Main()
  {
    double x = 5.015;
    System.Console.WriteLine(System.Math.Round(x, 2)); // 5.01
  }
}


僕はMono環境でしか確認していませんが、おそらく.Net環境でも同じ結果になると思います。

何が起こったか

原因は毎度おなじみ浮動小数点演算による誤差の蓄積です。5.015は浮動小数点数でピッタリ表現できず、わずかに小さい数として表現されます。


前述の3言語で小数点以下第n位までに丸める処理は、与えられた浮動小数点数を10^n倍してから四捨五入して10^nで割る実装だと考えられます。5.015の例で言えば100倍して四捨五入して100で割るわけです。


しかし、5.015に一番近い数を100倍した数もやはり浮動小数点数でピッタリ表現できません。ここでも一番近い浮動小数点数に丸められるわけですが、501.5(これは浮動小数点数でピッタリ表現できる数です)でなく、それより1イプシロン小さい数に丸められてしまいます。これが更に四捨五入されて501となり、100で割って計算全体として5.01になったと考えられます。


もっと良い実装があるか?

実は、MySQLではのdecimal型に浮動小数点数を代入したときにはこの問題が起きません。MySQLはdecimal型で小数を扱う際、四捨五入による丸めを行いますMySQLにも浮動小数点数型がありますから、今回の3言語と同じ問題が起きても不思議はありません。


しかし、実際に浮動小数点数の5.015をdecimal(3,2)型のカラムに格納してみると5.02として格納されます。

mysql> create table decimal_test(id integer auto_increment primary key, a decimal(3,2));
Query OK, 0 rows affected (0.07 sec)

mysql> insert into decimal_test(a) values(5.015E0);
Query OK, 1 row affected, 1 warning (0.02 sec)

mysql> select * from decimal_test;
+----+------+
| id | a    |
+----+------+
|  1 | 5.02 |
+----+------+
1 row in set (0.00 sec)

mysql>


これは、MySQLが10^n倍したり割ったりといった小数点の位置をずらす処理なしで丸めを行っているためです。この処理の実体はMySQLのstrings/dtoa.c中のmy_gcvt関数で、浮動小数点数を指定された桁数の文字列にします。これにより浮動小数点数の5.015が文字列の"5.015"として表現されるので、誤差なく処理できるというわけです。


この実装は、利用者への説明が少なくて済む点で既に説明した各言語の実装よりも優れていると思います。5.015が浮動小数点数でピッタリ表せないにしても、5.015を小数点以下第2位までに丸めて5.01になる挙動は内部実装(100倍して四捨五入して100で割る)を知らないと理解できません。


ここで改めて注意したいのは、5.015を浮動小数点数で表現したときに5.015より小さい数になることと、各言語の丸め結果が5.01になったこととはイコールではない点です。たとえば最初の例に挙げた1.15も実は1.1499999999999999112…と理想より小さい数として表現されますが、RubyPythonC#も1.2に丸めます。これは、1.15を浮動小数点数で表現したときの丸め誤差が、更に10倍した数を浮動小数点数で表現したときの丸め誤差と打ち消しあい、11.5という期待通りの浮動小数が得られるためです。

追記)なぜMySQLのdecimal型を例に使ったかについて

上記の結果だけでは特に不思議な結果に見えないかもしれません。では次の結果ではどうでしょうか。

mysql> insert into decimal_test(a) values(5.0149999999999996803E0);
Query OK, 1 row affected, 1 warning (0.00 sec)

mysql> insert into decimal_test(a) values(5.0149999999999987921E0);
Query OK, 1 row affected, 1 warning (0.00 sec)

mysql> select * from decimal_test;
+----+------+
| id | a    |
+----+------+
|  1 | 5.02 |
|  2 | 5.02 |
|  3 | 5.01 |
+----+------+
3 rows in set (0.00 sec)

mysql>


id=2とid=3が新たに登録したレコードです。この処理では、5.015に一番近い浮動小数点数と、それより1イプシロン小さい数をdecimal(3,2)型のカラムに格納していますが、前者だけが5.02に丸められています。一度浮動小数点数になってしまったものを正確かつ最短の10進表記に直すのはそれほど自明な処理では無いのですが、この例では実現できていることを紹介したかったというわけです。


decimal型を使ったせいで誤解を増やしてしまったかもしれませんが、これはあくまで浮動小数点数の話題になります。他にこの処理が使われる場所を見つけられなかったので、こんな例になってしまいました。

PHPのround関数について

実はPHPのround関数ではこの問題は起きません。

$ php -r '$x=5.015; var_dump(round($x, 2));'
float(5.02)


しかし、これはPHPのround関数が素晴らしいというわけでなく、アバウトな処理をしているのが良い方向に倒れただけです。逆に、本来なら切り捨てるべき数を切り上げてしまう状況も考えられます。


最近のPHPのround関数の詳細は「PHP5.3.0alpha3のround関数の実装がPHP5.2.6と変わった」を参照してください。

まとめ

  • RubyPythonC#のround関数で浮動小数点数を小数点以下n位までに丸めると不正確な結果になることがあります
    • 正確さが必要な場合は注意してください
    • 自前実装するならMySQLの実装が参考になるかもしれません
  • PHPのround関数で小数点以下第n位までに丸める場合、そもそも挙動が難しいので注意しましょう


ちなみに、Ruby1.8系のround関数には丸め桁数を指定するオプションが無いので、今回指摘したような問題は起きません。