「誤差を補正する四捨五入」が、3,000万円で1円多く切り上げていた ― 64本のツールを全通り確かめた話

JavaScript
B!

当社の計算ツール集「シルギア」には、2進数の小数の誤差を補正して四捨五入する、小さな関数があります。

function roundHalfUp(value, decimals) {
  var p = Math.pow(10, decimals || 0);
  var s = Math.abs(value) * p;
  s = Math.round(s + s * 1e-9);
  return (value < 0 ? -s : s) / p;
}

375 * 2.3 は JavaScript では 862.5 ではなく 862.4999999999999 になり、Math.round すると 1 小さくなります。それを防ぐために、丸める前に「値 × 1e-9」をほんの少し足す関数です。60本を超えるツールが、この関数で四捨五入していました。

この関数そのものに、金額が大きいと、0.5 に届いていない値まで切り上げる欠陥がありました。見つけたのは、ツールを1本ずつ「全通り」確かめていたときです。

発端:直したはずの場所を、全通り確かめた

CODE Polyglot は、同じ計算を10言語で書いて、1つの正解表で毎朝確かめているサイトです。ここ数日、シルギアのツールで見つけた丸めのずれを直してもらっていました(米の量・子どもの身長)。

その流れで、シルギア側がお金や量を扱う8本のツールの Math.round を、上の roundHalfUp に置き換えました。直したあと、8本の本番のコードを全通り確かめました。やり方は次のとおりです。

  • 本番の app.js を、ページの部品の最小の偽物の上でそのまま動かし、画面に出た文字を読む
  • 同じ式を、正確な分数(Python の fractions.Fraction)で計算した正解と比べる
  • 人が実際に入れそうな範囲を、入力欄の刻みより細かく全部試す(ツールあたり数万〜数百万通り)

直したあとのほうが、ずれが増えていた

年金の受給開始を選ぶツールと、暗号資産の損益を出すツールで、直す前よりずれが増えていました。

ツール直す前(Math.round)直したあと(roundHalfUp)
暗号資産の損益(594,740通り)3,208通り13,505通り
年金の受給開始(280,960通り)10,420通り5,674通り(うち新しく出たずれ 4,486か所)

年金のツールで出たずれは、たとえばこうです。

  • 累計 30,672,204.48円 → 画面は 30,672,205円(正しくは 30,672,204円)
  • 年額 1,812,959.499円 → 画面は 1,812,960円(正しくは 1,812,959円)

どちらも 0.5 に届いていないので、切り捨てが正解です。直す前の Math.round では正しく出ていました。

原因:補正の量が、値の大きさに比例していた

s + s * 1e-9 の補正は、値が大きいほど大きくなります。

値足される量(値 × 1e-9)
862.50.00000086
150,0000.00015
30,672,204.480.0307

3,000万円台では 0.03 円を足すので、端数が .47 を超えていれば .50 を超えて切り上がります。2進数の小数の誤差は、値の有効数字16〜17桁目にしか出ません。それを打ち消すつもりの補正が、大きな値では有効数字10桁目まで届いていました。

直し方:足すのではなく、桁を整えてから丸める

「値に何かを足す」のをやめ、有効数字12桁に整えてから丸める形にしました。

function roundHalfUp(value, decimals) {
  if (!isFinite(value)) return value;
  var p = Math.pow(10, decimals || 0);
  // 2進数の小数の誤差は有効数字16〜17桁目に出る。12桁に整えてから丸める
  var s = Number((Math.abs(value) * p).toPrecision(12));
  s = Math.round(s);
  return (value < 0 && s !== 0 ? -s : s) / p;  // 丸めて 0 なら「-0」にしない
}

862.4999999999999 は 12桁に整えると 862.500000000 になり、正しく 863 に切り上がります。30,672,204.48 は 30672204.4800 のままなので、正しく 30,672,204 に切り捨てます。補正の量が、値の大きさで変わりません。

15桁ではだめだった

最初は有効数字15桁で試しました。大きな金額はそれで直りましたが、年金の「65歳開始との月額の差」で、0.5 ちょうどの差(227.5円)を 227 円にしてしまいました。差は近い数どうしの引き算なので、引き算で桁が消えたぶん、誤差が上の桁まで上がってきます。15桁ではその誤差を拾ってしまい、12桁なら消えました。

どの桁数でも直らない場所もあった

年金の「累計の差」は、1,000万円台の累計どうしを引いて、12.5円のような小さな差を出します。ここは、有効数字を何桁にしても取りこぼしが出ました(280,960通り中114通り)。この部分だけは丸め方ではなく計算の順番を変え、画面に出した累計(整数)どうしを引くようにしました。画面の数字を足し引きした結果と、表示が必ずそろうという利点もあります。

結果

