プチコン3号講座

実数型の誤差による誤動作を回避するための方法

 プチコン3号ではこちらに書いているように数値変数において整数型と実数型(一般的には「倍精度浮動小数点型」と呼ばれているもの)を使い分けることができます。しかし、実数型を使用する場合には注意すべき点があります。初心者には若干難しい部分もあるのですが、正しく計算されているはずなのになぜか正しく判定が行われないという場合にはここを読んでみてください。

 プチコン3号(Smile Basic)は昨今の一般的な開発ツールにあるデバッガ等はありません。しかし、プログラムをSTARTボタンで一旦停止してやればその時点での変数の値はDIRECTモードで簡単に知ることができます。例えば変数Aの現時点の値を知りたければDIRECTモードで?A(要するにPRINT A)とするだけで良いわけです。
 これは、プチコン3号でデバッグ(バグ取り)を行う場合にはよく使われる手段ですが、そこには落とし穴があるのです。例えばDIRECTモードで変数Tの値が10という値を示しているにも関わらず IF T==10 THEN 〜 が実行されない場合があるのです。


(1) プチコン3号はすべての数値は2進数で表現されている



 プチコン3号は内部では数値はすべて2進数で表現されています。
 そもそも、2進数とはどんなものかを初心者向けに簡単に書いておきます。

 10進数では012345678910、…のようになりますが、2進数というのは11011100、…のように2で桁が繰り上がるため01のみで表現されています。そのため10進数で10を2進数に変換すると1010になります。
 プチコン3号には10進数を2進数に変換する関数は用意されてないver.3.3.0BIN$が追加されました!)のですが、逆に2進数を10進数に変換することは簡単にできます。それは頭に"&B"を付けるだけです。A=&B1010とすると変数Aには1010を10進数に変換した10という値が入ります。B$に文字列"1010"が入っている場合はA=VAL("&B"+B$)で変数Aには10という値が入ります。

 実は10進数を2進数に変換するのは簡単にできます。それは2の累乗の和で表現すれば良いだけだからです。

 10進数で10という値は2の累乗の和で表現すると8+2となるわけですが、これは (1*2^3)+(0*2^2)+(1*2^1)+(0*2^0)と記述できます。("^"は累乗を示す記号とする)
 これを順番に見ていけば1010となり、10を2進数に変換したら1010になることが分かります。

 では、10進数で0.1を2進数に変換するとどうなるでしょうか?
 先ほどと同じく2の累乗の和で表現すると
  0.1=0/2+0/4+0/8+1/16+1/32+0/64+0/128+1/256+1/512+0/1024+0/2048+1/4096+1/8192+0/16384+0/32768+1/65536+1/131072… となります。1/22^-11/42^-2であり分母はすべて2の累乗となっている)
 つまり、0.1を2進数に変換すると0.0001100110011…という循環小数になるわけです。

 循環小数というのは例えば1÷7の答えを10進数で表記した場合に0.142857142857…のように同じ数字が循環して延々と続く状態の小数です。変数に入る桁数は有限(実数型の場合は2進数で52桁分)なのでプチコン3号で0.1という小数は正確には表現できません。
 ちなみにプチコン3号の実数型は符号部が1桁(1bit)、指数部が11桁(11bit)、仮数部が52桁(52bit)の合計64桁の2進数(64bit)で表現されています。0.1という値はプチコン内部ではIEEE754という一般的な浮動小数点の規格に準拠していると思われるため指数部のバイアス値を1023とすると)0011111110111001100110011001100110011001100110011001100110011010という64桁の2進数で表現されているわけです(最後の部分が循環していないのは単純な切り捨てではなく「最近接偶数丸め」という丸め処理が行われているため)。これを10進数に直すとプチコン3号上では0.10000000000000001となっています。
 仮数部の52bitが有効桁数になり実数型の有効桁数は10進数換算で約15.6桁となります。ただし、確実に有効桁数が15桁分確保されているというわけではなく演算を繰り返せば必然的にどんどん少なくなっていきます。(同じくらいの値の減算を行う場合には有効桁数は一気に減りこの状態を「桁落ち」と言い実数型では同じくらいの値の減算は可能な限り避ける方が良い)
 10進数を64桁の2進数に変換するには私の自作関数BIN64$を使うと簡単に分かります。BIN64$に関して詳しくは詳しくは
