一昨日、「getimagesize()関数とdisplay:inline-block;は便利。」の記事の中でphpのround()関数について簡単に書いたが、“丸める”ということがどういうことか気になったので勉強してみた。

キーワードは、ずばり“浮動小数点数”だ。

今日はいつになく、大真面目な話題である。

襟を正して読むように。

数学では“虚数”なるものが存在するが、我々の実世界で扱う数は“実数”である。

“実数”とは“虚数”以外の「全て」の数を指し、無限の値を取りうるものである。

言い換えると、連続性を有する“アナログ”な数なのである。

ところが、所謂コンピューターの世界では、全ての情報は0/1の2進数(= binary digitを略してbit)で量子化されて扱われる。(ちなみに、一般的には1バイト(byte)は2進数の8桁、即ち8ビット(bit)を意味する。)

数値に関しても当然量子化を行い、整数値(0/1)で表現する。これが“デジタル(digital)”な値である。

つまり、数値は連続性をもたない離散的な値として表現されることになるのである。

ここまではお分かりいただけただろうか。

ちょっと堅苦しい言葉で説明をしてきたが、要はコンピューターの世界では実数全体を正確に表すことが“不可能”であり、数値はあくまでも“近似値”として表現されるということである。

考えてみりゃ、当然のことだわな。

“無限”の数を“有限”のCPUで表現できるワケねえっつうの。

“アナログ”を“デジタル”に変換する過程では、必ず微細な部分を切り捨てる必要が出てくるワケだ。

おっと、言葉が乱れてしまった。失敬。

話を元に戻す。我々が通常扱う実数は10進数である。これを具体的にコンピューター世界で扱われる2進数で表現してみよう。

整数は全て0/1の2進数(bit)で表すことができるので問題はない。

例えば、100という数字は
1*26(=64) + 1*25(=32) + 0*24(=0) + 0*23(=0) + 1*22(=4) + 0*21(=0) + 0*20(=0)
となるので、2進数で表すと1100100となる。

同様に、1475は
1*210(=1024) + 0*29(=512) + 1*28(=256) + 1*27(=128) + 1*26(=64) + 0*25(=0) + 0*24(=0) + 0*23(=0) + 0*22(=0) + 1*21(=2) + 1*20(=1)
なので、10111000011となる。

マイナスの値は?
(-1)0 = 1
(-1)1 = -1
となり、0/1で表すことができるのでやはり問題ない。

では、小数はどうだろうか。

14.75は
1*23(=8) + 1*22(=4) + 1*21(=2) + 0*20(=0) + 1*2-1(=0.5) + 1*2-2(=0.25)
なので、1110.11と表せる。問題ない。

じゃあ、0.1は?
1*2-4(=0.0625) + 1*2-5(=0.03125) + 0*2-6(=0) + 0*2-7(=0) + 1*2-8(=0.00390625) + 1*2-9(=0.001953125)……
となってしまうため、2進数で表すと0.000110011……という循環小数になってしまい、無限に数値が続いてしまう。

つまり、「“無限”の数を“有限”のCPUでは表現できない」ことになる。

そこで「微細な部分を切り捨て」て、有限の値に“近似する”作業が必要になってくる。

この“近似”の方法の一つとして“浮動小数点数”というものがあるのだ。(やっと“浮動小数点数”が出てきた!)

“浮動小数点数”は、それぞれ固定長(つまりは“有限”)の仮数部と指数部により表現された数である。

“浮動小数点数”のフォーマットにはいくつか主要なものがあるが、最も広く採用されている標準規格のものとして“IEEE(アイトリプルスリー)方式”がある。

我々が普段用いる“パソコン”のCPUでは“IEEE方式”が実装されており、また多くのプログラム言語においても採用されている。

phpでの数値処理も“IEEE方式の浮動小数点数”で行われている。

“IEEE方式の浮動小数点数”では、
(-1)a*b*2c
という形式で数値を扱う。

aを符号部、bを仮数部、cを指数部とそれぞれ呼び、仮数部は必ず 1 ≦ b < 2 の値で表す。

符号部は0/1なので必ず1bitであり、仮数部と指数部はそれぞれ固定長の数である。

仮数部に23bit、指数部に8bitを割り当ててトータルで32bit(4byte)で表す方式が“単精度”、仮数部に52bit、指数部に11bitを割り当ててトータルで64bit(8byte)で表す方式が“倍精度”である。

当然、使用するbit数が大きい“倍精度”の方が表現できる桁数が大きくなる(つまり精度が上がる)が、その分計算処理の速度は遅くなる。

ここから先は“単精度”方式で話を進める。

