Perlで小数の四捨五入
2023/02/17
自分で処理関数を作らないと望まない結果になることも
  • 結論から書くと小数の四捨五入は小細工が必要です。sprintf関数で小数の四捨五入に問題があるのは既知の話ですが、Math::Round::nearest関数を使っても、極稀に想定外の結果になる(*1)ことがあります。

  • 問題は下記条件で起きます。例えば「555.555」の末尾を四捨五入して「555.56」とするケースです。
    • 四捨五入対象の数字が
    • 小数点数値リテラルの末尾で
    • かつ「5」のとき
    • 丸め誤差で「5未満」と解釈される

  • そこで上記条件のとき「末尾を6に変更する」という小細工で逃げることにしました。サンプルのコードはこちらになります。コード内の round_alt() が小細工している関数です。

なぜ四捨五入程度の処理で問題が出るの?
  • コンピュータにおける小数の丸め誤差について知識が無いと、四捨五入を間違えると言われても「なんで?」という感想になってしまいますよね。これは数値を2進数で扱うコンピュータ一般の問題なので、まずはそこから説明(*2)します。

  • 通常「十進数」の、例えば「123」という数値は下記のように書けます。桁が10のべき乗で表現されていますね。
    \[(123)_{10} = 1*10^{2} + 2*10^{1} + 3*10^{0}\]
  • 扱う数値が小数だとしても同じです。小数点以下は10のべき乗をマイナスにします。 \[(123.45)_{10} = 1*10^{2} + 2*10^{1} + 3*10^{0} + 4*10^{-1} + 5*10^{-2}\]
  • 2進数の場合もべき乗の底を「2」にするだけです。例えば十進数の「5.5」を2進数にするのは下記となります。
    \[(5.5)_{10} = 5*10^{0} + 5*10^{-1} = 1*2^{2} + 0*2^{1} + 1*2^{0} + 1*2^{-1} = (101.1)_{2}\]

  • ここまで特に問題ありませんが、「0.55」を2進数で表すとどうなるでしょうか。
      小数01桁: 1 : 0.55 > $2^{-1}$=0.5
      小数02桁: 0 : 0.05 < $2^{-2}$=0.25 : (注)0.05=0.55-($2^{-1}$)
      小数03桁: 0 : 0.05 < $2^{-3}$=0.125
      小数04桁: 0 : 0.05 < $2^{-4}$=0.0625
      小数05桁: 1 : 0.05 > $2^{-5}$=0.03125
      小数06桁: 1 : 0.01875 > $2^{-6}$=0.015625 : (注)0.01875=0.05-($2^{-5}$)
      小数07桁: 0 : 0.003125 < $2^{-7}$=0.0078125 : (注)0.003125=0.01875-($2^{-6}$)
      小数08桁: 0 : 0.003125 < $2^{-8}$=0.00390625
      小数09桁: 1 : 0.003125 > $2^{-9}$=0.001953125
      小数10桁: 1 : 0.001171875 > $2^{-10}$=0.0009765625 : (注)0.001171875=0.003125-($2^{-9}$)
      ...
  • そうです。「0.100110011...」の循環小数になってしまいます。

  • このため有限桁で打ち切った二進数値について、前述の進数表現で有限桁を合計しても元の十進数値である「0.55」に絶対届きません。仮に16桁までの結果を十進数に戻すと「0.5499877930」になります。これが丸め誤差と呼ばれるものです。

  • 上記丸め誤差により数値は「0.54...」のため、小数点第2桁の四捨五入結果は切り捨てになってしまうのです。

