実数演算における誤差

実数の内部表現

Pythonに限らず、一般にコンピュータでは二進法をベースとした浮動小数点数によって実数を扱っています。「コンピュータ概論」でほぼ最初の週に以下のスライドがありましたね。

image-20231219181847803

コンピュータの内部で浮動小数点数をどうやって表現しているか(内部表現がどのようなビット列になっているか)、その詳細はともかくとして、以下のスライドで話された「10進数の0.1は2進数では循環小数になる」話題を思い出して下さい。

image-20231219181816923

上のスライドは十進数の0.1が二進数では0.0001100110011… になることを求めるための手続きを示したものです。当時の思考経路を思い出せない人向けに(コンピュータ概論の中での説明とは異なるアプローチかもしれませんが)この後で少し説明します。

二進法と二進数
二進法とは底を2とする位取り記数法(2を基数とした数の表記法)のことです。二進法によって表記された数を二進数と呼びます。上のスライドの「10進数の0.1は2進数では0.00011001100…になる」とはそういう意味です。なおこの文書では簡単さのために「十進での5は」などと書くことがありますが、それは「十進法で表記した場合の 5 は」といった意味です。

小数の内部表現

以下に基数(10とか2とか)と「桁」との関係を示します。桁とは指数、その桁の値は係数となります。

image-20231221151054224

二進法では係数は1か0しかない、つまりその桁に値が存在したか、しないか、しかあり得ません。(一桁の係数に0〜9までの10種類使えるのが十進法、0と1の二種類しかないのが二進法です。)

つまり二進法での表現とは「どの桁の和か」を0,1の列で示したものだと考えることができます。以下にこの考えに基づいて十進数の 5 を二進表記の 101 に変換する手順を図示します。

image-20231221201359958

整数を例とすると直感的で分かりやすいのですが、今回取り上げる誤差の問題を理解するためには1より小さい数、つまり小数ではこれがどうなるか知らねばなりません。以下に同じ手順で十進数の 0.1 が二進表記の 0.000110011… に変換される様子を図示します。

image-20231221202117702

この計算はこの先も続き、循環小数となります。(循環する、ということについては先に出した「余談」と付けられたスライドの計算手順の方が分かりやすく出ていて良いかもしれません。)

つまり「十進法では僅かの桁数でキッチリ表現できた数」でも、コンピュータ内部で用いている二進法に基づいた限られた桁数までしか保持しない浮動小数点表現では、桁数が足りず近似値でしか表現できない状況が生じることが分かるでしょうか。

誤差

そうした近似値でしか表現できない 0.1 をコンピュータで繰り返し足すと、以下のような事、つまり「誤差」が生じます。

>>> 0.1 + 0.1
0.2
>>> 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1
0.9999999999999999
>>> 

0.1を10回足したのですから1になって欲しいのですが、0.9999999999999999になりました。

以下の例ではもっと多く、1000回繰り返してみました。

>>> sum = 0
>>> for i in range(1000):
...     sum += 0.1
... 
>>> print(sum)
99.9999999999986
>>> 

本来 sum は 100 になって欲しいところですが、99.9999999999986と、かなり誤差が出てしまいました。0.1 そのものが二進表現では正確に表現できないため、その不正確な値を積み重ねた結果こうなってしまったわけです。

それなら 0.1 と表示できているのは何故だ?
0.1が内部表現では無限小数だ、と言うのなら、そもそも 0.1 と表示されている方が「おかしい」ことになります。その通りPythonが0.1をそのまま「0.1」と表示しているのはある種の「嘘」です。以下のように「小数点以下75桁」まで表示するようにフォーマット指定して 0.1 を表示させると、18桁目からおかしな値が入り込んでいることが観測できます。
>>> print(“{:.75f}”.format( 0.1 ))
0.100000000000000005551115123125782702118158340454101562500000000000000000
つまりこの「キッチリでない」値をPythonは普段「見やすい値」である「0.1」に丸めて表示しているのですね。

誤差含みの処理における条件判定

このような状況ですから、例えば以下のようにして「sumが10になったら反応する」ようなコードを書いても正しく機能しません。(キッチリ「10」 ではなく、9.99999…. あたりの数ですからね)

