誰もが遭遇する浮動小数点の精度問題
どのプログラミング言語でも0.1 + 0.2と入力してみてください。0.30000000000000004が返ってきます。0.3ではありません。これはJavaScript、Python、Java、C++、Rustのバグではなく、コンピュータが10進小数を2進数で表現する方法の根本的な帰結です。表示される浮動小数点の精度誤差はミスではなく、近似値に対する2進演算の数学的に正しい結果です。
理由:10進数の0.1は2進数では循環小数(0.0001100110011...)です。ちょうど1/3が10進数で循環する(0.333...)のと同じです。64ビット浮動小数点数は53ビットの有効桁しか格納できないため、0.1は最も近い表現可能な値に丸められます:0.1000000000000000055511151231257827021181583404541015625。2つのこのような近似値を足すと、丸め誤差は相殺されず蓄積します。
これはIEEE 754浮動小数点を使うすべての言語に影響します(ハードウェア加速の数学演算を使うすべての言語です)。Python、JavaScript、C、Java、Go、Rust——すべて同じ結果を生みます。同じ64ビット倍精度フォーマットを使っているからです。デフォルトでこれを回避する言語は、任意精度演算を使うもの(HaskellのRational型やPythonのDecimalモジュール)だけで、速度と引き換えに正確さを得ています。
IEEE 754が数値を表現する方法
IEEE 754倍精度(64ビット)浮動小数点数は数値を次のように格納します:1ビットの符号、11ビットの指数、52ビットの仮数(暗黙の先頭1ビットを加えて53ビットの精度)。値は:(-1)^符号 × 2^(指数-1023) × 1.仮数。これにより約15-17桁の有効10進数字の精度と、±5×10^-324から±1.8×10^308の範囲が得られます。
53ビットの仮数は、2^53(9,007,199,254,740,992)までの整数を正確に表現できることを意味します。それを超えると、連続する表現可能な数値の間隔が1より大きくなります。JavaScriptでは:9007199254740992 + 1 === 9007199254740992がtrueと評価されます。その数値は文字通り表現できません。これがJavaScriptにBigIntがある理由であり、2^53を超えるデータベースIDがJSONで文字列として送信されなければならない理由です。
任意の2つの表現可能な浮動小数点数の間には、表現できない実数が無限に存在します。表現可能な数値間の間隔は変動します——ゼロ付近では連続する浮動小数点数は約5×10^-324離れています。1.0付近では約1.1×10^-16。1,000,000付近では約1.2×10^-10。これは相対精度がほぼ一定(約15桁)であることを意味しますが、絶対精度は数値が大きくなるにつれて低下します。
特殊値:+0と-0(はい、負のゼロが存在し、JavaScriptでは-0 === +0です)、InfinityとーInfinity(オーバーフローまたはゼロ除算の結果)、NaN(Not a Number——0/0、sqrt(-1)、無効な演算の結果)。NaNには奇妙な性質があります:NaN !== NaN——IEEE 754で自分自身と等しくない唯一の値です。Number.isNaN()またはisNaN()でチェックしてください。
// The classic floating point precision errors
0.1 + 0.2 // 0.30000000000000004
0.1 + 0.2 === 0.3 // false
1.0 - 0.9 // 0.09999999999999998
0.1 * 0.2 // 0.020000000000000004
// Large integer precision loss
9007199254740992 + 1 // 9007199254740992 (unchanged!)
9007199254740993 === 9007199254740992 // true (!)
// Special values
1 / 0 // Infinity
-1 / 0 // -Infinity
0 / 0 // NaN
NaN === NaN // false (!)
Number.isNaN(NaN) // true
// The epsilon approach for comparison
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON // true
// But EPSILON is too small for accumulated errors:
Math.abs(0.1 + 0.2 + 0.3 - 0.6) < Number.EPSILON // false!浮動小数点とお金(浮動小数点を使わないで)
金融計算に浮動小数点を使ってはいけません。¥100 + ¥200は¥300であるべきで、¥300.00000000000004ではありません。数千件の取引にわたって、これらの微小な誤差は実際の不一致に蓄積します。1日1,000万件の取引を処理する銀行で、各取引に±0.000000000000001の丸め誤差があると、1日あたり¥0.01ずれる可能性があります——これは監査の失敗と規制上の問題を引き起こします。
標準的な解決策:最小単位の整数で金額を格納します(円)。¥1,999は1999として格納します。すべての演算は正確な整数演算です。表示時にのみ小数に変換します。これがPayPay、LINE Pay、すべての本格的な決済システムの方法です。JavaScriptでは:const total = priceInYen * quantity; で十分です(日本円には小数点以下がないため)。
小数点以下の精度が必要な通貨(暗号通貨、外国為替)には、固定小数点ライブラリを使いましょう。JavaScriptには組み込みのdecimal型がありませんが、decimal.js、big.js、dinero.jsなどのライブラリが任意精度の10進演算を扱います。Pythonにはdecimalモジュール(from decimal import Decimal; Decimal("0.1") + Decimal("0.2") == Decimal("0.3")はTrue)があります。JavaにはBigDecimalがあります。正確な10進結果が重要な計算にはこれらを使いましょう。
実例:2012年、Knight Capitalは45分間で4億4,000万ドルを失いました。厳密には浮動小数点エラーではありませんが、金融システムにおける小さな計算エラーがいかに壊滅的に連鎖するかを示しています。教訓:金融コードには正確な演算、徹底的なテスト、フェイルセーフが必要です。percentage-calculatorは税率や割引を計算する際、内部的に整数演算を使ってこれらの問題を回避しています。
浮動小数点数の比較
浮動小数点の計算結果を===(または==)で比較してはいけません。代わりに、差が許容値より小さいかチェックします:Math.abs(a - b) < epsilon。しかしepsilonはいくつにすべきでしょうか?Number.EPSILON(2.2×10^-16)は1.0と次の表現可能な浮動小数点数の最小差です——1.0付近の数値の比較には適切ですが、大きな数値には小さすぎ、ゼロ付近の数値には大きすぎます。
より良いアプローチ:相対epsilon。差を数値の大きさと比較します:Math.abs(a - b) <= epsilon * Math.max(Math.abs(a), Math.abs(b))。これにより許容値が値のサイズに応じてスケールします。ほとんどのアプリケーションで、1e-9から1e-12の相対epsilonが適切です。累積計算(数千の値の合計)では1e-6が必要かもしれません。
ULP(最終桁の単位)アプローチ:2つの浮動小数点数が最大N ULP異なる場合に「等しい」とみなします。1 ULPは浮動小数点数とその最近傍の距離です。数学的に最も厳密な比較ですが、実装が難しいです。実際には、相対epsilon比較が99%のユースケースをカバーします。
ソートと重複排除では、浮動小数点の比較は特に厄介です。測定値のリストから「重複」値を除去する場合、一貫した同値関係(a≈bかつb≈cならa≈c)が必要です。単純なepsilon比較はこの推移性を保証しません。比較前に固定の有効桁数に丸めるか、整数表現を使うことを検討しましょう。
科学計算:精度が最も重要な場面
壊滅的桁落ち:ほぼ等しい2つの数値を引くと、有効桁のほとんどが失われます。a = 1.0000001、b = 1.0000000(どちらも15桁の精度で格納)の場合、a - b = 0.0000001は1桁の精度しかありません——残りの14桁はノイズです。これは二次方程式の計算、数値微分、大きな数値の小さな差を計算するあらゆるアルゴリズムで発生します。
二次方程式の修正:x = (-b ± sqrt(b²-4ac)) / 2aの代わりに、数値的に安定な形式を使います。標準公式で一つの根を計算し(桁落ちを避ける符号を選択)、次にx1 * x2 = c/aを使ってもう一つの根を得ます。これはb² >> 4acのときに発生する壊滅的桁落ちを回避します。scientific-calculatorは内部的にこの安定化された形式を使用しています。
加算の順序が重要です。1e-16を1.0に足すと1.0になります(小さな値が精度の閾値以下)。しかし1e-16を1兆回足すと0.0001になるはずです。素朴な加算はこれらの小さな寄与を完全に失います。Kahan加算(補償加算とも呼ばれる)は丸め誤差を追跡して補正し、浮動小数点の完全な精度まで正確な結果を与えます。異なる大きさの多くの値を合計するときに使いましょう。
区間演算:単一の浮動小数点数を計算する代わりに、真の結果を含む範囲[下限, 上限]を追跡します。各演算後、区間は丸めを考慮して広がります。最終区間が[2.99999, 3.00001]なら、真の答えは3.0 ± 0.00001だとわかります。これは厳密な誤差限界を与えます。単一浮動小数点計算では累積誤差を推測するしかありません。MPFI(C)やpyinterval(Python)などのライブラリがこれを実装しています。
言語固有の落とし穴
JavaScript:すべての数値は64ビット倍精度浮動小数点数です(BigIntが登場するまで整数型がありませんでした)。これは整数演算が2^53までしか正確でないことを意味します。JSON.parse()は大きな整数の精度を黙って失います:JSON.parse("9007199254740993")は9007199254740992を返します。大きなIDを返すAPI(Twitter、Discord)は文字列として送信する必要があります。2^53を超える正確な整数演算にはBigIntを使いましょう。
Python:floatは64ビット倍精度です。しかしPythonにはDecimal(任意精度10進数)とFraction(正確な有理数演算)もあります。金融コードには:from decimal import Decimal, ROUND_HALF_UP。科学コード:numpyはデフォルトで64ビット倍精度を使いますが、一部のプラットフォームでfloat128をサポートします。Pythonの//演算子はフロア除算を行い、負の数に対して切り捨てとは異なる動作をします。
C/C++:floatは32ビット(7桁精度)、doubleは64ビット(15桁)、long doubleはプラットフォームにより80ビットまたは128ビット。x87 FPUは内部的に80ビット拡張精度を使うため、同じコードでも中間値がレジスタに留まる(80ビット)かメモリに格納される(64ビット)かで異なる結果を与える可能性があります。再現可能な結果には-ffp-contract=offと-fno-fast-mathを使いましょう。
SQL:FLOATとDOUBLEはIEEE 754型で同じ精度問題があります。DECIMAL(p,s)とNUMERIC(p,s)は正確な固定小数点型です——金額にはこれらを使いましょう。PostgreSQLのNUMERICは小数点前最大131,072桁、小数点後16,383桁を格納できます。MySQLのDECIMALは合計最大65桁をサポートします。金融カラムには必ずDECIMALを使い、FLOATやDOUBLEは絶対に使わないでください。
浮動小数点バグを避ける実用ルール
ルール1:お金には整数を使う。円を格納する。ビットコインではなくsatoshiを格納する。表示時にのみ小数に変換する。これでビジネスアプリケーションの浮動小数点バグの90%が排除されます。
ルール2:浮動小数点数を===で比較しない。ドメインに適した許容値ベースの比較を使いましょう。UI座標なら0.01ピクセルの許容値で十分です。科学計算には相対epsilonを使います。金融計算にはそもそも浮動小数点を使うべきではありません(ルール1参照)。
ルール3:引き算に警戒する。近い値の2つの数値を引いている場合、壊滅的桁落ちのリスクがあります。引き算を避けるようにアルゴリズムを再定式化するか、その特定の計算により高い精度を使いましょう。二次方程式、数値微分、分散計算が典型例です。
ルール4:敵対的な入力でテストする。ゼロ付近の値、非常に大きな値、2のべき乗の値(正確に表現可能)vs そうでない値(0.1、0.3、0.7)。多くの小さな値の合計。ほぼ等しい値の差。コードがお金を扱うなら、¥1、¥10、¥9,999、¥999,999,999でテストしましょう。basic-calculatorツールは適切な内部表現を使ってこれらのエッジケースを正しく処理するよう設計されています。