josdejong / mathjs

An extensive math library for JavaScript and Node.js
https://mathjs.org
Apache License 2.0
14.44k stars 1.24k forks source link

Why does 1cm + 1mm give rounding error, but 1mm + 1cm does not? #870

Closed arve0 closed 7 years ago

arve0 commented 7 years ago

When using eval with units, first unit in string is set as the "ground" unit for the calculation. For example:

const m = require('mathjs')
m.eval('1cm + 1mm').toString()  // '1.0999999999999999 cm'
m.eval('1mm + 1cm').toString()  // '11 mm'

The result is a funny difference in the output format. Of course one could force output format:

m.eval('1cm + 1mm').format(2)  // '1.1 cm'
m.eval('1mm + 1cm').format(2)  // '11 mm'

I guess this is because first calculation is done with decimal (1 mm as 0.1 cm, which is not precise in binary) and second calculation in integers. As I see it, a sensible heuristic could be:

  1. "Sort" inputs by SI-prefix, smallest SI-prefix first.
  2. Convert all inputs to this SI-prefix.
  3. Do calculation.

Does this heuristic make sense for mathjs, or should the user of mathjs implement similar heuristics himself?

BTW, see that Math Notepad does what I expected: image

ericman314 commented 7 years ago

Both calculations are done with floating point arithmetic and actually result in the same value:

1cm + 1mm to cm    // = 1.0999999999999999 cm
1mm + 1cm to cm    // = 1.0999999999999999 cm

I would suggest formatting the result to about 12 to 14 decimal places, depending on what works best for you. I believe this is what Math Notepad does. Another possibility is using BigNumber, if you need even greater precision.

arve0 commented 7 years ago

Thanks! Setting precision below 17 fixes this.

arve0 commented 7 years ago

Just curious, why does this only happen to cm?

> n = m.eval("1cm + 1mm in meter").format(20)
'0.011 meter'
> n = m.eval("1cm + 1mm in cm").format(20)
'1.0999999999999999 cm'
> n = m.eval("1cm + 1mm in mm").format(20)
'11 mm'
> n = m.eval("1cm + 1mm in micrometer").format(20)
'11000 micrometer'
ericman314 commented 7 years ago

I don't know. Floating point arithmetic is mysterious to me. I think it is something many people have just learned to live with.

Here is a far more information on the subject: https://en.wikipedia.org/wiki/IEEE_floating_point And yet even more information: https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html

arve0 commented 7 years ago

I think (and hope), that I have a good conceptual understanding of floats. I made this contrived example, which I assume is similar in the JS-engine implementation when converting numbers to string. Note that I do calculations with type number, the correct implementation would be with type string and placing the numbers in correct places.

function numberStored (number) {
  let binaryRepresentation = number.toString(2)  // 0.000011010110110
  let [integer, decimal] = binaryRepresentation.split('.')

  let intValues = integer.split('').reverse()
    .map((b, i) => b == 1 ? Math.pow(2, i) : 0)
    .filter(v => v !== 0)  // remove zeroes

  let floatValues = decimal.split('')
    .map((b, i) => b == 1 ? Math.pow(2, -1 - i) : 0)
    .filter(v => v !== 0)  // remove zeroes

  return { intValues, floatValues }
}

let res = numberStored(0.011)
res == { intValues: [],
  floatValues:
   [ 0.0078125,
     0.001953125,
     0.0009765625,
     0.000244140625,
     0.00000762939453125,
     0.000003814697265625,
     0.0000019073486328125,
     2.384185791015625e-7,
     5.960464477539063e-8,
     1.4901161193847656e-8,
     7.450580596923828e-9,
     5.820766091346741e-11,
     9.094947017729282e-13,
     4.547473508864641e-13,
     2.842170943040401e-14,
     3.552713678800501e-15,
     4.440892098500626e-16,
     2.220446049250313e-16,
     5.551115123125783e-17,
     2.7755575615628914e-17,
     1.3877787807814457e-17,
     3.469446951953614e-18 ] }

res.reduce((sum, v) => sum + v, 0)  // 0.011

So with the assumption that mathjs uses the binary representation of 0.011 as the value when creating string, result should be deterministic and same for meter, cm, mm, and so on.

I've debugged a bit and found this interesting:

const m = require('mathjs')

let v = m.parse('1cm + 1mm')  // not evaluated yet, no signs of 1.099999...
v.compile().eval.toString  // still no signs of 1.09999...
v.compile().eval()  // 1.09999.. emerges

Another example:

> m.format(0.011, 100)
'0.011'
> m.format(0.11, 100)
'0.11'
> m.format(1.1, 100)
'1.1'  // why not 1.09999999 ?
> m.format(11, 100)
'11'

Maybe with plus instead?

> m.format(0.01 + 0.001, 100)
'0.011'
> m.format(0.1 + 0.01, 100)
'0.11'
> m.format(1 + 0.1, 100)
'1.1'  // same!
> m.format(10 + 1, 100)
'11'

So I still feel there is something funny going on, reopening.

arve0 commented 7 years ago

Btw, code for add in mathjs is here: https://github.com/josdejong/mathjs/blob/master/lib/function/arithmetic/addScalar.js (used some time to find it)

Can't see that it does anything special.

arve0 commented 7 years ago

If I understood it, the magic (converting 1 cm to value) happens at these places:

https://github.com/josdejong/mathjs/blob/master/lib/function/arithmetic/multiplyScalar.js#L36 https://github.com/josdejong/mathjs/blob/e105f60ab795dedcd294a66d7fcf0c3d0b24fea5/lib/type/unit/Unit.js#L457

Given by eval:

m.parse('1cm + 1mm').compile().eval

function eval (scope) {
  if (scope) _validateScope(scope)
  scope = scope || {}
  return math["add"](
    math["multiply"](1, ("cm" in scope ? getSafeProperty(scope, "cm") : new Unit(null, "cm"))),
    math["multiply"](1, ("mm" in scope ? getSafeProperty(scope, "mm") : new Unit(null, "mm")))
  )
}
josdejong commented 7 years ago

When adding two units or converting a unit, you can get round-off errors because the Units use regular numbers (floats).

The function math.format rounds to a specific precision, which is a great way to get rid of round-off errors (see docs), and apparently it does it's job :).

I'm not entirely sure what question you try to get answered exactly?

arve0 commented 7 years ago

I'm sorry for rambling, TL;DR:

Why does math.eval('1cm + 1mm') get a rounding error when a = 0.01; b = 0.001; c = a + b does not. Both are represented in float, why are the results different?

you can get round-off errors because the Units use regular numbers (floats).

Are you saying that other numbers do not use floats?

The function math.format rounds to a specific precision, which is a great way to get rid of round-off errors (see docs), and apparently it does it's job :).

What about when I specify above the maximum precision, why does it not give the same rounding error? Assuming JS has 64 bit float, precision is 15.96 digits according to wikipedia. Then math.format(c, 20) should give the same rounding error, right?

josdejong commented 7 years ago

The Units of mathjs store the value they hold in SI units (like in meter), so round-off errors can occur when multiplying and dividing to normalize/unnormalize values there. That is really because we try to store values from a 10 digit system into a binary representation with 2 digits. It's how these floats work. Why does the operation 0.5 - 0.2 result in 0.3, whilst 0.4 - 0.1 results in 0.30000000000000004 in the IEEE 754 number system?... Because most numbers cannot be represented exactly and there are round-off errors introduced. Sometimes they pop up, sometimes they don't.

Are you saying that other numbers do not use floats?

Math.js currently supports three numeric types: numbers, BigNumbers, and Fractions. You also have integer number representations (though not natively in JavaScript). etc.

And yes, the JavaScript numbers are 64 bit floats, can hold almost 16 digits. So rounding to 20 digits doesn't make sense. You can try it for yourself though in your developer console :D

arve0 commented 7 years ago

Yes, rounding error do occur, we agree on that. What I'm arguing is that 1cm + 1mm is the same calculation as 1*0.01 + 1*0.001, and it should give the same rounding error.

Sometimes they pop up, sometimes they don't.

I'm trying to find out why, for the enlightenment. As I see it, there is two possibilities here:

  1. My assumptions are wrong, they are not the same calculation. When I read the code, I can not verify this.
  2. There is a bug in the toString() method.
