hnwの日記

PHPの新しいround関数を読み解く (2)pre-roundingの意味


(2011/10/18 01:50)「他の言語で試してみる」「誰が正しいのか?」を追記しました。また、初心者への対策と強調しすぎて誤解を招いた気がしたため、少し表現を変更しました。


前回記事「PHPのround関数を読み解く (1)丸め桁数が大きすぎ・小さすぎる場合」に引き続きPHPのround関数の処理を解説していきます。今回は、PHP5.3のround関数で最も特徴的なpre-rounding処理を追いかけていきましょう。

pre-roundingとは

pre-roundingとは、与えられた数の丸め処理を行う前に、与えられた数をいったん10^n倍して10^14以上10^15未満の数にして整数への丸めを行うことです。pre-rounding処理のあとで、改めて本来の丸め処理を行います。


ちなみに、pre-roundingという単語は一般的な単語ではありません。少なくとも僕はPHPのround関数でしか聞いたことが無い単語です。

round関数の処理概要

pre-roundingがPHPのround関数の処理でどのように利用されているか、もう少し詳しく見てみましょう。PHPのround処理を書き下すと次のようになります。

  • 最初に、丸め対象の数が10進数表記で何桁目から何桁目までが十分正しいか(精度を保っているか)を判定します。
    • 具体的には、丸め対象の数を10進表記した場合に、最初に0以外の数が出現する桁から15桁を有効な桁とみなします。例えば10.05について言えば、小数点以下-1桁目から13桁目までは正しいというわけです。
  • 四捨五入する桁と、四捨五入される桁(1繰り上がる桁)とが、上記の正しい桁の範囲にあるかを判定します。10.05を小数点以下1位に丸める場合であれば、小数点以下1桁目と2桁目は-1から13の間に含まれています。このような場合のみpre-roundingを行います。
  • pre-roundingを行なわない場合の丸め処理は次の通りです。
    • 丸める桁数nに対し、丸め対象の数を10^n倍します。例えば、小数点以下1位に丸めるのであれば10^1倍します。
    • 整数への丸めを行います。
    • 丸めた結果を元に戻すため、10^(-n)倍します。これが丸め結果になります。*1
  • pre-roundingを行う場合の丸め処理は次のようになります。
    • 丸め対象の数を10^m倍して、10^14以上10^15以下の数にします。10.05の例であれば、10^13倍して100500000000000.0とします。
    • 整数への丸めを行います(pre-rounding)。
    • 丸める桁数nに対し、丸め対象の数を10^(n-m)倍します。10.05を小数点以下1位に丸める場合であれば、100500000000000.0を10^(1-13)倍して100.5を得ます。
    • 整数への丸めを行います。
    • 丸めた結果を元に戻すため、10^(-n)倍します。これが丸め結果になります。


少し面白い点だと思うのですが、PHPのround関数の実装では、丸め桁数と丸め対象の数の大きさに応じて通る分岐が変わります。たとえば0.5を整数に丸める場合はpre-roundingを行わないのに対し、1.5や2.5を整数に丸めるときはpre-roundingを行うわけです。


pre-roundingを行わない場合の処理は、直感的に理解できるものです。つまり、浮動小数点以下n位へ丸める場合であれば、整数への丸めになるよう小数点の位置をずらしてから丸め処理を行っています。


一方で、pre-roundingの意図が一目でわかる人はまず居ないと思います。このあたりを更に掘り下げていきましょう。

pre-roundingの意図

pre-roundingの意図は、プログラミング初心者が不思議な丸め結果で混乱することを避けるためだと言えます。


特に、小数点以下n位への丸めを素直に実装した場合、多くの人の直感に反することがあります。というのも、浮動小数点数は内部的には2進数であり、0.05や0.005をピッタリ表せないため*2、四捨五入したはずの結果が一見不思議な結果になることがあるのです。pre-roundingを行うことで、僅かな誤差を導入する代わりに多くの人の直感に一致する結果を返すようになります。具体例は次のセクションで紹介します。


浮動小数点数でも0.5はピッタリ表せるので、整数への丸めであればそもそも混乱は少ないはずです。

RFC上の記述

pre-roundingに関連して、RFCの記述を引用します。

Multiplication with 10^places


Multiplication with 10^places is problemtic because of the fact that if the previous floating point representation was not exact, after multiplying with 10^places the resulting floating point number may not be the exact representation of the intended number.


Take, for example, the number 0.285. Its floating point representation is 0.284999999999999975575093458246556110680103302001953125. If you multiply that with 100, the resulting number has the floating point representation 28.499999999999996447286321199499070644378662109375. This is not the exact representation of 28.5 - which is actually 28.5 in this case.


