fuku_no_uchi_23
@fuku_no_uchi_23

Are you sure you want to delete the question?

Leaving a resolved question undeleted may help others!

VB2019 での Math.Round で四捨五入されないように見える件

VB2019 での Math.Round で四捨五入されないように見える件

VisualBasic (VisualStudio 2019, .NET Framework 4.7.2) の Math.Round() 関数で下のような挙動(中間値なのに切り上げられない)となる理由をご教示頂ければと思います。

Decimal 型の利用で解決できる事は確認しております。後学のため、詳細な理由を教えて頂ければ幸いです。

※明示的に MidpointRounding.AwayFromZero を指定しているので「銀行家丸め(ToEven)」にはならないはず…

発生している問題・エラー

TestMathRound
  ソース: SampleTest.vb 行 7
  期間: 2 ミリ秒

 メッセージ: 
    Assert.AreEqual に失敗しました。<8251.491110> が必要ですが、<8251.491100> が指定されました。

 スタック トレース: 
    SampleTest.TestMathRound() 行 8

TestSample
   ソース: SampleTest.vb 行 12
   期間: < 1 ミリ秒

指定桁(5桁目)の次が「5」なので四捨五入されれば繰り上がるはずなのですが、切り捨てられてテストが失敗します。
(確認用に丸め桁(5)より 1桁多くToString("F6")で出力しています)

数値が読み辛くて申し訳ありません :bow:

該当するソースコード

Imports System.Text
Imports Microsoft.VisualStudio.TestTools.UnitTesting

<TestClass>
Public Class SampleTest
    <TestMethod>
    Public Sub TestMathRound()
        Assert.AreEqual("8251.491110", Math.Round(8251.491_105, 5, MidpointRounding.AwayFromZero).ToString("F6")) ' 四捨五入されない?
    End Sub

    <TestMethod>
    Public Sub TestSample()
        Dim target As Double = 0.008_251_491_105 * Math.Pow(10, 6)

        Assert.AreEqual("8251.491105", target.ToString("R"))
        Assert.AreEqual(False, target < 8251.491_105)
        Assert.AreEqual(False, target > 8251.491_105)
    End Sub
End Class

自分で試したこと

本来は TestSample() 内のように桁シフトしてから丸めていた時に気付きました。

(当然ですが)次のように少しだけ値を大きくするとテスト成功します。

- Math.Round(8251.491_105, 5, MidpointRounding.AwayFromZero)
+ Math.Round(8251.491_105_00001, 5, MidpointRounding.AwayFromZero)

TestSample() で、ラウンドトリップ形式での表示も、リテラル値との比較結果も Double 特有の微小誤差の存在を示していない点が不可解です。
※Double の下位桁にゴミがあれば、ToString("R")で表示されるはず…

単なる疑問の上、もしかしたら常識なのかもしれませんが、ご存知の方がおられましたらご協力いただけると嬉しいです。
よろしくお願いいたします。

0

3Answer

勘違いでしたらすみません。

の「注意 (呼び出し元)」セクションによれば、

次の例では、2.135 が 2.14 ではなく 2.13 に丸められます。

とのことです。 機械による日本語訳なので原文を読んだ方がよさそうですが、 まずは動作確認してみてはいががでしょう。

===

追記

原文:

This occurs because internally the method multiplies value by 10digits, and the multiplication operation in this case suffers from a loss of precision.

和訳:

この事象はvalueを10で乗算することによります。このケースでの乗算は、精度の損失を生みます。

この場合、2.135を1000倍したら2134になっちゃうってことなのでは。
(ここまで書いたのなら自分で試せよとも思いますが、時間あったらやってみます・・・)

1Like

