知っておくべきこと

計算は難しい

Pythonの統合環境での実行例です。これを見て何か不思議な感じがしませんか?

>>> d = 0.0000000000000000000123
>>> d == 0
False
>>> d
1.23e-20
>>> 1+d == 1
True

まず、とても小さな実数をdに代入しています。この値がゼロでないことはd == 0の結果がFalseであることからも明らかですね。 念のためdの値を見れば、1.23e-20と表示されます。1.23e-20というのは指数表現と呼ばれる実数の表現方法で、この場合、1.23に (10のマイナス20乗) を掛け合わせた数であるということを意味します。10のマイナス20乗ときいてもピンとこないかもしれませんが、これは (10の20乗)分の1 つまり1/100000000000000000000ということです。

さて、dがゼロでない小さな数であるということがわかったところで、問題は次です。
1+dという計算について、小学校の算数の知識で考えてみてください。1にゼロを足しても1のままです。これはわかりますね。
1にゼロでない数を足せば?もちろん、1ではなくなります。1より大きくなるか、あるいは小さくなるかのどちらかでしょう。
すでにわかっているように、dはゼロではありません。とても小さな正の実数で、その値は1.23e-20です。

ところがです。なんと我らがPythonは、意に反して1+d == 1の結果はTrueであると答えるのです。 1にとても小さな実数を足し合わせると1になるという、我々の習った算数や数学の常識とはかけ離れた結果が堂々と表示されてしまいました。
さて、これはどういうことでしょう?Pythonともあろうものが計算ミスをしたのでしょうか?あるいはPythonのバグ?

いいえ、バグでも何でもありません。これがコンピュータで実数(正確にはIEEE754に準拠する浮動小数点数・Floating Point Number)を扱う上での正常なふるまいなのです。 念のため、別の言語でも試してみましょう。まずはCommon Lisp (SBCL)です。

CL-USER> (setq d 1.23e-20)
1.23e-20
CL-USER> (= 0 d)
NIL
CL-USER> (= (+ 1 d) 1)
T

Pythonに問いかけたときと全く同じ結果となりました(Lispでは真はT※、偽はNILで表されます)。(※正確にはNILでない全ての値が真とみなされます)
では、次にHaskell (GHC-8.6.4のghci)での計算結果を見てみましょう。

Prelude> d = 1.23e-20
Prelude> d == 0
False
Prelude> d
1.23e-20
Prelude> 1+d == 1
True

やはり、1にゼロではないdを足して1になるという現象をHaskell(ghci)でも確認することができます。

知らなければ大怪我をする

なぜこのようなことが起こるかといえば、浮動小数点数の構造にあります。詳しく述べればものすごく長くなりますので、浮動小数点数の詳しい説明はWikipediaでも参照していただくとして、ここではものすごく単純化して(間違いを恐れずに)説明してみましょう。

コンピュータ言語で使われている浮動小数点数は、主に単精度浮動小数点数(32ビット)か倍精度浮動小数点数(64ビット)かの何れかです。C/C++では、それぞれfloat型・double型と呼ばれています。サイズが大きくなるほど精度が高くなるのですが、ここでいう精度とは、おおざっぱにいえば桁数のことだと思ってください。

ここで、現実の浮動小数点数よりも、ずっとずっと精度の低い浮動小数点数というものがあると想像してみましょう。 この想像上の浮動小数点数は、3桁の精度しかもっていないことにします。つまり、3桁の小数X.XXに、10の何乗という数を掛けた数しか表すことができません。それでも、1.23e-16(0.000000000000000123)や4.56e-32(0.0000000000000000000000000000000456)という非常に小さい数から、5.79e+32(579000000000000000000000000000000.0)といった巨大な数まで表現することができます。
ただし、表現できる精度は3桁ですので、123.4のような数を正確に表現することはできません。3桁の精度では、1.234e+21.23e+2と解釈されてしまうので、123.4は結局123.0になってしまいます。

なんとなく謎が解けてきた感じがしませんか?
そう、精度が3桁の浮動小数点数というものをデフォルトの浮動小数点数として採用するコンピュータ言語が仮にあったとすれば、123.4 == 123が成立してしまうというカラクリです。

では、再びPythonの統合開発環境で実験してみましょう。(注:環境によってはデフォルトの浮動小数点数の精度が異り実行結果が変わる可能性があります)

