tools4j / decimal4j

Java library for fast fixed-point arithmetic based on longs with support for up to 18 decimal places.
decimal4j.org
MIT License
156 stars 17 forks source link

Difference in RoundingMode.HALF_UP rounded value for 0.663125 at scale 5 #17

Closed nezda closed 4 years ago

nezda commented 4 years ago

This test comparing rounding of a value with decimal4j 1.0.3 vs commons-math3 3.6.1 fails

  @Test
  public void test() {
    final double input = 0.663125;
    final double expected = Precision.round(input, 5, RoundingMode.HALF_UP.ordinal()); // 0.66313
    final var decimalArithmetic = Scales.getScaleMetrics(5).getArithmetic(RoundingMode.HALF_UP);
    final double actual = decimalArithmetic.toDouble(decimalArithmetic.fromDouble(input));
    assertThat(actual).isEqualTo(expected);
  }

output:

expected: 0.66313
but was : 0.66312
dcullender-cb commented 4 years ago

please try

final var decimalArithmetic = Scales.getScaleMetrics(6).getArithmetic(RoundingMode.HALF_UP);

The scale you used (5) meant that the 6th decimal place was truncated

nezda commented 4 years ago
    final double input = 0.663125;
    final double expected = Precision.round(input, 5, RoundingMode.HALF_UP.ordinal()); // 0.66313
    final var decimalArithmetic = Scales.getScaleMetrics(6).getArithmetic(RoundingMode.HALF_UP);
    final double actual = decimalArithmetic.toDouble(decimalArithmetic.fromDouble(input)); // 0.663125
    assertThat(actual).isEqualTo(expected);

fails differently...

expected: 0.66313
but was : 0.663125
terzerm commented 4 years ago

Hi

Short answer is: this is expected behaviour in decimal4j.

The longer answer is an explanation and some alternative ways to round a double with decimal4j.

Explanation

Firstly --- this is a very good question, thanks for stating it here. The problem that Is highlighted here is more broadly the problem of 'rounding a double value' or more generally 'rounding a binary floating point value to a decimal precision'.

To see this let’s look into the input value. If printed to its full precision the value is actually 0.66312499999999996447286321199499070644378662109375

The value can be obtained via

new BigDecimal(0.663125).toPlainString();

Now you need to know that decimal4j looks at the exact value representation of a double value when it is converted — so it produces the correct result when rounding above value down to 0.66312.

The big question — and source of confusion — is that Java prints this value as 0.663125 and not the huge 50 fraction digit value above. It prints “as many, but only as many, […] digits as are needed to uniquely distinguish the [...] value from adjacent values […]”. This is quite ingenious but can lead to confusion as many values now look just like an exact decimal value when in fact they are not, and it is also not trivial to implement. The documentation for this can be found on the static Double.toString(double) method.

To reiterate, the decimal4j fromDouble(..) conversion methods operate with the binary exact value and not with the string representation of that value.

Alternatives

The most straight forward way to round the above value according to its string representation is to input it as a string:

DecimalArithmetic arith = Scales.getScaleMetrics(5).getArithmetic(RoundingMode.HALF_UP);
arith.toDouble(arith.parse(String.valueOf(0.663125)));

This is not always desirable as it creates a string object and therefore garbage. This can be prevented by using a StringBuilder that is reused:

StringBuilder stringBuilder = new StringBuilder(64);
stringBuilder.setLength(0);
stringBuilder.append(0.663125);
DecimalArithmetic arith = Scales.getScaleMetrics(5).getArithmetic(RoundingMode.HALF_UP);
arith.toDouble(arith.parse(stringBuilder, 0, stringBuilder.length()));

A big drawback of this is currently that decimal4j does not support scientific notation, so it will not work for all double values. Parsing scientifically printed double values has been requested here and may be supported in future decimal4j versions.

A last option to round a value more intuitively is to use a 2 step rounding approach:

For our example this would look for instance like this:

DecimalArithmetic arith8 = Scales.getScaleMetrics(8).getRoundingHalfEvenArithmetic();
DecimalArithmetic arith5 = Scales.getScaleMetrics(5).getArithmetic(RoundingMode.HALF_UP);

long unscaled8 = arith8.fromDouble(0.663125);//rounding off the noise
long unscaled5 = arith5.fromUnscaled(unscaled8, arith8.getScale());//actual rounding happens here!
double rounded = arith5.toDouble(unscaled5);

The scale for noise rounding should be at least 1 more than the actual precision, our favourite choice is 3 extra digits. Note however that the extra precision means a lower maximum value and one should be careful not to cause overflows!

Source Code

All code above is available as a unit test here: