go-rounding icon indicating copy to clipboard operation
go-rounding copied to clipboard

Rounding 0.00015 to precision 4 is not giving 0.0002

Open oderwat opened this issue 2 years ago • 6 comments

I am actually looking into this for some hours, I need "Bankers Rounding" on place 4 and originally started with a float variant:

func BankerRound4(num float64) float64 {
	// fails for 0.00015 because 0.00015*10000 becomes 1.4999999999999998
	return math.RoundToEven(num*10000) / 10000
}

I didn't notice that problem before I tried to implement a big.Rat version myself. In the end, I came up with:

func RatBankerRound4(num *big.Rat) *big.Rat {
	floatStr5 := num.FloatString(5)
	var result = &big.Rat{}
	fifth := floatStr5[len(floatStr5)-1:]
	addOne := false
	result, _ = result.SetString(floatStr5[:len(floatStr5)-1])
	if fifth == "5" {
		fourth := floatStr5[len(floatStr5)-2 : len(floatStr5)-1]
		switch fourth {
		case "1", "3", "5", "7", "9":
			addOne = true
		}
	} else if fifth > "5" {
		addOne = true
	}
	if addOne {
		if num.Sign() < 0 {
			result = result.Sub(result, big.NewRat(1, 10000))
		} else {
			result = result.Add(result, big.NewRat(1, 10000))
		}
	}
	return result
}

This does solve all my tests, which I verified with multiple online calculators (of which one had the problem I described above):

		// See: https://www.calculatestuff.com/math/rounding-numbers-calculator
		{"Test 0.00014", args{0.00014}, 0.0001},
		// half to even. 1 to even = 2
		{"Test 0.00015", args{0.00015}, 0.0002},
		{"Test 0.00016", args{0.00016}, 0.0002},

		{"Test 0.00024", args{0.00024}, 0.0002},
		// half to even. 2 to even = 2
		{"Test 0.00025", args{0.00025}, 0.0002},
		{"Test 0.00026", args{0.00026}, 0.0003},

		{"Test 0.00034", args{0.00034}, 0.0003},
		// half to even. 3 to even = 4
		{"Test 0.00035", args{0.00035}, 0.0004},
		{"Test 0.00036", args{0.00036}, 0.0004},

		{"Test 0.00044", args{0.00044}, 0.0004},
		// half to even. 4 to even = 4
		{"Test 0.00045", args{0.00045}, 0.0004},
		{"Test 0.00046", args{0.00046}, 0.0005},

		// here the negatives
		{"Test -0.00014", args{-0.00014}, -0.0001},
		// half to even. -1 to even = -2
		{"Test -0.00015", args{-0.00015}, -0.0002},
		{"Test -0.00016", args{-0.00016}, -0.0002},

		{"Test -0.00024", args{-0.00024}, -0.0002},
		// half to even. -2 to even = -2
		{"Test -0.00025", args{-0.00025}, -0.0002},
		{"Test -0.00026", args{-0.00026}, -0.0003},

		{"Test -0.00034", args{-0.00034}, -0.0003},
		// half to even. -3 to even = -4
		{"Test -0.00035", args{-0.00035}, -0.0004},
		{"Test -0.00036", args{-0.00036}, -0.0004},

		{"Test -0.00044", args{-0.00044}, -0.0004},
		// half to even. -4 to even = -4
		{"Test -0.00045", args{-0.00045}, -0.0004},
		{"Test -0.00046", args{-0.00046}, -0.0005},

While implementing stuff, I found that I needed to truncate big.Rat values to 4 decimals. This is when I found your package.

While looking through it, I saw your rounder and tried rounding.Round(num, 4, rounding.HalfEven), which I thought, it would be maybe more elegant than my quick straightforward implementation, that uses strings to detect the different cases. But the results are similar to the Float64 version I posted above. In fact, nearly every implementation I find does that "wrong". This includes a big Go database. There is a lot of code that uses multiply/divide using math.Pow() or some variant. It seems that all of them suffer from the floating-point problematic, that I think causes the problem.

See also: https://go.dev/play/p/bduKZF3AfQC

oderwat avatar May 09 '23 16:05 oderwat

This may help to resolve the problem: https://go.dev/play/p/HP82b8ZX_Zp

trunc() is defective:

trunc(new(big.Rat).SetFloat64(0.00015), 5) returns "14/100000" which is not right, is it?

oderwat avatar May 09 '23 17:05 oderwat

OK. Forget it :) ... I just found that the real problem here is how I initialize the variable!

fmt.Println(trunc(big.NewRat(15, 100000), 5)) does return "15/100000".

I guess my Round routine actually works "correct" in an unexpected way because it introduces a similar rounding as all the other float64 "outputs" do.

I hope I did not waste your time. Thanks for this great package!

oderwat avatar May 09 '23 17:05 oderwat

Actually. Maybe it is a bug because it should also round 5534023222112865/36893488147419103232 correctly?

oderwat avatar May 09 '23 17:05 oderwat

Thanks for checking out the module!

Actually. Maybe it is a bug because it should also round 5534023222112865/36893488147419103232 correctly?

How are you testing that? It seems to be correct to me:

x, _ := new(big.Int).SetString("5534023222112865", 10)
y, _ := new(big.Int).SetString("36893488147419103232", 10)
fmt.Println(rounding.Round(new(big.Rat).SetFrac(x, y), 3, rounding.HalfEven).FloatString(3))
fmt.Println(rounding.Round(new(big.Rat).SetFrac(x, y), 4, rounding.HalfEven).FloatString(4))
fmt.Println(rounding.Round(new(big.Rat).SetFrac(x, y), 5, rounding.HalfEven).FloatString(5))
fmt.Println(rounding.Round(new(big.Rat).SetFrac(x, y), 32, rounding.HalfEven).FloatString(32))

Gives:

0.000
0.0001
0.00015
0.00014999999999999998685946966948

Which looks correct based on:

  • https://www.wolframalpha.com/input?i=5534023222112865%2F36893488147419103232
  • https://www.wolframalpha.com/input?i=round+5534023222112865%2F36893488147419103232+to+4+decimal+places
  • https://www.wolframalpha.com/input?i=round+5534023222112865%2F36893488147419103232+to+32+decimal+places

wadey avatar May 10 '23 00:05 wadey

Yes. Your package works correct but that is the problem with float64 origins. Because we all expect it to be not correct. So Rounding new(big.Int).SetString("0.00015") probably should be aware of the 0.000149999999^ number and when I say trunc it to 4 it may (optionally?) roung in the 5 or "last" place first.

oderwat avatar May 10 '23 11:05 oderwat

What I mean is that if you have a periodic '9'er non-rational input number, you may want to round it before truncating:

0,00015*10000=1,4999999999999999 = trunc(1) = 1,4 ... but is that really what we want?

oderwat avatar May 10 '23 11:05 oderwat