Why isn't format!("{:.2}", x) accurate?

fn main() {
    println!("{:.2}", 4.305); // 4.30 -> innaccurate
    println!("{:.2}", round_number(4.305)); // 4.31 -> accurate
}

pub fn round_number(nr: f64) -> f64 {
    (nr * 100f64).round() / 100f64
}

Why isn't {:.2} accurate by default?

Because floating-point numbers aren't decimal.

5 Likes

To elaborate, this demonstrates the precise behavior at play relatively well:

fn main() {
    println!("{:.2}", 4.305); // 4.30 -> “innaccurate”
    println!("{:.80}", 4.305); // 4.30 -> full accuracy of *actual* float
}
4.30
4.30499999999999971578290569595992565155029296875000000000000000000000000000000000

In case you wonder now “why does multiplying by 100 give the more ‘correct’ seeming result then?”, the answer is, that the deviation between 4.30499999999999971578290569595992565155029296875 and 4.305 is so small, that the floating point number closest to 430.499999999999971578290569595992565155029296875 turns out to be exactly 430.5.

fn main() {
    println!("{:.80}", 430.499999999999971578290569595992565155029296875);
}
430.50000000000000000000000000000000000000000000000000000000000000000000000000000000

Finally, if you wonder, why does println!("{}", 4.305); print something different than println!("{:.80}", 4.305), almost as if the float in question was exactly 4.305, the answer to that question in turn is: When printing a float without specifying a number of digits, then the printing algorithm will choose only as many digits as are necessary in order to converting the result back into a float to create an accurate result. Since turning the decimal number 4.305 into a float will correctly result in the floating point number 4.30499999999999971578290569595992565155029296875 being generated, that means that 4.305 is a fine choice for printing it.

10 Likes

And if you're interested in further details, there is this gem and its accompanying article, which brightened my whole day when I first stumbled across them: the world just seems a better place when you discover something so well done.

8 Likes

Ah, very nice! I’ve googled for similar tools quickly, but didn’t come across one where I could properly link to the number in question ^^

1 Like

On top of the other good answers, it's worth noting that floating point is not meant to be a perfect representation (not even of numbers with an exact representation in binary); they are meant to be an approximation of the infinite set of real numbers into a finite set, designed to be amenable to numerical analysis and also to get "good enough" results most of the time if you're not doing numerical analysis of your algorithms.

In turn, numerical analysis is the field of mathematics that looks at how approximations behave algorithmically, and aims to either tell you that your algorithm can't be used to get useful results with an approximation, or what the error bound is for your algorithm. For example, naïve floating point summation has an error bound that's proportional to the number of entries in the list to be summed; a good compensated summation algorithm has an error bound that's independent of the number of entries in the list, and only depends on the range of values in the list and the precision of the floating point types in use.

5 Likes

Because your example doesn't actually show what you think it does. As proof, here's the same code with a different value to "show" that {:.2} is accurate but round_number isn't:

fn main() {
    println!("{:.2}", 4.3049999999999999); // 4.30 -> accurate
    println!("{:.2}", round_number(4.3049999999999999)); // 4.31 -> inaccurate
}

pub fn round_number(nr: f64) -> f64 {
    (nr * 100f64).round() / 100f64
}

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=da4aedef8484d127fb24321f50529c5d

Rounding is a horrible operation, because any error in the input can be greatly magnified in the output.

6 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.