BIN64$のMiiverseへの投稿を参照)

 つまり、0.1という数値はプチコン3号の内部ではすでに丸められた値(簡単に言えば1÷30.3333…ではなく一定の桁数で計算を打ち切って0.3333として記録しているみたいな感じ)となっているわけです。これは0.1に限らず2の累乗分の1の和で表現できない小数値はプチコン3号ではすべて丸められた値になります1/2となる0.51/4となる0.251/8となる0.125などは正確に表現ができる)
 上記の例にある0.3333…と0.3333は等しくありません。これは循環小数を有限の桁数で表現しているので当然です。
 このように小数を含む10進数が有限の2進数に値が丸められることによって発生する誤差を「丸め誤差」と言います。


(2) 実数型の丸め誤差による誤動作



 丸め誤差によってどんな問題があるかというと次のプログラムを見てください。

《 丸め誤差によって発生する誤動作の例 》
T=0
WHILE 1
 T=T+0.1
 PRINT T
 IF T==10 THEN PRINT "10びょうたちました!":END
 WAIT 6
WEND

 これは見ての通り変数Tの値は6フレーム、つまり、0.1秒ごとに0.1ずつ加算してその値を表示しています。つまり、Tの値が時間を示していて「0.1秒ずつ画面上で増えていき10秒経つと終了する」というプログラムです。WAITについては、
「VSYNCとWAITでタイミングを取れ」を参照)

 一見すると問題がないこのプログラムですが、実は10秒経っても終了しません。それは、上記の通り0.1がプチコン3号内部では循環小数であり、正確な値ではないためそれを100回加算してもぴったり10になることはなくIF T==10 THEN 〜というIF文が成立しないためです
 10秒経った時には表示上で10になっているもののその時のTの値は正確には9.9999999999999805になっています。10は整数値であり9.9999999999999805とは異なる値です。したがって、これだけから判断するとこのIF文が実行されないのは当たり前と思うかも知れません。しかし、このプログラムを実行中に画面上でTの値が10を示している時に一時停止させてDIRECTモードで変数Tの値を確認しても10となるためであり、プチコン3号の内部でも(2進数を10進数に変換した場合)10になっているものの109.9999999999999805は異なる値であるため9.9999999999999805という値は確認できません。

 実はDIRECTモードでの表示させた場合や普通に実数型変数の値をPRINT命令で表示させた場合には小数第9位(整数部分が7桁以下の場合)で四捨五入された値が表示されているのです。9.9999999999999805という値を小数第9位で四捨五入すると10になりますよね。したがって、画面上には10という値が表示されているわけです。

 「小数第9位以下の値を確認したい」というのでしたらFORMAT$を使うと良いです。?FORMAT$("%0.16F",T)とすれば変数Tの値を小数第16位まで確認が可能です。この場合は小数第16位まで表示すればプチコン3号で計算された正確な値(10秒が表示されている時T==9.9999999999999805trueになる)
が確認可能でしたが、表示する数値によっては小数16位では足りない場合もあるし、多すぎる場合もあります。
 どんな場合でも変数に入っている正確な値を簡単に知るためには私が作ったPSTR$関数を使うと良いです。PSTR$は数値を誤差ゼロで文字列に変換する関数(文字列に変換する場合の誤差に関しては「数値にする?それと文字にする?」を参照)ですが、それ故に正確な値も確認可能になるわけです。PSTR$に関して詳しくは詳しくはPSTR$のMiiverseへの投稿を参照)

 ぴったり10にならないのならば条件をIF T>=10 THEN 〜に変更すれば良いと考えるかもしれません。確かにこうすることで無限ループになるのを避けることはできますが、この場合は10.1秒で終了してしまい10秒ちょうどで終了させることはできません。
 条件をIF T==9.9999999999999805 THEN 〜とすれば一応正常動作しますが、初期値や終了値を変えた場合や増分量を0.1から変えた場合はまた誤動作となるため本質的な改善策にはなりません。


