hnwの日記

Pythonのround関数の議論を読んでみた

さて、round関数の続きの記事を周囲からも催促されている気がするんですが、まだまとめ切れずにいて僕自身困っていたりします。PHPを擁護するような方向の話をしようかと考えているんですが、なかなか難しくて正直気が重いです*1PHPの中の人の意図も一回はわかったような気がしたんですが、またわからなくなりました。


そんなわけで、横道にそれるわけではありませんが、今回は少し気楽な話題でいきたいと思います。


PHPのroundの件と似た話題を調べていたら、偶然PythonのMLの議論を見つけました。以下のURLとその続きを読んでもらえればわかりますが、質問者は0.0225を小数点以下第3位までに丸めようとして、Python2.3.5だと0.023に、Python2.4.1だと0.022になることに気づきました。

適当に要約すると、「なんでPython2.4で挙動が変わったんだ?」という質問に対して「FAQ。浮動小数について勉強し直して来い」「いや浮動小数一般の話じゃないだろ、コンパイラが違うせいだ」「原因なんてどうでもいい話だ」「特に実装は変わっていない」といった議論があったりして、軽く荒れて終了しています。


さて、今回話題にしたいのは上記の挙動に関してです。まずはこの原因から探ってみましょう。念のためPythonのround関数のソースコードを貼り付けておきます。これはPython/bltinmodule.cに含まれています。下記は2.3.5と2.4.1で共通です。また、僕の手元にバイナリがある2.3.4と2.4系最新版の2.4.4でも同じでした。

static PyObject *
builtin_round(PyObject *self, PyObject *args)
{
        double x;
        double f;
        int ndigits = 0;
        int i;

        if (!PyArg_ParseTuple(args, "d|i:round", &x, &ndigits))
                        return NULL;
        f = 1.0;
        i = abs(ndigits);
        while  (--i >= 0)
                f = f*10.0;
        if (ndigits < 0)
                x /= f;
        else
                x *= f;
        if (x >= 0.0)
                x = floor(x + 0.5);
        else
                x = ceil(x - 0.5);
        if (ndigits < 0)
                x *= f;
        else
                x /= f;
        return PyFloat_FromDouble(x);
}

見て分かる通り、PHPとほぼ同じ実装と言って構わないかと思います。違いはPHP_ROUND_FUZZにあたる部分が0.5固定なことくらいです。


この挙動のブレの原因は前回記事「PHPのround関数の謎が少し解けた」で説明した通り、x86系CPUの浮動小数点数レジスタが80bitあるためだと考えられます。前回のPHPのときのブレと同様、質問者のPython2.4.1ではround関数の中のfloorが80bit精度で評価してしまうのに対し、2.3.4の方は64bit精度で評価しているのでしょう。


一方で僕が興味深く感じたことは、MLの回答者たちがこの挙動のブレを当然のものとして受け止めているということです。僕の考えでは、上のソースコードのxをvolatile指定するだけでほぼ全ての環境の挙動を揃えられると思うのですが、それはPythonとしては認められないようです。精度を落とすような実装はすべきじゃない、だから環境の差は絶対残るよ、というように僕には読み取れました。

#!/usr/bin/python

import math
def my_round(x,y=0):
    return float(math.floor(x*math.pow(10,y)+0.5)/math.pow(10,y));

print "%.19f" % my_round(0.0225,3);
print "%.19f" %    round(0.0225,3);
$ python -V
Python 2.3.4
$ ./my-round.py
0.0229999999999999996
0.0219999999999999987
$

上記の実行結果は僕の手元の環境で、MLの質問者と同じくround(0.0225,3)が0.022になる環境です(正確にはもう少しだけ小さい数ですけど)。この環境でPython標準のround関数と同一の処理をPythonで実装したところ、標準関数と異なる結果が得られました。これは一見奇妙に思えますけど、十分ありうる*2ことで、これがわからない奴は勉強しなおせということなんですね。勉強になりました。


さて、これまでの議論が正しいとしたら、たとえば今回例に挙げた0.0225をdoubleに丸めた数は、round関数で小数点以下第3位までに丸めた場合に0.022と0.023とどちらに転ぶかわからない数だ、ということになります。このような数について何も手を加えないというのは見識だと思う一方で、意図的にどちらかに傾けてしまう実装も十分あり得ると僕は思います。


僕が嘘を書いている可能性も十分あると思います*3ので、ご意見お待ちしております。

注:6/6 8:50頃に途中の1段落を削って最後から2番目の段落を追加しました。また、意味不明だったので9:05頃に最後から2番目の段落を大幅に修正しました。

追記1: 僕の書き方が少し悪い印象を与えているかもしれない、という指摘を受けました。
僕がPythonに対する嫌味なり皮肉なりを言っているように取った方がいらっしゃるとしたら、
それは誤解です。僕自身はこの議論について良い悪いかが分かるほど知識が無いと自分では
考えており、未消化の状態です。それまで僕が考えていたことを否定されたような部分もあり、
混乱している面もあります。ただ、浮動小数点数に関して理解度がかなり高い(ように見える)
人物が複数人居るコミュニティというのは強いなあ、と素直に感心しています。

追記2: 書き忘れましたが、Pythonは10進浮動小数モジュールを持っています。
細かい誤差がいやならこちらを使うべきだ、というMLの回答もありました。
通常の浮動小数点数は2進ベースであり、これを10進で言うところの
小数点以下n桁目で丸める場合には誤差があちこちで発生してしまいます。
10進浮動小数であれば、仕事内容によっては一切誤差が出ない可能性さえあります。

http://www.python.org/doc/current/tut/node13.html#SECTION0013800000000000000000

*1:今から荒れそうな気がするんだもん

*2:Python浮動小数点数は内部的にはdoubleそのもので、計算内容も計算順序も同一で同一CPU上で同一丸めモードで実行していますが、計算途中の精度だけが異なっているため

*3:僕はこの一連の記事で初めてPythonに触っていますし、MLで回答している人たちのプロジェクト内での立場も知りません