四捨五入間違いの実例
  • sprintf関数を使用した間違い例を示します。テストコードはこちらです。出力結果が下記になります。
      =================================
      Case1 : 555.345, -555.755
      ---------------------------------
      /1    : 555,    -556    # sprintf : Pass
      /0.1  : 555.3,  -555.8  # sprintf : Pass
      /0.01 : 555.35, -555.75 # sprintf : Fail -555.76 expected!!
      ---------------------------------
      /1    : 555,    -556    # nearest : Pass
      /0.1  : 555.3,  -555.8  # nearest : Pass
      /0.01 : 555.35, -555.76 # nearest : Pass
  • 上段がsprintfによる結果。下段がMath::Round::nearestを使用した結果です。これにより正しい四捨五入結果を得たければMath::Round::nearestを使うべきだという話になりますが、ここで四捨五入対象の数値を意地悪なものにしたところ、nearestでも間違いの結果が出てしまいました
      =================================
      Case2 : 555.555, -555.555
      ---------------------------------
      /1    : 556,    -556    # sprintf : Pass
      /0.1  : 555.6,  -555.6  # sprintf : Pass
      /0.01 : 555.55, -555.55 # sprintf : Fail 555.56, -555.56 expected!!
      ---------------------------------
      /1    : 556,    -556    # nearest : Pass
      /0.1  : 555.6,  -555.6  # nearest : Pass
      /0.01 : 555.55, -555.55 # nearest : Fail 555.56, -555.56 expected!!
  • このnearest側の四捨五入ミスは、数値を小さく(555.555 → 5.555)すると治まったりします(nearest結果を抜粋)。
      =================================
      Case2b: 5.555, -5.555
      ---------------------------------
      /1    : 6,      -6      # nearest : Pass
      /0.1  : 5.6,    -5.6    # nearest : Pass
      /0.01 : 5.56,   -5.56   # nearest : Pass

Math::Round::nearestで四捨五入間違いが起きる理由と対応
  • Math::Round::nearest関数のソースコードは下記です。尚 $Math::Round::half=0.50000000000008; です。
      sub nearest {
       my $targ = abs(shift);
       my @res  = map {
        if ($_ >= 0) { $targ * int(($_ + $Math::Round::half * $targ) / $targ); }
           else { $targ * POSIX::ceil(($_ - $Math::Round::half * $targ) / $targ); }
       } @_;
      
       return (wantarray) ? @res : $res[0];
      }
  • 四捨五入対象桁に0.5より少し大きい値を加えた後小数点位置をずらし、intで小数点以下を切り捨てた後、小数点位置を戻すという動きです。$Math::Round::halfの末尾にある'8'がポイントで、これで丸め誤差に対処しようとしています。

  • ただし0.5を少し超える値が小さいため、それなりの小数点桁数を集めてこないと丸め誤差の落とし穴に落ちます(*3)。ですがコード内のコメントを見ると設定値は適宜調整して欲しいと書かれているため、バグではなく使い方の問題となります。
      The variable B<$Math::Round::half> is used by most routines in this
      module. Its value is very slightly larger than 0.5, for reasons
      explained below. If you find that your application does not deliver
      the expected results, you may reset this variable at will.
  • しかし共通モジュール内のコードを変更するのは避けたいので、nearestでも問題が出ないように処置をしてから渡すという方針を取ることにしました。それが冒頭に書いた「末尾を6に変更する」という小細工です。

  • Perlはスカラー変数が数値リテラルだったとしても、それらを文字列として扱うことができます。したがって小数点演算の前に四捨五入対象の数字(文字)を見て、丸め誤差の落とし穴にはまりやすい「末尾の5」を「6」に変えてしまうという「文字列」処理が可能なのです。

  • 処理後「文字列」をMath::Round::nearestに渡せば「数値リテラル」解釈され、四捨五入後に変更桁は削られます。

そもそも、その四捨五入に意味はあるの?
  • 四捨五入対象の桁が末尾で数値が「5」の場合に問題になることがあるため対処を書いたわけですが、よく考えると、得られた数値リテラルから「1桁だけ四捨五入で削る」という状況です。

  • そうです。「なんで削るの? 削らなくても良くない?」という素朴な疑問が湧きますよね。実は私もそう思っています。はっきり言って、この条件の四捨五入の多くはいわゆる「ブルシット・ジョブ」に相当する(*4)ものでしょう。

  • それでも残っているということは、明らかに変更すべき内容についても、変更コストが高過ぎる環境や状況にあると推察されますが、それは「イノベーションが起きない残念な環境」であるとも言えます。注意したいものですね。
Notes
  • 他に標準的な方法があるのではないかと探していますが見つからない...。う〜ん。
  • 高校の情報の授業で扱っている話かも。私が学生の頃は無かったんですよね。
  • 意地悪ケースで実際に落ちてしまいました。
  • 例外はお金の計算ぐらいでしょうか。
Copyright(C) 2023 Altmo
本HPについて