The same happens for 1.255: The representation is 1.25499999999999989341858963598497211933135986328125. Multiply that by 100 and get 125.4999999999999857891452847979962825775146484375. The exact representation of 125.5 however is 125.5.


If 0.5 is now added to that number and that number is then rounded with floor, the result will be 28 and not 29 which would be the naive expected value.


PHP: rfc:rounding


引用部では、浮動小数点数を10^n倍すると期待通りにならないような2つの例を紹介しています。CPU上で0.285と1.255をそれぞれ100倍した場合、それぞれ28.5と125.5にはなりません。


どういうことかというと、1.255はCPU上でピッタリ表現できないため、一番近い数1.25499999999999989…として表現されます。





次にこれを100倍すると、この演算結果もピッタリ表現できないため、一番近い数である125.499999999999985…になってしまいます。本来なら125.5になるはずの演算が、2回誤差が蓄積することで別の数になってしまったわけです。





このことは1.255を小数点以下第2位に丸めるような場合に影響してきます。round関数を素直に実装した場合、1.255を10^2倍し、整数への丸めを行ってから10^(-2)倍することになるはずです。ところが、上記の問題があるため1.255を10^2倍した時点で125.5より僅かに小さい数になってしまい、丸めの最終結果が1.26ではなく1.25になってしまうのです。これは多くの人の直感に反する結果です。

Pre-rounding to the value's precision if possible


The previous measures only concern the problems with the division but not the problem with the multiplication. Here, another measure is taken:


If the requested number of places to round the number is smaller than the precision of the number, then the number will be first rounded to its own precision and then rounded to the requested number of places.


Example: Round 1.255 to 2 places precision, expected value is 1.26. First step: Calculate 10^places = 10^2 = 100. Second step: Calculate 14 - floor(log10(value)) = 14 - 0 = 14 which indicates the number of places after the decimal point which are guaranteed to be exact by IEEE 754. Now, 2 < 14, so the condition applies. So, calculate 10^14 and multiply the number by that: 1.255 * 1e14 = 125499999999999.984375… Now, round that number to integer, i.e. 125500000000000. Now, divide that number by 10^(14 - 2) = 10^12 (the difference) and get 125.5 (exact). NOW round that number to decimal which yields 126 and divide it by 10^2 = 100 which gives 1.26 which is the expected result for that rounding operation.


Of course, one may argue that pre-rounding is not necessary and that this is simply the problem with FP arithmetics. This is true on the one hand, but the introduction of the places parameter made it clear that round() is to operate as if the numbers were stored as decimals. We can't revert that and this seems to me to be the best solutions for FP numbers one can get.


PHP: rfc:rounding


この問題への対策としてpre-roundingの具体例が説明されています。


1.255を小数点以下第2位に丸める場合であれば、1.255をいったん10^14倍して125499999999999.984375…にし、これを丸めて125500000000000を得ます。

てから、これを更に10^12で割った125.5を四捨五入して126となり、これを10^2で割った1.26(に最も近い浮動小数点数)を返すのがPHPの丸め処理です。

他の言語で試してみる

実際、ここで指摘されている1.255の問題は他の言語でも起こります。Pythonで試してみましょう。

$ python -V
Python 2.6.1
$ python -c 'print round(1.255, 2);'
1.25
$


確かに、1.255を小数点以下2位に丸めた結果は1.26でなく1.25になってしまいます。手元に環境がなく実験していませんが、おそらく.NET環境でも同じ結果が得られると思います。

誰が正しいのか?

では、プログラミング言語で1.255を小数点以下2位に丸めた結果はどうあるべきなのでしょうか?


1.25以上1.26未満の数を小数点以下2位に丸めることを考えると、1.255以上なら1.26に、1.255未満なら1.25に丸めるのが理想的な挙動だと考えられます。この1.255は浮動小数点数でキッチリ表せませんが、代わりに1.255に最も近い浮動小数点数を基準にすれば1.255は1.255以上ですから、丸めた結果を1.26にできるはずです。


つまり、この問題は浮動小数点数の性質から不可避な問題などではなく、浮動小数点演算の順序および誤差の蓄積に関する問題だと捉えることもできます。さきほどはPHPの処理を「初心者への対策」と書きましたが、上級者だからといって必ずしも納得できる話題ではないのです。


もちろん何が正しいかは仕様次第ですし、真面目にやったとして現実的な解があるのかどうかもわかりませんが、この1.255の話題はとても面白い指摘だと僕は思います。

いったい何をしているのか?

ここまでの内容でPHPのpre-rounding処理の狙いが明らかになってきました。次に、この処理の意味について説明していきます。


浮動小数点数仮数部はhidden-bitを含めて53bitです。10^15<2^53<10^16より、10進数表記で先頭15桁は十分正しい数だと言えます。ただし、さきほどの1.255の例のように、誤差の蓄積により上位の桁に影響が出てしまうこともあります。


