Round()関数に⾒るHScriptの丸めについて


この記事ではHScriptのround()関数を少し詳しく掘り下げてみようと思います。

round()関数は以前の記事でも紹介した関数で、
「与えられた小数に対して、その小数に一番近い整数に丸め込んだ値を返す」関数となります。

例えばround(1.3)=1,  round(1.6)=2 といった具合です。
このようにみると、一見、round()関数は四捨五入を行うような関数にも見えるかと思いますが、実際のところどうなのでしょうか。

例えば引数に負の数を入れてみます。


上の結果は恐らく、正しいのでしょう。
しかし、下の結果、この結果は四捨五入として考えると、正しいでしょうか。
-1.5であれば、-2になるべきでは?と考える方もいらっしゃるかと思います。

これが「HoudiniのRound()関数は厳密には四捨五入ではない」の真実のひとつです。

そもそも、四捨五入というのは実はあいまいな概念で、例えば、マイナスになると、その解釈は様々です。
インディゾーンでも調べてみましたが、「四捨五入」という日本語には、正確な定義を見つけることはできませんでした。
正確に定義されていないものは、コンピュータの世界で一般に採用されることはまずないでしょう。

その代わりにコンピュータでは「似たような厳密な定義」として「丸め」というものがあります。
これは英語ではRoundingと呼ばれるもので、日本ではround()関数が丸め≒四捨五入の関数として認知されております。
(実際、四捨五入は英語に訳すとRoundとなり、四捨五入は丸めの中の一つの手法概念という位置づけになります。)

しかし、実はこの丸めも定義が結構バラバラとなっています。
基本的には丸めでは、指定した桁以下の数字を、指定した桁の数字の中で近いものに更新します。
しかし、その結果として、最後の桁数が5になり、両方の数字からの差が均等になる、いわゆる中間点の扱いが浮いてしまうのです。

現に、日本の場合、丸めの定義はJIS8401で定義されていますが、それによると、0.5などの中間点の扱いは以下のようになっています。

規則 A 丸めた数値として偶数倍のほうを選ぶ。
規則 B 丸めた数値として大きい整数倍のほうを選ぶ。

既に、この時点で定義が2つあります。

規則Bは一般に言う四捨五入に近いものになりますが、直観的に考えると、規則Aはおかしい、と思われるかもしれません。
しかし、コンピュータで計算を行う上ではこちらの方が都合がよいこともあります。コンピュータと人間では様々な違いがあるのです。
※規則Aには、丸めによって生じる統計的な誤差の偏り、累積を是正するような効果があり、統計分野などで広く使われます。

因みに、HoudiniではJIS8401における規則Bに則る形でHScriptのround()関数が実装されております。
しかし、これは別にJIS8401がHoudiniにおいて採用されている訳ではなく、この一致はただの結果論になります。

JISは日本国内の規格ですので、海外製品には関係のないものであり、海外には全く違った丸めの定義があります。
このあたりが、この後に記載されている各プログラミング言語による仕様の違いに話がつながっていきます。

しかし、この話は少しHoudiniから離れていってしまいますので、先にもう少しだけHScriptのround()関数を掘り下げてみましょう。

 

次は「誤差」の話になります。

まずはこちらを見てみましょう。

この数字は1.5未満ですので当然、この結果は「1」が返されております。

しかし、次はこちらの画像を見てみましょう。

この数字も同様に1.5未満のはずですが、この結果はなぜか「2」になってしまいます。

この2つの数字はいったい何が違うのでしょう。今度は縦に並べてみます。

ご覧の通り、下の方が1桁多いことがわかります。

理論上、この2行はいずれも1.5未満であることから、両方とも「1」が出力されないといけないのですが、なぜかそうはなりません。
これが「HoudiniのRound()関数は厳密には四捨五入ではない」の真実のもうひとつです。

実はこれは小数表現の誤差が原因で、浮動小数をコンピュータで扱うと、どうしても誤差が発生してしまうのです。

というのも、無限に値が小さく成りうる小数を、リソースが有限のコンピュータを使って完璧に表現するのは不可能なのです。
そこで、コンピュータにおいて小数を表現する手段として、固定小数点数と浮動小数点数の2種類のデータ形式が考え出されました。

固定小数点数とは、2進数における各桁が整数部であれば、左に桁が増えるにつれ2倍、4倍、8倍と定義しているのと同様に、
右に桁が増えるにつれて1/2、1/4、1/8倍と定義する手法です。

例:
     0b101.10 =5.5
     0b100.11 =4.75

各桁がそのままビット数に対応していることから、厳密に小数を表現できるのですが、表現できる数が少なく、あまり採用されません。

それに対して浮動小数点数とは、一般的に各ビットを符号部、指数部、仮数部の3部に分けて表現する手法です。
この手法は固定小数点数より複雑である為、詳細につきましてはIEEE745で検索をして頂ければと思うのですが、
端的に説明するならば、浮動小数点数は小数の類似計算手法になります。

つまり、厳密な小数ではなく、近似値が出てくるもの、生成/計算の過程で誤差が発生することを前提としたデータ形式なのです。