josdejong commented 7 years ago

Ok here we go :)

math.eval('1 cm')        // a unit created holding 0.01 m
math.eval('1 mm')        // a unit created holding 0.001 m
math.eval('1 cm + 1 mm') // a unit created holding 0.011 m

So far no rounding errors.

When calling .toString(), the unit is transformed back to either mm or cm. The heuristics of the current implementation in mathjs will select the prefix of the first unit of the two added units.

math.eval('1 cm + 1 mm').toString() 
    // convert 0.011 m to cm -> 0.011 * 100 = '1.0999999999999999 cm'

math.eval('1 mm + 1 cm').toString() 
    // convert 0.011 m to mm -> 0.011 * 1000 = '11 mm'

So in this case 0.011 * 100 introduces a floating point rounding error, whilst 0.011 * 1000 doesn't.

arve0 commented 7 years ago

Ahh! Of course, it's (0.01 + 0.001) * 100! I'm sorry for the noise.

arve0 commented 7 years ago

Last note:

math.eval('(0.01 + 0.001) * 100').toString().length - 1  // 17, above precision

Minus 1 is the . dot. Thanks again!

josdejong commented 7 years ago

You're welcome.

And yes... the number of digits of a number can float a bit. If you drive deeper into the mystical world of floating point numbers you will surely discover why :)

q2apro commented 6 years ago

Is there any best practice of how to catch those rounding errors?

My examples:

1 um in km     // 0.0000000009999999999999999 km (9.999999999999999e-10 km)
45m^2 in dm^2   // 4499.999999999999 dm^2
1 liter in dm^3    // 0.9999999999999998 dm^3

I tried deal with the first error 1 um in km by:

answer = math.eval(mathline, scope);
answer_out = math.format( answer, {notation: "fixed", lowerExp: -10, upperExp: 10} );
if(answer.toNumber() < 1e-9)
{
    answer_out = math.format( answer, {notation: "fixed", precision: 10, lowerExp: -10, upperExp: 10} );
}

But that is only working for this specific case and will cut the result of 1 nm in km for instance.

I have no idea how to fix the other rounding errors like 45m^2 in dm^2 or the uncommon 1 liter in dm^3 error above.

Maybe round() can help somehow?

josdejong commented 6 years ago

You can play around with the various options that math.format offers. Using a limited precision is what you probably want. The format function uses rounding internally, I don't think using round instead would make it easier.

q2apro commented 6 years ago

Thanks for your reply. I played around already but as I wrote in the post before, those hacks are no solution. That's why I asked for a "best practice".

I thought using precision:10 would be an option, but (1.) it always prints multiply 000 in the end and (2.) it "destroys" handling with values that have more digits. E.g. 1 nm in km is "1e-12 km" - but with the precision hack we get 0.0000000000 km.

PS:

If I write 1 um in km in your http://mathnotepad.com/ it gives me the correct 1e-9km.

If I write math.eval("1 um in km").toString() in the developer console on mathjs.com it gives "9.999999999999999e-10 km" ?!

josdejong commented 6 years ago

If I remember well mathnotepad doesn't do much magic, it does something like:

math.format(math.eval("1 um in km"), {precision: 10}) // "1e-9 km"

I thought using precision:10 would be an option, but (1.) it always prints multiply 000 in the end and (2.) it "destroys" handling with values that have more digits. E.g. 1 nm in km is "1e-12 km" - but with the precision hack we get 0.0000000000 km.

Can you give examples of this?

q2apro commented 6 years ago

I used now:

math.format( answer, {precision: 10, lowerExp: -10, upperExp: 10} );

and it seems to work. Weird. The notation: "fixed" without having precision seems to be the culprit.

Result: https://www.matheretter.de/notiz?n=a113z (1 um in km works now!)

Thanks.

josdejong commented 6 years ago

Good to hear you have a working solution now!

q2apro commented 6 years ago

I have been happy to early :-(

See for example here: https://www.matheretter.de/notiz?n=88374

You can enter 12*0.50+3*1.99 which leads to 11.969999999999999

Damn it :(

q2apro commented 6 years ago

All good, my bad. Did not aply the fix in all my code. Works now!