Comments

  1. ジャストな回答ありがとうございます。
    上記ページにも目を通したつもりだったのですが、該当セクションをまるっと読み飛ばしてしまっていたようです…

    内部処理で 10の倍数を乗算して判定していれば Double の精度損失が出るのは納得です。
    (何か上手い処理で判定しているのだろうと思いこんでいました…)

    第2引数で丸め桁指定が 0 なら乗算しないので想定通り丸めてくれるのでしょうね。

    ピンポイントで欲しい回答を教えて頂き、誠にありがとうございました。
  2. こちらこそありがとうございます。いやあ、「情報は与えるから自分で考えて」的な回答ですみませんでした・・・。

    しかし、Microsoftの表現も微妙ですね。10倍(100倍、1000倍・・・)することで精度損失するわけでなく、Doubleの時点で精度損失する(ことがある)ので。
    (とか言ってまんま私も和訳しているんですけど)

    ということで、本質的には @radian-jp さんの書かれていることが原因と思います!
  3. だいぶ間があいてしまいましたが、追加調査した結果(下に投稿)、対象値自体も Double では表現しきれていなかった点を納得することができました。

    お二方に示唆して頂いたおかげで、自身で納得いくまで調査を進められました。
    どうもありがとうございました。
  4. ドンマイです!

IEEE754浮動小数点方式の仕様で、正確に表現出来ない数が存在します。
0.1すら正しく表現出来ません。別にVB.NETに限った事ではないです。
小数点以下の精度が重要なら、Decimalを使用してください。

    Sub Main()
        Dim d1 = Double.Parse("8251.491105000001")
        Dim d2 = Double.Parse("8251.491105000000")
        Dim d3 = Double.Parse("8251.491104999999")
        Dim d4 = Double.Parse("8251.491104999998")

        Console.WriteLine(d2 = d1)
        Console.WriteLine(d2 = d3) 'コンピュータの内部表現としては同じなのでTrueになる
        Console.WriteLine(d2 = d4)

        Dim l1 = CLng(d1 * 10000000000000.0)
        Dim l2 = CLng(d2 * 10000000000000.0)
        Dim l3 = CLng(d3 * 10000000000000.0)
        Dim l4 = CLng(d4 * 10000000000000.0)

        Console.WriteLine(l1)
        Console.WriteLine(l2)
        Console.WriteLine(l3)
        Console.WriteLine(l4)

        Console.ReadKey()
    End Sub


(出力結果)
False
True
False
82514911050000016
82514911049999984
82514911049999984
82514911049999968

1Like