>>> sum = 0
>>> for i in range(1000):
...     sum += 0.1
...     if sum == 10:                <<< sum が 10 になったか?で判定
...         print("10になった!")
... 
>>>                                  <<< 一度も「10になった!」とは表示されなかった
>>> print(sum)
99.9999999999986                     <<< 100近い値になってる、つまり 10 は通過したはずなのに
>>> 

これに反応させるためには、以下のように書く必要があります。

>>> e = 0.0000000001      <<< 微小な、しかし生じうる誤差より大きな値を用意する
>>> print(e)
1e-10                     <<< 今回は 10の -10 乗とした
>>> sum = 0
>>> for i in range(1000):
...     sum += 0.1
...     if (10 -e < sum) and (sum < 10 +e):   <<< 10より少し小さいか、少し大きい範囲にあるか?
...         print("10になった!")
... 
10になった!                      <<< 反応した
>>> 
条件判定の書き方
Pythonでは上の例のように論理積演算子(and)を使わず、if 10 -e < sum < 10: のようにして等価な条件判定を書けます。この a < b < c という条件記述の仕様をよく把握しないまま多用すべきでは無いのですが、今回ばかりはこの書き方が向いているように思えます。

ところで、「なるほど == を使わなければ良いのだな」と単純に考えるわけにはいきません。

たとえば以下のように書いても、やはり期待通りには動きません。つまり10回ループするかと思いきや、11回ループします。10回目のループでは「1.0」ではなく「ほぼ 1.0」である0.999… までしか到達せず、r < 0.1 の判定が True となってしまい、11回目のループに入るからです。この11回目の周回では、r は「ほぼ 1.1」で処理されています。

>>> r = 0
>>> while r < 1:                  <<< 1 より小さければ、で判定すると、、、
...     r += 0.1
...     print("{:.30}".format(r))
... 
0.100000000000000005551115123126
0.200000000000000011102230246252
0.300000000000000044408920985006
0.400000000000000022204460492503
0.5
0.599999999999999977795539507497
0.699999999999999955591079014994
0.799999999999999933386618522491
0.899999999999999911182158029987
0.999999999999999888977697537484
1.09999999999999986677323704498    <<< これは 11 行目の出力だ
>>> 

これも同様に微小な値を変数に用意して、このように書けば良いのです。

>>> e = 0.0000000001              <<< 微小な値を用意
>>> while r < (1 -e):             <<< 1 との比較ではなく、誤差を見越した範囲設定をすると
...     r += 0.1
...     print("{:.30}".format(r))
... 
0.100000000000000005551115123126
0.200000000000000011102230246252
0.300000000000000044408920985006
0.400000000000000022204460492503
0.5
0.599999999999999977795539507497
0.699999999999999955591079014994
0.799999999999999933386618522491
0.899999999999999911182158029987
0.999999999999999888977697537484   <<< 正しく 10 行目の出力が最後になった
>>> 
0.5 は近似値ではない
上の出力例で「あれ?0.5 では誤差が消えている」と違和感を持った人が居るかも知れません。少し乱暴な説明になりますが、これには 0.5 は 2 の -1 乗、つまり二進法で表現した時に近似値表現ではない「キッチリした数」になる事が関係しています。
以下のように、0.1は微小な誤差が含まれているのですが、0.5は全く誤差無く表現されている事が確認できます。
>>> print(“{:.70f}”.format( 0.1 ))
0.1000000000000000055511151231257827021181583404541015625000000000000000
>>> print(“{:.70f}”.format( 0.5 ))
0.5000000000000000000000000000000000000000000000000000000000000000000000

より詳しく

ここでは原理的なことと、シンプルに影響が出る事例に限って取り上げました。しかし誤差の話は奥が深く、丸め誤差、情報落ち、といった専門用語が多くあります。また誤差の少ない演算方法(演算処理の工夫・数値計算法)などもあり、CG (Computer Graphics) や物理シミュレーションなどの応用領域ではその影響を無視できません。そもそも浮動小数点表現でも「けち表現」といった細かな工夫があります。

そのあたりに興味のあるひとはPython公式ドキュメントの 15. 浮動小数点演算、その問題と制限 あたりから読み、登場する語句をたどって調べていくと良いでしょう。