10

I have this "scientific application" in which a Single value should be rounded before presenting it in the UI. According to this MSDN article, due to "loss of precision" the Math.Round(Double, Int32) method will sometimes behave "unexpectedly", e.g. rounding 2.135 to 2.13 rather than 2.14.

As I understand it, this issue is not related to "banker's rounding" (see for example this question).

In the application, someone apparently chose to address this issue by explicitly converting the Single to a Decimal before rounding (i.e. Math.Round((Decimal)mySingle, 2)) to call the Math.Round(Decimal, Int32) overload instead. Aside from binary-to-decimal conversion issues possibly arising, this "solution" may also cause an OverflowException to be thrown if the Single value is too small or to large to fit the Decimal type.

Catching such errors to return the result from Math.Round(Double, Int32), should the conversion fail, does not strike me as the perfect solution. Nor does rewriting the application to use Decimal all the way.

Is there a more or less "correct" way to deal with this situation, and if so, what might it be?

Community
  • 1
  • 1
Oskar Lindberg
  • 2,255
  • 2
  • 16
  • 36
  • Can you please provide a runnable code example where 2.135 rounds to 2.13? – Scott Chamberlain Jun 25 '15 at 14:52
  • The range of a decimal is bigger than that of a Single, so there should be no overflow – aochagavia Jun 25 '15 at 14:52
  • @ScottChamberlain The MSDN link provided contains such code. – Oskar Lindberg Jun 25 '15 at 14:54
  • may be if you can convert them just for trunc you can use Math.Truncate: https://msdn.microsoft.com/en-us/library/system.math.truncate.aspx – lem2802 Jun 25 '15 at 14:57
  • @aochagavia I get `System.OverflowException: Value was either too large or too small for a Decimal. at System.Decimal..ctor(Single value)`. – Oskar Lindberg Jun 25 '15 at 15:00
  • @OskarLindberg: Can you provide a specific example of a Single value that causes a problem? – Michael Liu Jun 25 '15 at 15:02
  • @MichaelLiu Presently, I'm afraid not. I can't get to the value causing the error right now, but I too want to see what it is. I'll update as soon as I can, but it might not be soon. – Oskar Lindberg Jun 25 '15 at 15:04
  • This is the code from MSDN and it's indeed showing the issue: https://ideone.com/REutCX – ytoledano Jun 25 '15 at 15:06
  • What do you want 2.135 rounded to, bearing in mind that the actual value might be 2.13499999 or 2.13500001 for example? – Graham Jun 25 '15 at 15:32
  • Do you have more context for where this becomes apparent? If it's an issue due to rounding user input, then the simplest solution is to either not allow the user to enter more decimals than are allowed or just don't round user input (only round the output of calculations.) Since the results of calculations don't show the 'unrounded' value, the unexpected behavior is not apparent. Ultimately the confusion isn't due to the rounding function itself, but rather due to the fact that the system displays the original value as "2.135", when it's actually not. – Dan Bryant Jun 25 '15 at 15:47
  • @Graham I understand the validity of your question i a general context. In this case however, I'm just asking for advice on how to deal with the situation safely and in a user-predictable manner, given the preconditions described. If you have some recommendation depending on the answer to yor question, please post it as an answer. – Oskar Lindberg Jun 25 '15 at 15:48
  • @DanBryant These are calculated values, not user input. – Oskar Lindberg Jun 25 '15 at 15:50
  • I'd advise that if you want exactness, avoid the inherently "inexact" mantissa-based types, like `Single` and stick with `Decimal` to begin with. https://msdn.microsoft.com/en-us/library/hd7199ke.aspx – code4life Jun 25 '15 at 15:51
  • @Oskar Maybe I don't fully understand the question. Is there anything unsatisfactory with just using `Math.Round(mySingle, 2)` – Graham Jun 25 '15 at 15:53
  • @Oskar, Yes, but how does the user ever see the discrepancy? This is most likely an issue of user expectations rather than correctness. It's only an issue of correctness if you're dealing with exact quantities like discrete counts and need to match base-10 results exactly for reproducibility with manual calculations, in which case you need to be doing your calculations in base-10. – Dan Bryant Jun 25 '15 at 15:57
  • Why are you rounding? For display purposes? In that case, you might prefer a format string instead of rounding the number. – CodesInChaos Jun 25 '15 at 20:48
  • @CodesInChaos I think you're right, if you don't need the control that `Math.Round` provides through the `MidpointRounding enum` - and for sole display purposes I agree that you probably don't. – Oskar Lindberg Jun 26 '15 at 18:18

3 Answers3

2

I would argue that your existing solution (using the Decimal version of Math.Round) is the correct one.

The underlying problem is that you expect numbers to be rounded according to their base 10 representation, but you've stored them as base 2 floating point numbers. The provided example of 2.135 is one of those edge cases where the base 2 representation doesn't exactly match the base 10.

To get the expected rounding behavior, you must convert the numbers to base 10. The easiest way is exactly what you're already doing: temporarily convert the number to a Decimal long enough to call Math.Round.

