3.14 * 10 = 31.400000002

[ python ]

python에서 다음 연산의 결과는 어떤 값을 출력할까?

print(3.14*10)

결과는 놀랍게도 31.4가 아닌 31.400000000000002 이 나오게 된다.
이 결과를 두고 파이썬의 버그가 아니냐는 글도 있으나, 이는 버그가 아니다.
https://bugs.python.org/issue39749

위의 결과를 보고 실수의 표현에 따른 오차로 인해 발생하는 현상이라는 명확하고 짧은 정답을 말할 수 도 있으나,
파이썬을 이용하여 하나하나 추적해 가면서 제대로 이해를 해보고자 한다.

1. 컴퓨터에서의 실수 표현

CS 경험이 있거나, 혹은 특정 프로그래밍 언어에서 실수의 표현 방식에 대한 이해가 있다면 다음 그림이 익숙할 것이다.

위 그림은 single, double precision으로 실수를 표현하는 IEEE 754의 저장 방식을 보여주고 있다.
두 방식 모두 부호(sign), 지수(exponent), 가수(fraction)의 3가지 부분으로 구성되어 있으며 지수부와 가수부의 비트차이에 따라 표현할 수 있는 범위의 차이가 발생하게 된다.

2. 파이썬 코드를 통한 확인

먼저 위의 결과를 다음의 3가지 측면에서 살펴본다.

  1. 3.14와 10 각각을 IEEE 754 형태로 저장을 하고 두 값의 곱셈을 통한 결과값을 확인한다.
  2. 3.14*10 값 자체가 이진수 형태로 어떻게 표현되는지 확인한다.
  3. 1과 2는 같은 것임을 확인한다.

실수의 이진수 변환을 위해 함수를 직접 작성할 수 도 있지만, bitstring 모듈을 이용하면 보다 편리하게 결과를 확인할 수 있다.

2-1. 함수 구현
import bitstring

input_x = 3.14
input_y = 10
2-2. 함수 구현

bitstring을 사용하면 아래 ieee754처럼 주어진 실수 값을 특정 비트로 구성된 비트열로 변화시킬 수 있게 되며 해당 값은 다시 extract함수에서 bin을 통해 위 1의 부호, 지수, 가수부를 각각 추출할 수 있게 작성하였다.

def ieee754(val: float):
    return bitstring.BitArray(float=val, length=64)

def extract(val: float):
    ieee754_bin = ieee754(val)
    binary = ieee754_bin.bin
    return binary[0], binary[1:12], binary[12:] #sign(1bit) exponent(11bit) fraction(52bit) ieee 754 - 64bits
2-3. 함수의 결과값 보기

다음 코드를 통해 위 함수를 이용하는 과정을 확인할 수 있다.

#3.14
binary_input_x_sign, binary_input_x_exponent, binary_input_x_fraction = extract(input_x)
print("input x sign: ", binary_input_x_sign)
print("input x exponent: ", binary_input_x_exponent)
print("input x fraction: ", binary_input_x_fraction)
# 10
binary_input_y_sign, binary_input_y_exponent, binary_input_y_fraction = extract(input_y)
print("input y sign: ", binary_input_y_sign)
print("input y exponent: ", binary_input_y_exponent)
print("input y fraction: ", binary_input_y_fraction)

위 print문의 결과는 다음과 같다.

x는 3.14의 ieee 754 64bit 표현이며, y는 10에 대한 표현이다.
결과에서 처럼 3.14의 값이 y값처럼 깔금하지 않은 것을 볼 수 있으며, 이는 이진수 변환에 따른 결과일 뿐이다.

input x sign:  0
input x exponent:  10000000000
input x fraction:  1001000111101011100001010001111010111000010100011111
input y sign:  0
input y exponent:  10000000010
input y fraction:  0100000000000000000000000000000000000000000000000000
2-4. 두 ieee 754값의 곱셈

ieee754로 구성된 두 실수 값을 곱하는 과정은 세가지로 나눠진다.

  1. 부호부분은 두 값의 xor 연산을 통해서 결졍할 수 있다.
  2. 지수부는 2의 지수승으로 표현되어 있기에 exponet값을 더해주면 된다. 단, 이 값은 64비트일 경우 1023으로 bias되어 있기 때문에 두 수의 지수부를 더하고 -1023을 한 번만 해주어야 한다.
  3. 가수부는 일단 정규화 되어 있는 1.XXXX, 1.XXX의 값들을 서로 곱해야 한다. 말 그대로 이진수의 곱셈 연산을 수행해야 하며 이 경우에(64비트 배정도)일때는 가수부가 52비트로 이루어져 있으므로 곱셈 연산은 1.을 포함하여 총 106비트가 된다.그리고 이 값을 다시 iee754의 형태인 정규화 즉 정수부분을 1. 만드는 작업이 필요하며 이 과정에서 두 번째의 지수부 값이 변화가 될 수 있다. 또한 106비트의 값에서 53비트를 추출해야 하므로 어쩔 수 없이 버려지는 비트들이 존재하며 이 과정을 ieee 754에서는 round라고 부르며 다양한 규칙들이 존재하고 있다.

정리하면, 부호 부분은 xor, 지수부분은 더하고, 가수 부분은 곱한 후 정규화 및 지수부의 변경 이렇게 요약할 수 있다.

코드를 통해서 보시죠.

아래 코드는 3.14와 10의 연산을 위에서 말한 형태로 수행하는 것이다.
부호 부분의 연산은 수행하고 있지 않으며, 지수부의 연산을 위해 bitstring을 이용하여 자수부를 정수로 변환 후 더하고 나서 1023을 빼주었다.