(3) 実数型で小数を扱う場合は等号を使って判定をしない



 0.1のようなプチコン3号の内部では循環小数となってしまう値を使って計算する場合には誤差が出るのは避けられません。
 Tの値がちょうど10にならないのならば、一定の範囲内に収まっているかどうかで判定するという方法もできます。ここでは9.95<T<10.05になった場合に終了となるようにしてみます。IF T>9.95 AND T<10.05 THEN 〜(もしくはIF T>9.95 && T<10.05 THEN 〜)と記述することができます。

《 範囲指定によって誤動作を回避する例 》
T=0
WHILE 1
 T=T+0.1
 PRINT T
 IF T>9.95 && T<10.05 THEN PRINT "10びょうたちました!":END
 WAIT 6
WEND

 Tの値が0から開始して0.1ずつ増加した場合においてこの範囲内に収まっている表示上のTの値が10の時のみです。そのため10秒ちょうどでTHEN以下を実行してプログラムは正常に動作します。 (厳密に言えばWAIT 6は0.1秒ではなくほぼ0.1秒なので10秒ちょうどというわけではなくほぼ10秒となる)

 このように実数型で小数を伴う演算をする場合に等号"=="を使って判定すると誤動作をする場合があり、その場合はこのように一定の範囲内に収まっていればOKという書き方をすれば誤動作を回避可能です。(0.1以外の増減がある場合にはその増減量に合わせて適切な範囲を指定する必要がある)

 また、上記では9.9510.05の範囲を指定していますが、これは言い換えると「0.05未満の誤差を許容している」とも言えます。それならばその「一定の誤差」を明示的にすることで判定を行うことも可能です。
 例えば、±0.05未満の誤差を許容するのであれば基準値(ここでは10との差分を取りその絶対値が0.05未満になっていれば9.95<T<10.05というのと同じ結果が得られます。

《 誤差許容によって誤動作を回避する例 》
T=0
WHILE 1
 T=T+0.1
 PRINT T
 IF ABS(T-10)<0.05 THEN PRINT "10びょうたちました!":END
 WAIT 6
WEND

 どの程度の誤差を許容するかは実行する内容や制作者の判断によって変わるためどの程度の値にするかがベターかは一概には言えません。「DIRECTモードや普通にPRINTで表示した場合に判断できないくらいの小さな誤差のみ許容したい」というのであれば0.05の部分を5E-9にすると良いでしょう。


(4) 実数型の様々な誤差対策



 この範囲指定や誤差を許容するやり方で問題なく判定が行えるものの誤差はどんどん蓄積していきます。このプログラムでは10秒ではループ回数が100回だったので誤差はわずかでしたが、大量に繰り返していけばDIRECTモードでも確認できるレベルの誤差が発生してしまいます。
 そこで「出来るだけ誤差無くし確実な判定を行いたい」という場合の方法を書いておきます。

 最も簡単にできる対策としては小数ではなく整数演算のみを行うことです。整数演算のみを行えば普通に等号を使って判定しても問題が発生することはありません(正確には2の53乗以上の整数値の場合はプチコン3号の有効桁数(仮数部のビット数)の関係で「情報落ち」による演算誤差が発生するため等号で判定すると誤動作をしてしまう場合があるけどそのような値を使う機会は少ないのでそれほど気にしなくてもいい)

《 整数演算によって誤動作を回避する例 》
T=0
WHILE 1
 T=T+1
 PRINT T/10
 IF T==100 THEN PRINT "10びょうたちました!":END
 WAIT 6
WEND

 Tの値は0.1ずつ加算ではなく1ずつ加算して表示の段階で10で割ることで判定を簡略化が可能になっています。
 小数を加算する場合は誤差が出ない0.5ずつ、0.25ずつ、0.125ずつなどであれば整数演算と同じく等号で判定しても問題はありません。

 整数演算を行う場合にはこのサンプルプログラムのように最初から整数値を指定しておくのがベターです。値が0.1単位での計算だからといって値を10倍したら整数になるとは限らないからです。
 10倍した際に残ってしまうわずかな誤差はFLOOR()関数で切り捨てるのではなくROUND()関数で丸めるようにしてください。FLOOR()で切り捨てた場合は仮に2.499999999のような場合において10倍すると24.99999999となり切り捨てで24になってしまうけどこの場合は25となるべきだからです。2.5が内部で2.499999999になっているというのではなくあくまでこのようなケースが想定されるという例)

  《 FLOOR()とROUND()の動作の違い 》
  FLOOR() その数を超えない最大の整数を返す FLOOR(2.5)2FLOOR(-2.5)-3となる
  ROUND() 小数部を四捨五入して整数を返す ROUND(2.5)3ROUND(-2.5)-3となる

 例えば変数Aに0.1単位の小数値が入っている場合は、ROUND(A*10)とすることでAの値を10倍した整数値を得ることができます


 
