실수형 값에서 발생하는 미세한 오차를 없애는 방법

실수끼리 연산 결과를 가지고 예상한 실수값과 비교했을 때, 예상과는 다른 결과가 나올 수도 있다.

이는 실수 값을 저장할 때 마지막 비트에서 생기는 아주 미세한 오차 때문이다.

예를 들어 아래처럼 0.1 과 0.2 를 더한 값이 0.3 은 다르다고 출력되는데, 사실 0.1 과 0.2 를 더한 값은 0.3000….0004 이기 때문에 그런 것이다.

func main() {
    a := 0.1
    var b float64
    b = 0.2
    var c float64 = 0.3
    fmt.Println(a+b == c)
    fmt.Println(a+b)
    fmt.Println(c)
}
bconfiden2@DESKTOP:~$ go run test.go
false
0.30000000000000004
0.3

실수값을 저장하기 위해서는 해당 타입이 가질 수 있는 비트를 지수 부분과 소수 부분으로 나눠서 저장한다.

이 때 모든 값들을 컴퓨터에서는 2진수로 저장하기 때문에 일반적으로 사람이 사용하는 소수를 아주 정확하게 표현하기 어렵다.

오차는 이렇게 비트 영역에서 발생하는 오차 때문에 발생하는 것이므로, 단순히 생각하면 적당히 작은 값을 threshold 로 미리 설정해놓은 뒤(0.00000001), 두 값의 차이가 해당 임계값보다 작으면 같은 값이라고 판정하게 할 수도 있다.

그러나 float32 와 float64 만 하더라도 표현 가능한 소수값의 범위가 많이 다르기 때문에, 이렇게 특정 값을 기준으로 삼는 것은 좋지 못하다.

대신, 마지막 비트에서의 차이 때문에 발생하는 것이므로 해당 비트만 조정해주는 방법이 있다.

예를 들어 아래처럼 논리적으로는 같아야 하는 0.3 에 대해서 비트를 확인해보면, 마지막 하나가 다른 것을 볼 수 있다.

func main() {
    a := 0.1
    b := 0.2
    c := 0.3

    fmt.Printf("%b\t", math.Float64bits(a+b))
    fmt.Printf("%0.17f\n", a+b)

    fmt.Printf("%b\t", math.Float64bits(c))
    fmt.Printf("%0.17f\n", c)

    fmt.Printf("%b\t", math.Float64bits(1.0 *c))
    fmt.Printf("%0.17f\n", 1.0 * c)
}
bconfiden2@DESKTOP:~$ go run test.go
11111111010011001100110011001100110011001100110011001100110100	0.30000000000000004
11111111010011001100110011001100110011001100110011001100110011	0.29999999999999999
11111111010011001100110011001100110011001100110011001100110011	0.29999999999999999

0.3 과 1 * 0.3 은 정확히 같은 비트를 갖지만(011), 0.1+0.2 의 결과는 맨 마지막 비트에 1이 추가됨으로써 100 이 되었다.

둘 다 정확한 십진수 0.3 은 아니지만 마지막 비트 하나를 기준으로 나뉘었기 때문에, 만약 두 값을 비교할 때 발생한 오차가 이 사이에 있던 것이라면, 두 수를 같은 수라고 볼 수도 있다는 뜻이다.

이는 float32 도 똑같이 확인 가능하기 때문에, 특정 임계값을 기준으로 사람이 나누는 것보다 권장된다.

이러한 기능을 위한 함수가 math 패키지에서 Nextafter 라는 함수로 제공된다.

Nextafter 은 float64 타입의 값 두개를 매개변수로 받아서, 첫번째 매개변수의 마지막 비트를 두번째 매개변수를 향해 조정한 값을 반환해준다.

예를 들어 위에서 0.1+0.2 의 결과가 ~100 이었기 때문에, 이를 첫번째 매개변수로 넣고 0.3 을 두번째 매개변수로 넣으면, ~100 에서 1비트만큼을 감소시킨 값을 반환한다.

따라서 이 결과값과 나머지 값을 비교할 수 있는 것이다.

func main() {
    fmt.Printf("           a+b: %b\n", math.Float64bits(a+b))
    fmt.Printf("             c: %b\n", math.Float64bits(c))
    fmt.Printf("Nextafter(a+b): %b\n", math.Float64bits(math.Nextafter(a+b, c)))
    fmt.Println(c == math.Nextafter(a+b, c))
}
bconfiden2@DESKTOP:~$ go run test.go
           a+b: 11111111010011001100110011001100110011001100110011001100110100
             c: 11111111010011001100110011001100110011001100110011001100110011
Nextafter(a+b): 11111111010011001100110011001100110011001100110011001100110011
true

이보다 더 정밀한 범위에서의 실수값을 표현하고 연산해야 하는 경우에는 math/big 패키지에 있는 Float 을 이용해야 한다.