Comments

  1. 丁寧なご回答ありがとうございます。

    分かりやすいサンプルコードや参照リンク付きで助かりました。
    おかげさまで朧げに覚えていた部分を復習することができました。

    特に dobon.net 様は他ページも大変勉強になるため、教えて頂き感謝しております。


    私の質問の書き方が悪く、主題がうまくお伝えできなかったようで申し訳ありません。
    迅速なご対応ありがとうございました。
  2. > 私の質問の書き方が悪く、主題がうまくお伝えできなかったようで申し訳ありません。

    遠まわしすぎて判りにくいけど、よく理解出来なかった、参考にならなかった、という認識でいいのかな?なんかこの一言あるので、モヤっとしちゃう。
  3. 丁寧にご対応頂いたにも関わらず、不手際なコメントをお返ししてしまい申し訳ございません。

    頂戴したご回答は浮動小数点に関しての理解を深める上で非常に参考になりました。

    その直前に @imagou 様に「ピンポイントで欲しい回答」とコメントしていたため、フォローしようと思ったのですが逆効果だったようで、本当にすみませんでした。

    質問内容につきましても私の凡ミスに起因しており、@radian-jp 様の貴重なお時間を頂戴してしまった点を深くお詫びいたします。

    最後に「うまくお伝えできなかった」点を弁解させて頂くつもりですが、よりご気分を害する恐れがありますので、この時点でページを閉じるかご検討いただければと思います。


    繰り返しになりますが、迅速かつ丁寧にご回答いただきました事、感謝しております。

    ----------------------------------------------------------
    質問の補足事項

    本文の最後の方で(こっそり)次のように書いてあります。
    > ※Double の下位桁にゴミがあれば、ToString("R")で表示されるはず…

    つまり 8251.491105 が Double で完全に表現できない場合には、ToString("R") の結果が "8251.49110499999" のように出ると考えておりました。

    実際、次のテストを書き、テスト成功を確認したので、丸め対象リテラル値(8251.491105)自体は Double で表現可能だとの認識でした。

    ```vb
    Assert.AreEqual("8251.491105", (8251.491_105).ToString("R"))
    ```

    (TestSample() 関数内に類似の項目がありますが、1e6 を乗算して同値を作成しているため誤解を招く表現となっており、申し訳なく思っております)


    更に、このコメントを書くにあたり、ご提供いただいたリンク先も参照して次のテストも作成いたしました。

    ```vb
    <TestMethod>
    Public Sub TestTargetDoubleIsJustValue()
    Dim dblJust = Double.Parse("8251.491105000000")
    Dim decimJust = CDec(dblJust)
    Assert.AreEqual("8251.49110500000000000000", decimJust.ToString("F20"))

    Dim dblWithError = Double.Parse("8251.491104999999999")
    Dim decimWithError = CDec(dblWithError)
    Assert.AreEqual("8251.49110500000000000000", decimWithError.ToString("F20"))

    Dim dblPrevious = Double.Parse("8251.4911049999946") ' 末尾を 6 から 7 にすると上と同じ Double 値になる
    Dim decimPrevious = CDec(dblPrevious)
    Assert.AreEqual("8251.4911049999900", decimPrevious.ToString("F13")) ' ※1
    Assert.AreEqual("8251.4911049999937", dblPrevious.ToString("R")) ' ※2
    End Sub
    ```
    こちらのテストも成功することから、やはり話題としている丸め対象値は(末尾桁の数値が 5 の中間値として) Double で表現できているものと思います。

    Decimal の有効桁数は 28桁(そのうち整数で 4桁)なので、ToString("F20") の部分も有効のはずかと。

    また、dblPrevious まわりで 8251.491105 と(5桁下までの) 8251.49110499999 は区別できるようです。
    ※2 の結果を見る限り、内部的には更に「…73」となっているようですが、ラウンドトリップ表現した時に 8215.491105 と区別可能とは言えるかと…

    ↑ @radian-jp 様は、このような点をご教示下さったと認識しており、実際のところ私の理解は(リンク先を読んだ後でも)ここで書いた程度の浅い物ではあります。
    (今でも ※1 と ※2 が上のように違う(※2側に揃わない)理由が分かりません)

    ともあれ、肝心なのは 8215.491105 は Double でそのまま表現されているようなのに、Math.Round() で切り上げられない(ように見える)というのが「お伝えしたかった主題」という事になります。


    質問・表現・コード・返信コメント等に至らぬ点があり、ご気分を害しましたこと、重ねてお詫び申し上げます。

    また、この部分に関する誤りや補足のご指摘は大歓迎です。
  4. > 実際、次のテストを書き、テスト成功を確認したので、丸め対象リテラル値(8251.491105)自体は Double で表現可能だとの認識でした。

    > ```vb
    > Assert.AreEqual("8251.491105", (8251.491_105).ToString("R"))
    > ```

    これは、単なる浮動小数点数を10進文字列化した物の比較になるので、表現可能という証明にはならないんですよね。10進表示自体が、近似値に過ぎないので。

    Math.Roundの説明にも、
    > 浮動小数点形式での精度およびバイナリ表現における問題により、メソッドによって返される値が予測不可能な場合があります。
    と記載されています。
  5. 再度のコメントありがとうございます。

    > これは、単なる浮動小数点数を10進文字列化した物の比較になるので、表現可能という証明にはならないんですよね。10進表示自体が、近似値に過ぎないので。

    厳密には仰る通りなのでしょう。

    質問を書いた時点で、8251.491_104_99999 や 8251.491_105_00001 と区別できて、文字列出力で末尾の「0」を省略して表示する程度には「ちょうどの値」を表現しているのならば切り上げた結果を期待していたという状況です。

    また、追加のテストの次の部分で、最後が「~499…」と循環値になっていない点からも(私の感覚では)末尾 5 ちょうどの値を表現しているイメージになってしまいます。

    Dim dblWithError = Double.Parse("8251.491104999999999")
    Dim decimWithError = CDec(dblWithError)
    Assert.AreEqual("8251.49110500000000000000", decimWithError.ToString("F20"))

    ---- ここから駄文 ----
    ・学生時代に数学で挫折しているので、そもそも私の感覚がおかしい蓋然性は高いです
    ・CDec() による変換が Double の理想表現値を Decimalに可能な範囲で表現してくれると期待しています
    → 末尾 5ちょうどより小さいなら Decimal 変換後に「~499…」(有効桁数いっぱい)で出力してほしい(あくまで私の願望です)
    → Double の理想表現値が無限循環の「~499…」の場合は数学的に = 8251.491105 だと言われてしまうとそれまでですが…
    ・ラウンドトリップ出力が末尾 5 で打ち切られているのだから、ちょうどなのだと思いたい(これも願望です)
    → テスト※2で出た 8251.49110499999 はラウンドトリップ出力すると "8251.4911049999937" になるわけですし…(もはや愚痴レベル)
    ---- ここまで駄文 ----

    いずれにしろ、第2引数(丸め桁指定)が 0より大きい場合には乗算処理が入るようですので、丸め対象値が Double で表現可能かどうかに関わらず、演算による【精度の損失】が影響するので現象には納得いたしました。

    仮に丸め処理時に乗算が行われないとしたら、その時こそ丸め対象値が Double で表現可能かどうかが問題になるのかと考えます。
  6. > ・CDec() による変換が Double の理想表現値を Decimalに可能な範囲で表現してくれると期待しています
    型変換そのものも誤差を累積する原因になるので、その辺りは注意してください。
  7. > 型変換そのものも誤差を累積する原因になるので、その辺りは注意してください。

    ここも仰る通りですね。留意します。ご指摘ありがとうございます。


    結局のところ、ご回答いただいた通り、小数点以下が気になるケースでは最初から Decimal を利用するのが望ましいという話になるのですね。
    このお話のおかげで実感が増したように感じます。

    遅い時間までお付き合いいただきまして恐縮です。
    得るものの多い質疑となった事に感謝いたします。本当にありがとうございました。
  8. 時間があいてしまいましたが、Double 表現について私なりに追加調査し、下に投稿させて頂きました。

    結果として @radian-jp 様の仰る通り、正確には表現できない数である事が分かり、納得いたしました。

    私が頑固にも自説を曲げようとせず、ご不快な思いをされたのではと反省しております。
    大変失礼いたしました。

    また、質問以外の部分においても学習の貴重な機会を与えていただきました点にも感謝いたします。
    ご指導のほど、誠にありがとうございました。