上記のようにプチコン3号では普通に表示した場合は最大でも小数第8位までしか表示されません。FORMAT$や私の自作関数であるPSTR$を使わないと小数第9位以下は確認できないのですが、それならば小数第8位まで表示されているものと同じ値に丸めてやれば面倒臭いことは一気に解消できます。
 それを可能にするのが私の自作関数PRです。PR関数は表示丸め関数の略で詳しくはPR関数のMiiverseへの投稿を参照)

《 自作関数PRによって誤動作を回避する例 》
T=0
WHILE 1
 T=PR(T+0.1)
 PRINT T
 IF T==10 THEN PRINT "10びょうたちました!":END
 WAIT 6
WEND

《 PR関数と同等の処理によって誤動作を回避する例 》
T=0
WHILE 1
 T=ROUND((T+0.1)*1E8)/1E8
 PRINT T
 IF T==10 THEN PRINT "10びょうたちました!":END
 WAIT 6
WEND

 これは0.1加算するごとにDIRECTモードで表示される値に丸める処理を行っているわけです。10と表示されるような値ならば10になるように計算結果を丸めてしまえば見た目通り正しく判定されるという単純な理由です。
 条件判定の段階で丸めるのではなく計算をする度に誤差が5E-9未満になるように丸めているためループ回数が数100万回になろうと数億回になろうと5E-9以上の誤差は発生しないというメリットがあります。常に丸め処理を行っている関係で多少処理が遅くなりますが、プチコン3号であればほとんど気にならないレベルの速度低下で済みます。そして、すでに丸められた値であるため小数であっても等号で判定が可能になるというメリットもあります。(DIRECTモードで10と表示される値ならば10と等号を結んで判定が可能)

 本来であれば、倍精度浮動小数点である実数型の場合は小数第9位を四捨五入という固定ではなく扱う数値によって相対的に変えるべきところなのですが、PR()関数はDIRECTモードでの確認時の数値に合わせることでデバッグ作業を容易に行えるようにするというのが最大の目的であるためこのような仕様になっています。そのため、演算結果が絶対値の小さい数値(例えば4E-9とか)になる場合には0扱いになってしまうのですが、普通に使用する分には問題がないと思います。

 小数演算を行うのが1箇所だけならば「PR関数と同等の処理によって誤動作を回避する例」のようにROUND()関数で行えば良いですが、複数箇所で小数を含む演算をしている場合にはPR関数のような自作関数を作っておくと便利です。


(5) あとがき



 プチコン3号の実数型は整数型とは異なり扱える数値の範囲が広く小数も扱えるというメリットがあります。しかし、小数を扱う場合には誤差が出るのは避けることができません。
 しかし、小数が正確に表現されないことによって発生する誤動作も様々な対策によって回避が可能ということも分かってもらえたのではないかと思います。

 初心者の方はがこれらを理解するのは難しいかもしれません。しかし、「誤動作する場合もある」というのを頭に入れておけば問題ないのです。対策の方法を覚えたりする必要はなく必要に応じてこのページを見てもらえたら良いからです。

 この実数型変数の誤差によって発生するのはIF文における誤判定だけではなくFORNEXTのループ回数の誤動作が発生する場合もあります。詳しくは
FOR〜NEXTの終了条件を見てください。
 実数型を使いこなすには誤差によって発生する様々な誤動作をあらかじめ頭に入れておく必要があるわけです。


RETURN (プチコン3号講座のページにもどる) RETURN *MAIN (トップページにもどる)

inserted by FC2 system