RogerN
  • 3,761
  • 11
  • 18
  • But doesn't the same thing happen when you convert single to decimal? – Graham Jun 25 '15 at 15:55
  • It's worth noting that the .NET Decimal format is still not storing a true base 10 representation, so you can run into accumulation errors if repeatedly adding/subtracting decimals at the extreme end of their precision scale. It's quite suitable for dealing with typical base-10 math with limited precision, however and will properly replicate 'manual' calculations that can be performed purely in base-10 with a limited number of decimals. – Dan Bryant Jun 25 '15 at 16:06
  • @Graham If I understand you correctly then, yes, there's a type of rounding that gets performed when you convert a single to a decimal. So 2.134999999 or whatever gets rounded to its decimal equivalent of 2.135, which *then* gets passed to Math.Round and converted to 2.14. – RogerN Jun 25 '15 at 16:13
  • So I am not convinced converting to decimal helps, because in your example, I expect 2.1349 to round to 2.13 (since it is nearer), but if you round to 2.135 first and then round again you get 2.14 – Graham Jun 25 '15 at 16:25
  • @Graham It wouldn't get rounded to 2.13 because the conversion to a decimal will use as much precision as is available, not just 2 decimal places. *Then* the explicit rounding call gets made with just 2 decimal places. – RogerN Jun 25 '15 at 16:27
  • The MSDN article claims that the reason why the rounding behaves as it does is "because internally the method multiplies value by 10 digits, and the multiplication operation in this case suffers from a loss of precision." How does this translate into the "underlying problem" being base 2 vs base 10? – Oskar Lindberg Jun 25 '15 at 17:16
  • @OskarLindberg Multiplication by 10 suffers from a loss of precision because the number is base 2, not base 10. Which means that the mantissa, not just the exponent, must change when multiplying a binary floating point number by 10. The binary representation of the number may have not enough bits to hold the precise result. Try it: `var test = (int)(2.135f * 1000.0f)` – RogerN Jun 25 '15 at 17:46
  • @OskarLindberg It really depends on your users. If you don't think your users will care then, sure, simplify your code by not converting. But in my experience users are frustrated by behavior that seems inconsistent to them. The original rounding issue you described could fall into that category: users won't understand base 2 vs. base 10 issues, or why "normal" rounding rules aren't being followed. The `Decimal` conversion trick is a relatively inexpensive way to remove (some) inconsistency. More complete solutions exist, but they'll take more work. – RogerN Jun 26 '15 at 18:19
  • @OskarLindberg I get the feeling that you've already formed a strong opinion about what to do, so I'm not sure what you're fishing for. Yes, the conversion may fail, but you've got a working fallback solution in place. If this method bothers you that much then you can always write your own rounding function. I can't tell you whether your users are likely to be bothered by your current fallback or not. – RogerN Jun 26 '15 at 18:29
1

Since floating point trades precision for range, the decimal value 2.135 can't be exactly represented in binary.

The [closest] binary representation works out to be something like 0.1348876953125 decimal, so the rounding is correct (if not intuitively obvious).

You should read Goldberg's paper, "What every computer scientist should know about floating-point arithmetic" (ACM Computing Surveys, Volume 23 Issue 1, March 1991, pp. 5-48)

Abstract. Floating-point arithmetic is considered as esoteric subject by many people. This is rather surprising, because floating-point is ubiquitous in computer systems: Almost every language has a floating-point datatype; computers from PCs to supercomputers have floating-point accelerators; most compilers will be called upon to compile floating-point algorithms from time to time; and virtually every operating system must respond to floating-point exceptions such as overflow. This paper presents a tutorial on the aspects of floating-point that have a direct impact on designers of computer systems. It begins with background on floating-point representation and rounding error, continues with a discussion of the IEEE floating point standard, and concludes with examples of how computer system builders can better support floating point.

Nicholas Carey
  • 71,308
  • 16
  • 93
  • 135
  • I know this already and I read that particular article you mention many years ago. Your advice is good, but my question is not about floating point arithmetics etc. – Oskar Lindberg Jun 26 '15 at 18:09
0

I just looked at the documentation and there appears to be a enum you can pass into Math.Round(). If you change to Math.Round(Double, Int32, MidpointRounding.AwayFromZero) you should get the desired result.

https://msdn.microsoft.com/en-us/library/vstudio/ef48waz8(v=vs.100).aspx

Edit: just tested with these numbers. Changed the numbers and

    double abc = 2.335;
    Console.WriteLine(Math.Round(abc, 2, System.MidpointRounding.AwayFromZero));        
    abc = 2.345;
    Console.WriteLine(Math.Round(abc, 2, System.MidpointRounding.AwayFromZero));

    abc = 2.335;
    Console.WriteLine(Math.Round(abc, 2));      
    abc = 2.445;
    Console.WriteLine(Math.Round(abc, 2));

and got these results.

2.34
2.35
2.34
2.44

Edit 2: I used the original numbers you gave and it is breaking. I thought that by using AwayFromZero it would solve the double rounding down (I figured it applied only to bankers rounding), it does not. If you do need the precision you are looking for from your rounding you'll have to create your own function that gives you the precision you need by converting to double or another method, but I've been looking for a while and haven't found anything, I'll check back to see if you come up with a solution.

double abc = 2.135;
Console.WriteLine(Math.Round(abc, 2, System.MidpointRounding.AwayFromZero));        
abc = 2.145;
Console.WriteLine(Math.Round(abc, 2, System.MidpointRounding.AwayFromZero));

abc = 2.135;
Console.WriteLine(Math.Round(abc, 2));      
abc = 2.145;
Console.WriteLine(Math.Round(abc, 2));

2.13
2.15
2.13
2.14
thinklarge
  • 672
  • 8
  • 24
  • Thank you for contributing, but it would seem that you may not have taken the time to properly read the links I provided. Also, I already clearly stated that I think that the "banker's rounding" issue is a different one, and provided enough information that you should be able to understand what that means. – Oskar Lindberg Jun 25 '15 at 15:30
  • Sorry I read the documentation and it says, "in some cases the Round(Double, MidpointRounding) method may not appear to round midpoint values to the nearest even integer". I figured this would be bypassed if you used AwayFromZero, but the example below shows that even that doesn't work. So you are stuck with decimals. – thinklarge Jun 25 '15 at 16:34