Hatena::ブログ(Diary)

hnwの日記 このページをアンテナに追加 RSSフィード

[プロフィール]
 | 

2013年12月29日(日) 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関数には丸め桁数を指定するオプションが無いので、今回指摘したような問題は起きません。

匿名匿名 2013/12/29 13:57 C#では誤差が重要になる場合にはdecimalを使うべきですね。
decimal x = 5.015m;
Console.WriteLine(Math.Round(x, 2)); // 5.02

匿名匿名 2013/12/29 15:40 MySQLでも、doubleを使えば同じ問題が起きます。

create table double_test(id integer auto_increment primary key, a double);
insert into double_test(a) values(5.015E0);
select round(*,2) from double_test; -- 5.01

正確さが重要なときはdecimalを、
計算速度や記憶容量が重要なときはdouble(あるいはfloat)を
使うのがいいと思います。

他の言語でも同様でしょう。

匿名匿名 2013/12/29 18:22 select round(a,2) from double_test; -- 5.01

失礼

sagesage 2013/12/29 18:39 ちょっと前にちょうど同じ問題に直面しました。
Python の場合は Numpy の round を使うと解決しますよ。

>> import numpy as np
>> x = 1.15
>> print np.round(x)
1.2

hnwhnw 2013/12/29 20:36 匿名さん:ありがとうございます。確かにプログラマ目線で「C#では誤差が重要になる場合にはdecimalを使うべき」はその通りですね。とはいえ、doubleであっても無用な誤差が入るのはできるだけ防ぐべきだと思いますし、round関数の実装としてより良い実装があるのではないかという点を伝えたかったのですが、もっと言語実装者に向けたメッセージにした方が誤解が少なかったかもしれません。ライブラリ関数内の実装としてむやみに浮動小数点数演算を増やすべきでは無いはずであり、MySQLが実装しているC関数は使えそうだというのが僕の意見です。

MySQLのROUND関数は各言語と同じ問題があるよね、はその通りなので文中の文言を修正させてもらいます。

MySQLの例でdecimal型を使っていることについては誤解がある人が居そうなので追記させてもらいます。匿名さんが同じ誤解をされているかどうかはわかりませんでしたが、ご参考まで。

hnwhnw 2013/12/29 20:38 sageさん:
僕の手元のnumpyは期待通りに動いてくれないようです…。

>>> import numpy as np
>>> x=5.015
>>> print np.round(x,2)
5.01

匿名匿名 2013/12/29 21:54 id=2については、
insert into decimal_test(a) values(5.0149999999999996803);
と書くべきで、これなら期待通りの結果「5.01」になります。

select round(5.015,2),round(5.015E0,2);
と、
insert into decimal_test(a) values(5.015E0);
を比べると、MySQLの実装が「利用者への説明が少なくて済む」ものになっているとは思えません。

rezoorezoo 2013/12/29 22:06 Pythonでしたらこういう場合は普通Decimal型を使うのが一般的でないでしょうか:
>>> from decimal import *
>>> x = Decimal("5.015")
>>> x.quantize(Decimal('0.01'), rounding=ROUND_UP)
Decimal('5.02')

rezoorezoo 2013/12/29 22:09 あ,すみません.rounding=ROUND_UPは無視してください.

hnwhnw 2013/12/29 22:10 匿名さん:

たしかにMySQLの方が混乱の元ですね…。参考になるかもしれない実装例ということで紹介したかったのですが、ちょっと書き方を考えてみます。

id=2については、「5.015E0」が「5.015」と同じ解釈になると思われた方がいたかなと考えて、同じ内容をあのように書き直してみました。

hnwhnw 2013/12/29 22:13 rezooさん:

そうですね、誤差が大事なら最初から10進で扱えばいいじゃん、ってのは本当にその通りだと思います。

浮動小数点数でナイーブな実装をすると誤差が蓄積することがあるという点と、実は誤差が蓄積しない実装もありそう、いつ使うかはともかく面白いと思いません?というのが伝えたかったことなんです。

sagesage 2013/12/29 22:56 追記:Numpy の round(around)のページにある R9 の文献が参考になるかも。
- numpy.around : http://docs.scipy.org/doc/numpy/reference/generated/numpy.around.html#numpy.around
- “How Futile are Mindless Assessments of Roundoff in Floating-Point Computation?”, William Kahan, http://www.cs.berkeley.edu/~wkahan/Mindless.pdf

sea-showsea-show 2013/12/30 12:03 ちょっと調べたところ、SQLiteは切捨てられてました。

SQLite version 3.8.2 2013-12-06 14:53:30
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
sqlite> select round(5.015,2);
5.01
sqlite> create table decimal_test(id integer auto_increment primary key, a decimal(3,2));
sqlite> insert into decimal_test(a) values(5.015E0);
sqlite> select * from decimal_test;
|5.015


SQLite Frequently Asked Questions <http://www.sqlite.org/faq.html#q16>
(16) Why does ROUND(9.95,1) return 9.9 instead of 10.0? Shouldn't 9.95 round up?


ソースだとdoubleToInt64()とかroundFunc()あたりっぽいですね。

hnwhnw 2014/01/01 18:14 sea-showさん:
ありがとうございます。

ちょっとSQLiteの中身を追ってみたところ、丸め処理本体はsqlite3VXPrintf()の中にありました。5.015を小数点以下第2位に丸める場合だと0.005を足してから第3位以下を切り捨てることで四捨五入を実現しています。

ただ、この0.005を足す部分でも誤差が入りうると思いますし、10進表示処理を担うet_getdigit()も1桁ずつ処理を繰り返しており誤差が蓄積しそうな処理だと感じました。

 | 
ページビュー
2109077