>>> 1234567890123456789.0 == 1234567890123456000.0
False
>>> 1234567890123456789.0 == 1234567890123456700.0
True
>>> 1234567890123456789.0 == 1234567890123456711.9876
True
>>> 1.000000000000001 == 1
False
>>> 1.0000000000000001 == 1
true

この結果から、有効桁数はおよそ16桁であることがわかります。

このように、浮動小数点数を使った計算では、常に有効桁数というものを意識しておく必要があります。

足し算と掛け算は違う?

再び算数の問題です。
X+X+X+X+X+X+X+X+X+Xを掛け算で表すとどうなるでしょう?
もちろん、X*10ですね。
XをN回足し合わせることと、XにNを掛けることは同じであると、小学校の算数の授業で習ったはずです。
本当かどうか、Pythonを使って検証してみましょう。

>>> d = 0.1
>>> d+d+d+d+d+d+d+d+d+d == d*10
False

おやおや、またもや算数で習ったこととまるで違うことを言っています。それどころか、

>>> d+d+d+d+d+d+d+d+d+d == 1
False

0.1を10回足し合わせたものが1ではないとまで言っています。

お察しのとおり、これもまた浮動小数点数の持つ特性なのです。 ここで、1/nをn回足し合わせて1になるのかという検証関数test_sumをPythonで定義して検証を行ってみます。

>>> def test_sum(n):
        d = 1/n
        result = 0
        for i in range(n):
            result += d
        return result == 1

>>> test_sum(10)
False
>>> test_sum(1000)
False
>>> test_sum(256)
True
>>> test_sum(1024)
True
>>> test_sum(1500)
False
>>> test_sum(65536)
True

足し合わせて正確に1になるものと、ならないものがあることがわかります。nが10,1000,1500という、なんとなくキリの良さそう数字のときには足し合わせても正確に1にならず、256,1024,65536といったキリの悪そうな数字のときには足し合わせて正確に1になっています。
念のため、Common LispとHaskellでも同じ実験をしてみましょう。

CL-USER> 
(defun test-sum (n)
  (let ((d (/ 1.0 n))
        (sum 0))
    (dotimes (_ n)
      (incf sum d))
    (= 1.0 sum)))

TEST-SUM
CL-USER> (test-sum 10)
NIL
CL-USER> (test-sum 1000)
NIL
CL-USER> (test-sum 256)
T
CL-USER> (test-sum 1024)
T
CL-USER> (test-sum 1500)
NIL
CL-USER> (test-sum 65536)
T
Prelude> testSum n = sum (take n $ repeat d) == 1 where d = 1 / fromIntegral n
Prelude> testSum 10
False
Prelude> testSum 1000
False
Prelude> testSum 256
True
Prelude> testSum 1024
True
Prelude> testSum 1500
False
Prelude> testSum 65536
True

LispでもHaskellでもまったく同じ結果となりましたが、更に念の為、scratchでも同じ実験をしてみました。

scratchのにゃんこ君も同じ答えを出してくれましたね。
これもまた浮動小数点数の構造からすると必然の結果です。

256,1024,65536という数は、どれも2のm乗のかたちで表される数です。256は2の8乗、1024は2の10乗、65536は2の16乗といった具合で、人間からするとキリの悪そうな数に見えても、2進数を基本とするコンピュータにとってはたいへんキリの良い数字というわけです。
このことから、浮動小数点数というのは、内部的にはある整数Xと2のM乗の(X,M)という2つの要素を持っていて、(間違いを恐れずに言いますと)それが表す実数とはXに2のM乗を掛け合わせたものであるということが類推できるのではないかと思います。
この表現方法でいえば、1/65536は(1,-16)ですし、1/256は(1,-8)です。こうするとキリの良い数であることがわかりますね。ところが、1/10ではそうはいかず、1/10の近似値にしかなりません。

このように、ある実数が浮動小数点数として扱われているのなら、その実数はそもそも近似値でしかないということを頭に入れておかなければなりません。そして足し算と掛け算が必ずしも同じではないこと、そして有効な桁数があることもしっかり頭に入れておく必要があります。
でなければ、どうやっても何度やっても計算結果が正しくないということになって右往左往する羽目になります。場合によっては、「だからコンピュータは信用できないんだ」というトンチンカンな結論に至る恐れすらあります。