少し時間ができたので、追加調査を行いました。

先に結論を述べると、@radian-jp 様のご指摘の通り、対象値は Double では正確に表現できない値でした。

この場を借りて、改めて過日の非礼をお詫びいたします。
私が説明を素直に受け入れようとしない中、根気良く質疑にお付き合いいただきましたこと、誠にありがとうございます。

IEEE 754 表現の調査は次のサイトのお世話になりました。

変換結果(独自記述。元サイトの方が見易いです)

対象値(Double value) 8251.491105
内部表現(Hex value) 0x40c01dbedc8754f3 (0x40cの右が仮数部)

sign exponent(指数部) mantissa(仮数部)
0 10000001100 0000000111011011111011011100100001110101010011110011
+1 1036 1.0000000111011011111011011100100001110101010011110011 (binary)
+1 2^(1036 - 1023) 1.0072620977783202
+1 8192.0000000000000 1.0072620977783202

以下、上の表現が 10進でいくつになっているかの確認となります。

>>> v = 8251.491105
>>> v.hex() # Python での表現も確認(上と一致)
'0x1.01dbedc8754f3p+13'

>>> import decimal
>>> d = decimal.Decimal('8251.491105')
>>> d # Decimal で対象値が表現できる事を確認
Decimal('8251.491105')

>>> d2 = decimal.Decimal('1.0072620977783202') # 仮数部
>>> d2
Decimal('1.0072620977783202')

>>> d2 * 8192 # 指数部を乗算してみる
Decimal('8251.4911049999990784')

結果として、Decimal での合成結果は対象値には一致せず、Double では正確に表現できない数であったと確認できました。

最後になりますが、ご回答いただきましたお二方に今一度お礼申し上げます。
本当にありがとうございました。

1Like

Your answer might help someone💌