What is the rounding rule when the last digit is 5 in .NET?
Asked Answered
P

2

45

Here is my code:

using static System.Console;

namespace ConsoleApp2
{
    internal class Program
    {
        static void Main(string[] args)
        {
            double[] doubles = new[] { 9.05, 9.15, 9.25, 9.35, 9.45, 9.55, 9.65, 9.75, 9.85, 9.95 };
            foreach (double n in doubles)
            {
                WriteLine("{0} ===> {1:F1}", n, n);
            }

        }
    }
}

Output in .NET Framework 4.7.2:

9.05 ===> 9.1
9.15 ===> 9.2
9.25 ===> 9.3
9.35 ===> 9.4
9.45 ===> 9.5
9.55 ===> 9.6
9.65 ===> 9.7
9.75 ===> 9.8
9.85 ===> 9.9
9.95 ===> 10.0

Output in .NET 6 (with same code):

9.05 ===> 9.1
9.15 ===> 9.2
9.25 ===> 9.2
9.35 ===> 9.3
9.45 ===> 9.4
9.55 ===> 9.6
9.65 ===> 9.7
9.75 ===> 9.8
9.85 ===> 9.8
9.95 ===> 9.9

So, in .NET Framework, the numbers are rounded just like we were taught in school. Which is called round half up in Wikipedia.

But in .NET 6, 9.05, 9.15, 9.55, 9.65, 9.75 are rounded up, while 9.25, 9.35, 9.45, 9.85, 9.95 are rounded down.

I know there is a rule called round half to even – rounds to the nearest value; if the number falls midway, it is rounded to the nearest value with an even least significant digit.

But this is obviously not round half to even, some numbers are rounded to odd.

How can we explain the difference in .NET Framework 4.7.2 with .NET 6 and how can I just round the numbers in the same way as .NET Framework in .NET 6?

Playroom answered 31/7, 2022 at 20:4 Comment(12)
I very much suspect the difference is in the {1:F1} part, but it is too late (as in bed time) for me to investigate for you.Bushwhack
Maybe the difference in behavior is related to floating-point formatting "improvements" in .NET Core 3.0.Sloe
@AndrewMorton The results follow the round half up rule when I change {1:F1} to {1:.#}.Playroom
This question seems relevantHymanhymen
Try changing your test to having a format specifier like {1:F20} and see what happens. Skimming through @MichaelLiu's link makes me believe that those changes are the rationale for what you are seeing. Remember: float and double are inexact representationsPrecedency
WARNING: if you are doing any kind of statistical analysis, the "round up" rule puts a bias into your results. That's the reason all statisticians -- and most good stats tools like R, Julia, use the "round to even" rule.Atthia
@CarlWitthoft It is called Banker's Rounding, I think .NET also use this rule if you see the answer from David Browne.Playroom
It does not matter what the rounding rule is. When rounding, you should take the accuracy of the value into account anyways, just like for any other floating point comparison.Dumpish
Rounding of last digit changes after Windows .NET update, Rounding issues .Net Core 3.1 vs. .Net Core 2.0/.Net Framework, Rounding in .NET 5 vs. .NET 4.7.2Yardage
You should also be aware of Is floating point math broken? Those numbers may not be exactly what you think they are.Crowns
This is a mega duplicate. Why isn't it closed instead of answered? There isn't any reason to answer the same basic questions over and over and over again.Rutharuthann
A start (2008)—not necessarily the canonical: Why does .NET use banker's rounding as default?Rutharuthann
E
53

Use decimal, not double, otherwise you're not starting with the exact values you think you are, and you get the expected results.

9.05 ===> 9.1
9.15 ===> 9.2
9.25 ===> 9.3
9.35 ===> 9.4
9.45 ===> 9.5
9.55 ===> 9.6
9.65 ===> 9.7
9.75 ===> 9.8
9.85 ===> 9.9
9.95 ===> 10.0

With doubles, most of the values are slightly off from the decimal literals in the code, and so are rounded to the nearest number. Only two are actually at the midpoint, and are rounded to even in .NET Core. But as @Traveller points out this isn't general rounding behavior; it's specific to how floating point numbers are printed.

9.05000000000000071E+000 ===> 9.1 <- rounded to nearest
9.15000000000000036E+000 ===> 9.2 <- rounded to nearest
9.25000000000000000E+000 ===> 9.2 <- rounded to even
9.34999999999999964E+000 ===> 9.3 <- rounded to nearest
9.44999999999999929E+000 ===> 9.4 <- rounded to nearest
9.55000000000000071E+000 ===> 9.6 <- rounded to nearest
9.65000000000000036E+000 ===> 9.7 <- rounded to nearest
9.75000000000000000E+000 ===> 9.8 <- rounded to even
9.84999999999999964E+000 ===> 9.8 <- rounded to nearest
9.94999999999999929E+000 ===> 9.9 <- rounded to nearest
Excretory answered 31/7, 2022 at 20:10 Comment(9)
That doesn't appear to explain the difference observed in the question. Changing the type of the variable is cheating ;) Is there official documentation to support the change?Bushwhack
@AndrewMorton I disagree. If the code operates on decimal values, for example 9.25, suggesting changing the type used is the right answer to give.Surfboard
@AndrewMorton Using doubles and then expected exact results is always wrong, If you need to round in a controlled fashion use doubles. #588504Patricide
@tymtam: Any of the other numbers would be better examples. 9.25 is exactly representable as a binary floating-point double, because it can be represented as a fraction with a power-of-2 denominator. (Try it for single-precision float in h-schmidt.net/FloatConverter/IEEE754.html). It's source values like 9.15 that are a problem, as you can see in David's table of the decimal values represented by the nearest double to 9.15, 9.25 etc. (i.e. the result of round-to-nearest applied at compile time to the source code text, before run-time rounding during conversion to a string.)Fireworks
@PeterCordes Haha, what are the chances. I guess, the chances are 20% because 9.75 is the other exactly representative number.Surfboard
@tymtam This answers the second of the two questions, on the condition that it's an option to change the data type. I'd argue that's non-trivial in most real-world, brownfield scenarios.Monstrous
@EricJ In my experience double->decimal is irrationality feared. (I acknowledge that this fear is a real obstacle to making the change.)Surfboard
@tymtam That really depends on the requirements of the program. Decimal is a great choice in many but not all scenarios. Decimal is significantly slower on most hardware because double has CPU support. Decimal takes twice the memory. Try running this code double foo = Math.Pow(10, 29); decimal bar = (decimal)foo;. Does the OverflowException matter? Depends.Monstrous
Decimal is significantly slower on most hardware. The number of projects where this matters is tiny and people who do these understand the differences. And the context here is ToString. Also, a faster multiplication that produces an incorrect result is hardly a success. In Summary, I don't disagree with you about the differences and that there are scenarios where decimal doesn't fit (Re: your pow example = badam tsh), but for a .net website project where you send numbers over json, decimal is most likely a better choice and should be the default. (You can have the last word here).Surfboard
V
38

The Microsoft documentation have this info carefully hidden in the Standard numeric format strings page (it's probably elsewhere as well, but not in the Double.ToString docs).

Here's the important excerpt, for posterity:

When precision specifier controls the number of fractional digits in the result string, the result string reflects a number that is rounded to a representable result nearest to the infinitely precise result. If there are two equally near representable results:

  • On .NET Framework and .NET Core up to .NET Core 2.0, the runtime selects the result with the greater least significant digit (that is, using MidpointRounding.AwayFromZero).

  • On .NET Core 2.1 and later, the runtime selects the result with an even least significant digit (that is, using MidpointRounding.ToEven).

Since .Net 5 and later mostly continue the Core line despite Microsoft's confusing statements about how they've been merged, that'll pretty clearly fall under the 2nd case.

Vaporetto answered 31/7, 2022 at 21:18 Comment(7)
When n=9.05, WriteLine("{0} ===> {1:F20}", n, n) outputs 9.05 ===> 9.05000000000000071054, how come 9.05 is not rounded to even in 2nd case?Playroom
Because 9.05000000000000071054 is closer to 9.1 than it is to 9.0. MidpointRounding only applies when you're actually at the midpoint, like with 9.25000000000000000Excretory
@DavidBrowne-Microsoft OK, I thought it was only decided by the second digit after the decimal point, regardless what behind this digit.Playroom
@Playroom - one problem with round away from zero and working with only positive numbers (e.g. rounding you remember from school) is that you don't realise that the rounding rule only applies when you're exactly at the midpoint. You don't need to invoke a rounding rule to know that 9.59 is closer to 10 than to 9. So when you're using a different rounding rule, you still wouldn't round 9.59 down to 9.Sanskrit
There is more info about .NET rounding on the Math.Round page.Holozoic
@Damien_The_Unbeliever: Does C# let you set a rounding mode like IEEE roundTowardNegative, so 9.99 rounds to 9, and -9.01 rounds to -10? (floor rounding, towards -Infinity). Semi-related: the four rounding modes x86 hardware supports directly (for every math operation and for rounding to integer) are the default roundTiesToEven (C# MidpointRounding.ToEven), roundTowardPositive (ceil), roundTowardNegative (floor), and roundTowardZero (trunc). Anyway, if using a non-midpoint rounding rule for float -> string conversion (where SW has to do the work anyway), it could matter.Fireworks
@Peter C# supports the following rounding modes. Iirc Banker's rounding is the default.Buy

© 2022 - 2024 — McMap. All rights reserved.