場合によっては有理数を使おう

有理数…、また何やら難しそうな言葉が出てきました。大人の方なら、はるか昔に数学の授業で習ったような記憶があるのではないでしょうか?
有理数とは、おおざっぱに言えば2つの整数MとNによる分数M/Nのことです。このように分数の形で正確に表すことのできる数を数学の言葉で有理数といいます。逆に、どうやっても分数の形で表すことのできない数を無理数といいます。人類が発見した最古の無理数として、円周率πや2の平方根などを挙げることができます。

では、浮動小数点数では正確に表すことのできなかった0.1という実数は有理数でしょうか無理数でしょうか。もちろん1/10と表すことができるので有理数ですね。
プログラミング言語のなかには、分数表記で書かれた数を自動的に有理数とみなして計算してくれる言語があります。
例えばLispがそうです。

CL-USER> 1/10
1/10
CL-USER> (+ 1/10 3/2)
8/5
CL-USER> (* 2/3 7/9)
14/27
CL-USER> (* 1/10 10)
1
CL-USER> (+ 1/10 1/10 1/10 1/10 1/10 1/10 1/10 1/10 1/10 1/10)
1

分数同士の計算が正確に行われていることがわかります。

HaskellではData.Ratioモジュールをインポートすることで有理数を扱えるようになります。

Prelude> import Data.Ratio
Prelude Data.Ratio> 1%10
1 % 10
Prelude Data.Ratio> 1%10 + 3%2
8 % 5
Prelude Data.Ratio> (2%3) * (7%9)
14 % 27

Pythonの場合、上の2つに比べると若干泥臭い見た目にはなりますが、fractionsモジュールをインポートすることで有理数の計算ができるようになります。

>>> import fractions
>>> d = fractions.Fraction(1,10)
>>> d+d+d+d+d+d+d+d+d+d == d*10 == 1
True
>>> fractions.Fraction(1,10) + fractions.Fraction(3,2)
Fraction(8, 5)
>>> fractions.Fraction(2,3) * fractions.Fraction(7,9)
Fraction(14, 27)

Lisp,Haskell,Pythonで扱うことのできる有理数に関していえば、メモリの許すかぎりどんなに高い精度の実数でも表すことができます。有理数同士の基本的な演算に関する限り、計算誤差は絶対に生じません。そのかわり、多くのメモリを消費し、計算速度もかなり犠牲になります。
対して、浮動小数点数は数バイトのメモリしか消費せず、計算は極めて高速です。有理数の計算に比べればまさに爆速、異次元の速さです。そのかわり、計算誤差というものを常に頭に入れておかなければなりません。

知識はチカラ

浮動小数点数演算に関する知識は、プログラミングを行う上で必須といっていいものです。このあたりの技術は何十年も前から変わっておらず、誤差を最小にする計算技法というものも熱心に研究されてきました。たとえば、奥村晴彦先生の大ベストセラー 「C言語による最新アルゴリズム事典」 には浮動小数点数演算の様々な技法が満載されています。C/C++プログラマでなくても、この本は全プログラマ必携のバイブルといえるものです。プログラマとして成長したいのなら、ぜひ一冊買っておかれることを強くおすすめします。筆者は30年近く前にこの本の初版を買って以来、破れてボロボロになるまで座右の書として使い倒し、もう一冊同じ本を書い直したほどです。

さて、最後になりましたが、プログラミング教育なるものが注目を集めている昨今、計算に関するこのような基礎的な知識はあまり教えられない傾向にあるようです。
プログラミング言語というものを、たちどころに正しい計算結果を返してくれる魔法の言葉のように考えていると、いつか必ず痛い目に遭います。これは浮動小数点数演算に限ったことではなく、PythonやCなどで深くネストされた再帰関数の呼び出しによってプログラムをクラッシュさせてしまうとか、Haskellで計算量を考えずにサンクを溜め込んでプログラムを破綻させてしまったりするのと根源的には同じ問題で、プログラミング言語を学ぶのなら、裏方で実際にどうやってプログラムが動いているのか、また実行させる上でどのような制限が課せられているのかといったことをしっかりイメージできるようになる必要があります。

子どもにプログラミングを教えるのなら、なるべく早い時点でシビアな現実も見せておかなければならないのではないでしょうか。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください