TT Lab
はじめる
学ぶ 学習パス コース

良いサービスを作る CS — 教科書の概念を計測で学び直す

float は近く書く約束だ — 和・比較・ID・分散・百分率

TT Labで続きを見る

一言でいうと

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つずつ配ります。

現場での姿

次のラボですること

いくつかの小数の実際の値を見て、3,010行の金額の流れを4通りの順序で足し、==とiscloseが異なる答えを出すペアを数えます。64ビットの注文IDがdoubleで何ペア衝突するかを数え、文字列で送る関数を作り、丸めポリシーをDecimalで固定し、Welfordで分散を求め、最大剰余で合計が100になるパーセンテージを作ったあと、すべてをダッシュボードのレスポンス1つにまとめます。