000102030405060708091011……293031
ab+127を2進法に変換(8bits)c(23bits)

指数部を仮数部の左側におくのは、大小比較を整数と同じ電子回路で行なえるようにするためである。

同じ目的のために、指数を示す値は b の値に 127 という定数を加えたものにする。

また、仮数を表すcは必ず“1.……”という値を取るので、ビットを節約する目的で最初の1を省略した値を入れる。

例として先程の14.75を考えると2進数では1110.11なので、浮動小数点数は
(-1)0*1110.11*20
となる。

しかし、仮数部は1以上2未満の数字でなければならないので、桁を揃える必要がある。これを“正規化”という。

正規化を行うと、2進数で3桁シフトするので、
(-1)0*1.11011*23
となる。

指数部は3なので、これに127を加えて2進数に変換すると、10000010となる。

従って“単精度”方式で32bitのデータとして格納すると、
|0|10000010|1101 1000 0000 0000 0000 000|
となる。

ちなみに、1475は
(-1)0*1.0111000011*210
なので、
|0|10001001|0111 0000 1100 0000 0000 000|
となる。

では、同様に0.1を格納することを考えよう。

浮動小数点数は
(-1)0*0.0001100110011……*20
なので、
(-1)0*1.100110011……*2-4
となる。

指数部は-4なので、これに127を加えて2進数に変換すると、01111011となる。

従って“単精度”方式で32bitのデータとして格納すると、
|0|01111011|1001 1001 1001 1001 1001 100(1100…)|
となり、仮数部の()部分が23bitに収まりきらない。

そこで、その収まりきらない部分を“丸める”ことになる。(やっと“丸める”が出てきた!)

IEEE方式では丸め方が4通りあるが、デフォルトでは“最近偶数丸め”という方法で丸める。

“最近偶数丸め”は基本的には0捨1入だが、端数(上記の()部分)がちょうど1の場合に限り、丸めた後の数が“偶数になるように”切り上げもしくは切り捨てをする。

なぜこのようなことを行うのかというと、0捨1入した場合の値は、全体的に元の数より大きくなる傾向があるからだ。

例えば小数点以下2桁の2進数の中で、0捨1入して“1”になる数は0.10、0.11、1.00、1.01の4つがある。

この合計は11.1、つまり10進数では3.5であるが、0捨1入した後の合計は4。つまり 0.5 だけ大きくなる。

一方“最近偶数丸め”では、0.10は端数がちょうど1となり、これを「丸めた後の数が“偶数になるように”切り上げもしくは切り捨てをする」と1ではなく0になる。

したがって“最近偶数丸め”で“1”になる2進数は0.11、1.00、1.01の3つだけとなる。

この3つの数について丸める前の合計は11.0、つまり10進数では3となり、“最近偶数丸め”で丸めた後の合計と同じになる。

以上のことから、単なる0捨1入と比較して“最近偶数丸め”の方が丸めによる変化が小さくなるのである。

話を10進数の0.1に戻そう。
|0|01111011|1001 1001 1001 1001 1001 100(1100…)|

この場合、端数がちょうど1ではないため、0捨1入を適用することになる。

つまり、
|0|01111011|1001 1001 1001 1001 1001 101|
となるのである。

つまり、浮動小数点数は
(-1)0*1.10011001100110011001101*2-4
となるため、
110011001100110011001101*2-27

従って、これを10進数に直すと、
110011001100110011001101 ⇒ 13421773
2-27 = 1/134217728
なので、
13421773 / 134217728 = 0.100000001490116119384765625
となる。

結果として、10進数で表された0.1という数字を一度2進数に変換してから10進数に戻すと、元の0.1より少しだけ大きな値となることが分かる。これを“丸め誤差”という。

よって、phpのround()関数も数値を“丸める”ための関数なので、“丸め誤差”が必ず含まれることになる。

そふぃのPHP入門
http://nyx.pu1.net/function/math/round.html

上記サイトのround()関数の“概略”部分に、

round()は、第1引数で指定された値を第2引数で指定された桁数で丸めます。ほぼ四捨五入と思ってもらっていいですが、内部的な2進数表現と16進数表現の差によって誤差が生じる場合もあります。さらに、PHPのバージョンによって丸め方が異なる場合もあるようです。

との一文が記されている。

“ほぼ四捨五入と思ってもらっていいですが”というのは、つまり内部的には2進数において“最近偶数丸め”で処理をしている、ということである。

従って、「場合によっては四捨五入とは異なる結果が得られる可能性がある」ということなのだ。

ああ、脳ミソから煙が噴いてきた、、、

たかだか数bitの脳ミソではこれが精一杯である。

おやすみ。