# 3.14*10
new_exponent = bitstring.BitArray(bin='0' + binary_input_x_exponent).int + \
               bitstring.BitArray(bin='0' + binary_input_y_exponent).int
new_exponent -= 1023
print("new_exponent: ", bin(new_exponent), new_exponent)

결과는 다음과 같다.

new_exponent:  0b10000000011 1027

가수부의 곱셈을 위해 정수 형태로 연산후 소수점을 직접 옮기는 과정으로 구현하였으며, 특정 위치에 문자를 넣기 위한 함수 역시 작성하였다.

def multiplication(a, b):
    if len(str(b)) == 1:
        if b == 0:
            return 0
        else:
            return a
    j = len(str(b)) - 1
    listAB = []
    zeros = ""
    while j >= 0:
        listAB.append(str(int(str(a * int(str(b)[j])) + zeros)))
        zeros = zeros + "0"
        j = j - 1
        pass
    while len(listAB) > 1:
        listAB[0] = bin(int(listAB[0], 2) + int(listAB[1], 2))
        listAB.pop(1)
        pass
    return listAB[0][2:]

def insert_str(string, str_to_insert, index):
    return string[:index] + str_to_insert + string[index:]

곱한 값의 새로운 가수부

아래 코드처럼 1을 포함한 형태로 두 이진수의 곱셈 연산을 수행하고 이 값을 새로운 가수부로 정하고 값을 하나 백업하며, 소수점을 표시하기 위하여 .을 추가한다.

print("===========")
new_fraction = multiplication(int("1" + binary_input_x_fraction), int("1" + binary_input_y_fraction))
print("new_fraction:\n" , new_fraction)
new_fraction_old = new_fraction[:]

new_fraction = insert_str(new_fraction, '.', -104)
print('new_fraction with points\n', new_fraction)

아래와 같은 결과가 나오며 이 값이 3.14*10에서 사용될 새로운 지수부의 값이며 이 값은 다시 절삭의 과정을 필요로 한다.

new_fraction:
 111110110011001100110011001100110011001100110011001101100000000000000000000000000000000000000000000000000
new_fraction with points
 1.11110110011001100110011001100110011001100110011001101100000000000000000000000000000000000000000000000000
 
2-5. 정규화

3.14*10은 정규화 과정이 필요 없지만 다른 값들의 연산에는 이 과정이 필요할 수 있다.
아래 코드처럼 간단하게 정규화 처리를 했으며, 이 부분은 좀 더 잘 작성될 필요가 있을 것이다.
정규화 과정에서는 소수점이 변경이 되므로 지수부의 변화 역시 필요하게 된다.

# normilization
left_parts = new_fraction[:new_fraction.find('.')]
if left_parts == '10' or left_parts == '11':
    new_exponent += 1
    new_fraction = insert_str(new_fraction_old, '.', -105)

print("real new fraction:\n",new_fraction)
2-6. 결합

위에서 구한 값들을 모두 합쳐서 이진수로 만들고 이를 float으로 변화 시켜 그 결과를 확인한다. 부호+지수+가수부의 형태로 문자열을 만들고 해당 문자열을 이진수로 하여 bin과 float값을 bitstring을 이용하여 출력하며,
조건문에서는 반올림 처리를 하고 있으며 이는 53비트로 축소하는 과정이다.
즉, …..01, 이며 마지막에서 올림을 하여 1을 추가한다.

my_result = "0" + bin(new_exponent)[2:] + new_fraction[2:54]
if new_fraction[53] == '0' and new_fraction[54] == '1':
    my_result = "0" + bin(new_exponent)[2:] + new_fraction[2:54]
    my_result = my_result[:-1] + '1'

print("my result: {}".format(bitstring.BitArray(bin=my_result).bin))
print("my result: {}".format(bitstring.BitArray(bin=my_result).float))

직접 3.14와 10을 ieee 754로 변환하고 곱한후의 결과를 출력해보면 아래와 같고 31.4의 끝에 2가 자리하고 있는 것을 확인할 수 있다.

my result: 0100000000111111011001100110011001100110011001100110011001100111
my result: 31.400000000000002

원래 하고자 했던 3.14*10을 직접 입력하여 위의 결과와 동일한지 확인한다. 동일하다면 끝자락에 있는 2의 이유를 설명할 수 있을 것이다.

print("original:  {}".format(bitstring.BitArray(float=input_x*input_y, length=64).bin))
print("original:  {}".format(bitstring.BitArray(float=input_x*input_y, length=64).float))

다음 결과처럼 위의 결과와 동일한 값을 확인할 수 있으며 결국 컴퓨터 내부에서도 위와 같은 형태로 연산이 이루어지고 있음을 확인할 수 있다.

original:  0100000000111111011001100110011001100110011001100110011001100111
original:  31.400000000000002
3. 결론

위 처럼 간단하게 파이썬을 이용하여 ieee754 64bit로 실수 값들이 저장되는 것과 저장된 값들의 곱셈 연산의 과정을 살펴 보았다.
결국, 실수의 컴퓨터 내부 표현은 특정 언어에 국한되는 현상은 아니며, 이로 인해 발생하는 오차들을 피할 수 없는 것이다.
이를 해결하기 위한 다양한 모듈들이 있으며 이를 활용하며 오차 값들의 처리에 조금은 신경을 덜 쓸 수 도 있을 것이다.