これまで見てきたように、pre-rounding処理では丸め対象の数を10^m倍して10^14から10^15の範囲の数にして整数への丸めを行います。これは、10進表記した16桁目を四捨五入しているのと同じです。pre-rounding処理とは、計算途中で10進15桁目が不正確になってしまう場合の補正を、16桁目の四捨五入で行うものだと言えます。実際、1.255の例であれば125499999999999.9…を四捨五入して本来の15桁目のゼロを取り戻しているわけです。


一方で、pre-rounding処理により、たまたま1.255より僅かに小さい数を扱っていた場合には1.255と同一視されてしまう可能性があります。このような事故を少しでも防ぐため、四捨五入に使う精度ギリギリの桁が16桁目なのだと思います。つまり、たとえばpre-roundingで10進の15桁目を四捨五入してもいいのですが、それでは巻き添えを食う数が増えてしまいます。一方で、先ほどの浮動小数点数の桁数の議論から10進17桁目はかなり不正確なので、ここで四捨五入すると今度は正しく補正ができなくなってしまいます。


四捨五入に使う16桁目の正確さについては、おそらく問題がないと考えています。というのも、log10(2^53)は約15.9であり、四捨五入に必要な程度の情報量は持っていると考えられるためです(この点は再度検討すべきかもしれません。もし問題がある例があっても大きな問題では無いと思いますが)。


また、pre-rounding処理後の演算の誤差は最終結果に影響を与えません。というのも、pre-roundingを行うための条件から、pre-rounding後の処理は10^14から10^15の間の整数を10^n(nは1以上14以下)で割ってから四捨五入する処理になります。整数への四捨五入処理では、端数が0.5以上か0.5未満かさえ正しければいいので、この観点で言えばこれらの計算は正確です。


まとめると、pre-rounding処理とは、丸め対象の数がコンピュータ上でピッタリ表現できない場合に10進小数としてキリがいい数に補正するような処理です。これにより、1.255のような場合に直感と同じ結果を得ることができます。


もちろん、pre-rounding処理により一部の数を丸めすぎてしまう可能性もあります。これが許容できない場合、自前でround関数を実装した方が良いかもしれません。まあ、素人が実装すると余計ひどいことになる可能性もありますから、プロ中のプロでなければ文句を言いながら標準関数を使うぐらいの方が平和だと思いますが、いかがでしょうか。

問題は無いのか?

この実装について、悪いところはないのでしょうか。


まず、1.255のような場合への対策が必要かどうかについての議論があるでしょう。これは僕も非常に疑問ではありますが、「小数点以下n位への丸めを行う引数を提供している時点で、小数が10進で格納されているかのような振る舞いをすべきだ」という考えは理解できます。整数以外の桁への丸めを導入したこと自体が関数設計レベルの失敗であり*3、今さらこの引数を撤回できない以上、この実装も必要だということで僕個人は納得しています。


1.255問題への対策として今回の実装が素晴らしいかどうかについてもやはり疑問で、もう少しマシな実装がいくらでもあるだろうと思います。まあ、挙動が文書化されて、こうして議論できるだけでもよしとしましょう。


次に細かい点になりますが、僕はこのアイデア自体に考え漏れもしくは実装ミスがあると考えています。「PHPの新しいround関数にバグをみつけた」で指摘した内容がそれです。丸め結果が10^15以上の場合に僕が理解できない分岐があり、妙な結果を生み出しています。レアケースであり、目くじらを立てるほどでは無いと思いますが、いずれ修正したい問題だと感じます。


また、pre-roundingを行うかどうかの分岐条件が現状でベストなのかどうかも疑問です。特に、整数への丸めであればpre-roundingを実施する意味は全く無いように思いますが、実際には処理が走ってしまうんですよね。このあたりにもバグか仕様か微妙なものが眠っていそうな気がします。


最後に一番重大だと思う点ですが、処理が複雑でPHPの中の人の多くが意味を理解していないような気がするのが懸念点です。そうでなければ「PHP5.3.4から5.3.6までのround関数がときどき丸めすぎてたのが直った」で指摘したような意味不明なコードが混入するわけがないからです。

まとめ

  • PHP5.3以降のround関数で採用されているpre-roundingについて紹介しました
    • 初心者の混乱を避ける目的は達成できています
    • レアケースですが新たな問題も見つかっています
    • 処理内容を理解している人が少なすぎるのが一番の問題かもしれません

*1:以前の記事で、ここで丸め桁数nが大きすぎたり小さすぎたりする場合の処理を紹介しました。

*2:1/3が有限桁の10進小数でピッタリ表現できないようなものです

*3:PHP以外の言語でも実装されていて事故の元になっている気がしますけど。過去の記事でC#での丸め関数の酷い例を紹介しました