Houdiniにおいてもこちらの浮動小数点数を採用している為、このような誤差が発生してしまうのです。
この誤差の原因は0.01のような、2進数で表現すると無限級数になってしまう数字を、途中で打ち切ったり丸めたりしていることです。

試しに、round()関数を使わずに、直接パラメータに、
1.499999999999999 及び1.4999999999999999を入力してみます。

ここで、round()関数関係なく、そもそも入力した通りの値がパラメータに表示されていないことがわかります。

上の数字は右から2つ目(小数点以下14桁目)の9が8になっており、下の数字は1.5に丸められてしまっています。
下ではHoudini内部で自動的に丸められた数を、更にround()関数で丸めてしまったので、上記のような問題が発生しました。

これを2重丸めといい、意図しない丸めが発生しうる状況下では、round()関数による丸めを行ってしまうと、
このように意図せず誤差が膨れ上がってしまうことがあります。
Houdiniの「1」は1mなので、もともとの誤差は10fm(100兆分の1m)の差でしかなく、
普段Houdiniを使う分には、この誤差はほぼ問題にしない数値のはずです。

実際、上の数字はたまたま、数が小さくなる方に誤差が出たので、10fmの誤差は問題になりませんでした。
しかし、下の数字は誤差が大きくなる方に出てしまいましたので、全ての9が繰り上がり、このような状態となってしまっています。

金融分野や設計などの本当に厳密な分野で利用されるプログラミング言語においては、
一部の処理を固定小数で行うことで、絶対に誤差の出ない計算を行うことはありますが、HScriptやVEXにはその機能はありません。
Pythonには固定小数を扱うライブラリはありますが、Houdini側が浮動小数にしか対応していないので、大きな意味はないでしょう。

こちらは、round()関数に起因する問題ではなく、小数をコンピュータによって表現する際の、根本的な制限によるものでしたが、
以上のような理由によって、HScriptのround()関数は完璧に四捨五入ができるわけではない、という話がこの記事の答えです。

また、当然これはHScript、Houdiniには限らない問題である、ということもご紹介させていただきました。

ただ、一つ困ったことがあり、インディゾーンで確認している限り、Houdiniのパラメータ内部での浮動小数の誤差の発生の仕方が、
浮動小数点数の世界標準規格である、IEEE754のものと異なっていることが確認できました。

このことからHoudiniパラメータ上ではIEEE754が採用されていない可能性が高く、以下のような問題が発生する可能性があります。
Pythonでエクスプレッションを記述した際に、コンソールでの出力結果と実際にパラメータに入力される数値が異なってしまう。
浮動小数の誤差発生の予測ができず、同様に検証も非常に難しい。

ですので、精度の必要な小数計算を行う際には、このような可能性について留意する必要があります。

この対策についてはいくつか考えうるものはありますが、
基本的にどのプログラミング言語においても、小数を使う計算はなるべく避けた方が良いというのは指針として常になりますので、
定数倍乗算することで、整数にしてから計算を行い、計算後に先ほどの数で割って元に戻すという誤差発生回避策がよく使われます。

 

余談ですが、HScriptの関数にはround()だけに限らず、sin()やcos()など、
他のプログラミング言語と同様の形式で定義されている関数が多くあります。

これは一見まったく同じような挙動をとるようにも見えますが、細かく見てみると、小数の扱いや関数の記述による言語仕様の関係で、
HScriptと他のプログラミング言語で、微妙に出力結果が異なったりします。

例えば先ほどの、round()関数における中間点処理を例にとってみます。
同じround()関数でも、プログラミング言語によって小数部が0.5の時の扱いの違いには、以下のような様々なパターンがあります。
大きい数値に丸める(HScript、Java、JavaScriptなど)
一番近い偶数に丸める(R、MATLAB、Python3など)
0に遠い方に丸める(C99、Python2、Haskellなど)
扱いを明示的に設定する(PHP、Ruby、Swiftなど)
※他にも様々な手法があります。

これもHScriptだけに限らず、全てのプログラミング言語で発生しうる問題で、今回みたいなクリティカルな値を入れることで、
round()関数のように、同名の関数でも言語によってどのような違いがあるのかを知ることが、重要になってくる場面があります。

因みに、Houdiniにおいて言語ごとの仕様違いを一番身近に感じられるのは、三角関数の仕様でしょうか。
HScriptではsin()関数の引数の単位は度[°]ですが、VEXでは全く同じ記述形式の、sin()関数の引数の単位はラジアン[rad]です。
この仕様の違いを知らなければ、正しい値を出すことはできません。
特にHoudiniではアプリケーション内で複数の言語が様々な領域で入り乱れるので、
この「仕様違い」の概念と、その可能性を頭の片隅に入れておくことが、非常に重要になってきます。

このような言語ごとの仕様の違いは、先述した国ごとの規格の違いであったり、業界の取り決めであったり、
想定される用途に対して都合の良いものを採用していたり・・・と理由は様々です。

このような関数の局所的な戻値の仕様や言語ごとの仕様の違いに目を向けると、更に汎用性の高いネットワークの作成に貢献できます。