float は近く書く約束だ — 和・比較・ID・分散・百分率
一言でいうと
floatは間違えるのではなく、決められた方式で近い値を書きます。問題は、その「近さ」がサービスのルールとぶつかる場所です。合計が順序によって変わり、==が偽になり、64ビットのIDがブラウザーで隣のIDに変わり、分散が負になり、パーセンテージの合計が99になります。このモジュールは、その場所を1つずつ数字で確認します。
なぜ必要なのか
精算バッチの合計が、再実行のたびに数ウォンずつ違うという報告が来ます。並列に分けて足す順序が毎回違ったからです。管理画面で注文をクリックすると、別の注文が開きます。サーバーはIDをJSONの数値として送り、ブラウザーはそれをdoubleとして読みました。応答時間ダッシュボードの標準偏差が、ときどきNaNになります。エポックミリ秒で二乗の平均を求めていて、負の分散の平方根を求めたからです。どれも例外を出しません。
どう動くのか
0.1は0.1ではありません: Pythonのチュートリアルは、ほとんどすべてのプラットフォームで、floatは53ビット精度のIEEE 754 binary64であると記し、0.1として保存される実際の値が0.1000000000000000055511151231257827021181583404541015625であることを、Decimal.from_float(0.1)で示しています。decimalのドキュメントにあるとおり、Decimal(float)は、二進の値を損失なく十進に移すので、これがfloatの実際の値を見るもっとも正直な窓です。float.hex()は、同じ値を仮数と指数で見せてくれます。
合計は順序に左右されます: 1回足すたびに結果を53ビットに丸めるので、大きな値の隣では、小さな値の下位の桁が切り捨てられます。そのため、同じ数列でも、先頭から足す、後ろから足す、ソートして足すと、異なる値になります。math.fsumは、複数の部分和を持ち歩き、失われた桁を追跡します。1つ注意すべきなのは、組み込みのsum()です。functionsのドキュメントによれば、3.12から、floatの合算が、より正確で交換法則に近いアルゴリズムに変わりました。そのため、このラボでは「先頭から足した合計」を、forループの+=で定義します。ほかの言語や3.11以前で動いていたコードを移してくると、数字が変わることがある、という意味でもあります。
比較は==の代わりにmath.iscloseです。デフォルトはrel_tol=1e-09、abs_tol=0.0で、ドキュメントは、0と比較するとき、isclose(x, 0)は、0でないxに対して常に偽になるので、適切なabs_tolを指定するよう記しています。残高が0であるべき検査でabs_tolを忘れると、1e-16の残高のために失敗します。
2^53を超える整数: MDNのNumber.MAX_SAFE_INTEGERは9007199254740991(2^53 − 1)で、MAX_SAFE_INTEGER + 1 === MAX_SAFE_INTEGER + 2が真になると記しています。RFC 8259の6節も、binary64を使う実装どうしでは、[−(2^53)+1, 2^53−1]の範囲の整数で値が正確に一致すると述べています。その範囲を超えると、このような合意はありません。スノーフレークのように2^60付近で大きくなる64ビットのIDをJSONの数値として送ると、受け取る側がdoubleとして読んだ瞬間に、近いIDが同じ値になります。解決策は、IDを文字列として送ることです。Python側のjson.loadsは、整数をintとして読むので問題なく、サーバーのテストでは表に出ません。
丸め: round()は、2つの候補が同じだけ近いとき、偶数の側に丸めます。ドキュメントは、round(2.675, 2)が2.68ではなく2.67になるのは、バグではなく、2.675をfloatで正確に書けないからだと説明しています。丸めモードをポリシーとして固定するには、文字列から直接Decimalを作り、quantize(..., rounding=ROUND_HALF_UP)を使います。行ごとに丸めて足した値と、合計を1回丸めた値が異なるのは、誤差ではなく契約の違いです。
分散: 教科書の式E[x²] − E[x]²は、2つの大きな数の差です。Algorithms for calculating varianceの項目は、標準偏差が平均に比べて小さいとき、この桁落ちが特に悪いと記し、4・7・13・16を10^9だけずらすと、この式が−170.67を出す例を挙げています(真の値は30)。同じ項目が紹介するWelford(1962)の方法は、平均と偏差の二乗和を一度に更新して、この桁落ちを避けます。
パーセンテージ: 各割合を別々に丸めると、合計が99や101になります。最大剰余方式(ハミルトン方式)は、割合の整数部分を先に配り、残りの枠を、小数部分が大きい順に1つずつ配ります。
現場での姿
- サーバーログの注文IDと、画面に表示されたIDが、末尾の2、3桁だけ違うなら、まずdoubleを疑います。2^60付近では、隣り合うdoubleの間隔が256なので、末尾の桁がその幅の中でつぶれます。
- 請求書の明細の合計と総額が数セント違うという問い合わせは、計算エラーよりも、「どこで丸めるか」を決めていない契約の問題であることが多いです。金額の整数の最小単位・丸めポリシー・配分は、「銀行現場の言葉」コースのfde-bank-moneyが、余った1ウォンをどの品目に割り当てるかは、「注文は100件なのに精算は98件だ」コースのdk-com-allocateが、深く扱います。
- モニタリング画面の標準偏差がときどき空になるのは、負の分散の平方根がNaNになった痕跡かもしれません。値を基準点の分だけ引いておくか、Welfordに変えれば消えます。
次のラボですること
いくつかの小数の実際の値を見て、3,010行の金額の流れを4通りの順序で足し、==とiscloseが異なる答えを出すペアを数えます。64ビットの注文IDがdoubleで何ペア衝突するかを数え、文字列で送る関数を作り、丸めポリシーをDecimalで固定し、Welfordで分散を求め、最大剰余で合計が100になるパーセンテージを作ったあと、すべてをダッシュボードのレスポンス1つにまとめます。