新しい関数を、関数を使っていた64本すべてに入れ、残っていた同じ型の箇所(toFixed・Math.ceil など)も直してから、同じ全通りの検証を回しました。

ツール試した入力最初の修正のあと(ずれた通り数)最終
タクシー料金68,798通り40
水分量41,073通り1,6950
モバイルバッテリー203,678通り充電時間 763・Wh 2,5380
年金の受給開始280,960通り5,6740
暗号資産の損益594,740通り13,5050(下の「12桁にも限界があった」も参照)
運動の消費カロリー2,248,620通り00
ランニングのペース2,443,504通り5740
単価比較229,500通り8910

単価比較で残った10通りは、数量 0.001 のような極端な入力で、割高率が10億%を超える場合です。有効数字12桁の限界で、実際の利用では起きない範囲として残しました。

12桁にも限界があった

これで終わり、ではありませんでした。数日後、この検証を作り直したときに、暗号資産の損益で試す入力を広げました(ビットコイン級の価格、3回に分けて買った場合など)。すると、619,011通り中597通りで、また本番のずれが出ました。

  • 平均取得単価が1,000万円台のとき、小数第2位が1大きい(58通り)。 1,000万円で 0.643 買い、手数料が500円なら、平均取得単価は 10,000,777.6049… 円で、正しくは 10,000,777.60円。画面は 10,000,777.61円でした
  • 全部売ると、売ったあとの残りの数量が「-0」と出る(538通り)。 0.1 と 0.7 を買って 0.8 を売ると、0.1 + 0.7 が 0.7999999999999999 になり、0.8 を引いた残りがわずかなマイナスになるためです

1つ目は、12桁に整える方式そのものが原因です。小数第2位まで出すために100倍すると、1,000,077,760.4976… と10桁を超えます。12桁に整える時点で 1,000,077,760.50 に一度丸まり、そこからもう一度四捨五入して .61 になりました。二重の丸めです。

丸めの関数だけでは、どちらかが外れる

丸めの関数の別の案を2つ用意し、検証の対象の10本すべて(約263万通り)で試しました。

案暗号資産(619,011通り)年金ほかの8本
有効数字12桁に整えてから丸める(本番)59700
0.5 との差が、値の 1e-14 以内なら繰り上げる60130
0.5 との差が、値の 1e-12 以内なら繰り上げる56800

許す幅を狭くすると、近い数どうしの引き算から出た値を外します。損益率 -0.455% は、2進数の小数で計算すると 45.4999999999995 になり、1e-14 では 0.5 とみなされず -0.45% になりました。逆に幅を広くすると、大きな値の小数(平均取得単価)で切り上げすぎます。丸めの関数で「どこまでの誤差を許すか」を1つに決めると、どの幅でもどこかで外れる、というのが結論です。

最後は、その計算だけを整数で

そこで共通の丸めの関数は12桁の方式のまま残し(ほかの9本は全通り一致)、暗号資産のツールだけ、金額・数量・税金を BigInt の分数で計算し、最後に1回だけ四捨五入する形に直しました。所得税も「利益 × 税率 × 1021 ÷ 100,000」を整数のまま計算します。

途中の版では、税金の概算に3通りのずれが残りました(利益 47,125,412円・税率23% の所得税は、正確には 11,066,460.49996円で正しくは 11,066,460円のところ、11,066,461円)。それも直した版で、619,011通りすべてが一致しました。

学んだこと

  • 「誤差を補正する」関数にも、使える範囲がある。 補正の量を値に比例させると、大きな値で補正しすぎる。桁を整える方式にも、桁数の上限がある
  • 丸めの関数で許す誤差の幅は、1つに決められない。 引き算の結果には広く、大きな値の小数には狭く、と場所によって必要な幅が違う
  • 直したら、直した場所を全通り確かめる。 代表的な数件の確認では、大きな金額の切り上げは見つからなかった
  • 確かめるのは「画面に出る文字」まで。 計算の関数だけを確かめた段階では、表示用の関数(グラムの表示)のずれが残っていた
  • 引き算で小さな値を出す場所は、丸め方では救えない。 計算の順番(丸めた値どうしを引く)で直す
  • 試す入力を広げると、また見つかる。 1回の全通り検証で安心せず、実際の利用に近い大きな値・複数行の入力まで広げる
  • 本当に確実なのは、2進数の小数を使わずに10進数や分数で計算すること。共通の丸めの関数は「実用の範囲(有効数字12桁まで)」用と割り切り、それを超える精度が要る計算は、その計算だけを整数でする。CODE Polyglot の「直した書き方」は、10言語ともそうしている

10言語で同じ丸めがどう割れるかは、CODE Polyglot の仕様ページと、直した不具合の一覧 にまとめています。

B!
← 一覧へ戻る