부동소수점 산술: 문제와 한계
부동소수점 산술: 문제와 한계 (Floating-Point Arithmetic: Issues and Limitations)
컴퓨터 하드웨어는 소수점 이하 숫자를 2진수(밑 2) 분수로 표현해요. 예를 들어 10진 분수 0.625는 6/10 + 2/100 + 5/1000의 값을 갖고, 마찬가지로 2진 분수 0.101은 1/2 + 0/4 + 1/8의 값을 가져요. 두 분수는 값이 완전히 같고, 진짜 차이는 하나는 10진 표기로, 다른 하나는 2진 표기로 적었다는 것뿐이죠.
문제는 대부분의 10진 분수는 2진 분수로 정확하게는 표현할 수 없다는 데 있어요. 그래서 여러분이 입력한 10진 부동소수점 숫자는, 실제로 머신에 저장되는 2진 부동소수점 숫자로 '근사'될 수밖에 없습니다.
출처: Python 공식 문서
문제는 10진에서 먼저 이해하기
이 문제는 10진에서 먼저 보면 훨씬 쉽습니다. 분수 1/3을 생각해 보죠. 이걸 10진 분수로 근사하면:
0.3
또는 더 나아가:
0.33
아니면 더 나아가:
0.333
이런 식이에요. 숫자를 아무리 많이 쓰려고 해도 결과는 절대 정확히 1/3이 되지 않고, 1/3에 점점 더 가까운 근사값이 될 뿐입니다.
똑같이, 2진 자릿수를 아무리 많이 쓰려고 해도 10진 값 0.1은 2진 분수로 정확히 표현되지 않아요. 2진에서 1/10은 무한히 반복되는 분수입니다:
0.0001100110011001100110011001100110011001100110011...
유한한 비트 수에서 멈추면 그건 어쨌든 근사값이에요. 오늘날 대부분의 머신에서 float는 최상위 비트부터 시작해 처음 53비트를 분자로, 분모를 2의 거듭제곱으로 하는 2진 분수로 근사됩니다. 1/10의 경우 그 2진 분수는 3602879701896397 / 2 ** 55인데, 이는 1/10의 참값에 가깝지만 정확히 같지는 않아요.
왜 화면에서는 깔끔하게 보일까
많은 사용자가 이 근사 사실을 눈치채지 못하는데, 그 이유는 값이 화면에 표시되는 방식 때문이에요. Python은 머신이 저장한 2진 근사값의 참 10진 값을 그대로 출력하지 않고, 그 '어림' 값만 출력합니다. 대부분의 머신에서 0.1에 대해 저장된 2진 근사값의 참 10진 값을 출력한다면 이렇게 표시돼야 해요:
>>> 0.1
0.1000000000000000055511151231257827021181583404541015625
대부분의 사람에게 유용한 것보다 많은 자릿수죠. 그래서 Python은 반올림한 값을 대신 표시해 자릿수를 적당히 유지합니다:
>>> 1 / 10
0.1
한 가지만 기억할게요. 출력 결과가 1/10의 정확한 값처럼 보여도, 실제 저장된 값은 가장 가까운 표현 가능한 2진 분수라는 겁니다.
같은 근사값을 공유하는 숫자들
재미있게도, 가장 가까운 근사 2진 분수를 공유하는 10진 숫자들이 아주 많아요. 예를 들어 0.1, 0.10000000000000001, 0.1000000000000000055511151231257827021181583404541015625는 모두 3602879701896397 / 2 ** 55로 근사됩니다. 이 10진 값들은 전부 같은 근사값을 공유하므로, 어느 것을 표시해도 eval(repr(x)) == x라는 불변식은 그대로 지켜져요.
역사적으로 Python 프롬프트와 내장 repr() 함수는 유효숫자 17자리인 0.10000000000000001을 골랐습니다. Python 3.1부터는 (대부분의 시스템에서) 이 중 가장 짧은 것을 골라 그냥 0.1을 표시하게 됐어요.
여기서 짚고 넘어갈 점은, 이건 이진 부동소수점의 본질적인 특성이지, Python의 버그도 여러분 코드의 버그도 아니라는 겁니다. 여러분 하드웨어의 부동소수점 연산을 지원하는 모든 언어에서 같은 일을 겪게 돼요. 다만 어떤 언어는 기본적으로 또는 모든 출력 모드에서 그 차이를 표시하지 않을 뿐이죠.
더 보기 좋은 출력을 원한다면
출력을 더 쾌적하게 만들고 싶다면 문자열 포매팅을 써서 유효숫자 개수를 제한할 수 있어요:
>>> format(math.pi, '.12g') # give 12 significant digits
'3.14159265359'
>>> format(math.pi, '.2f') # give 2 digits after the point
'3.14'
>>> repr(math.pi)
'3.141592653589793'
중요한 건 이게 어떤 의미에서는 **환상(illusion)**이라는 점이에요. 실제 머신 값의 표시를 반올림하고 있을 뿐이죠.
환상은 또 다른 환상을 낳기도 해요. 예를 들어 0.1은 정확히 1/10이 아니므로, 0.1을 세 번 더해도 정확히 0.3이 되지 않을 수 있습니다:
>>> 0.1 + 0.1 + 0.1 == 0.3
False
또한 0.1이 정확한 1/10 값에 더 가까워질 수 없고 0.3도 정확한 3/10 값에 더 가까워질 수 없으므로, 미리 round()로 반올림해도 소용없어요:
>>> round(0.1, 1) + round(0.1, 1) + round(0.1, 1) == round(0.3, 1)
False
원하는 정확한 값에 더 가까워질 수는 없어도, 부정확한 값을 비교할 때는 math.isclose() 함수가 유용합니다:
>>> math.isclose(0.1 + 0.1 + 0.1, 0.3)
True
대안으로 round() 함수로 어림값끼리 비교할 수도 있어요:
>>> round(math.pi, ndigits=2) == round(22 / 7, ndigits=2)
True
이진 부동소수점 산술은 이처럼 놀라운 일이 많아요. "0.1"의 문제는 아래의 "표현 오차(Representation Error)" 절에서 정밀하게 설명할게요. 이진 부동소수점이 어떻게 동작하고 실제로 자주 마주치는 문제가 무엇인지 좋게 요약해 둔 글도 있으니 찾아보세요. 그 밖에 다른 흔한 반전들에 대한 더 완전한 이야기도 참고할 만합니다.
겁먹지 말 것
그 글 끝부분에 "쉬운 답은 없다"고 나오지만, 그렇다고 부동소수점을 지나치게 경계할 필요는 없어요. Python float 연산의 오차는 부동소수점 하드웨어에서 물려받은 것이고, 대부분의 머신에서 연산 한 번당 2**53분의 1 이하 수준입니다. 대부분의 작업에는 충분히 넘치는 정확도지만, 이게 10진 산술이 아니라는 점, 그리고 모든 float 연산마다 새로운 반올림 오차가 생길 수 있다는 점은 기억해 둬야 해요.
병적인(병리적인) 경우도 존재하지만, 부동소수점을 일상적으로 쓰는 경우라면 최종 결과 표시를 기대하는 10진 자릿수로 반올림하기만 하면 원하는 결과를 보는 경우가 대부분입니다. 보통 str()이면 충분하고, 더 세밀하게 조절하고 싶다면 str.format() 메서드의 포맷 지정자(format specifier)를 쓰세요.
정확한 10진 표현이 꼭 필요한 경우라면, 회계용이나 고정밀도 용도에 알맞은 10진 산술을 구현한 decimal 모듈을 써 보세요. 또 다른 형태의 정확한 산술은 유리수(분수) 기반의 산술을 구현한 fractions 모듈이 지원하는데, 덕분에 1/3 같은 숫자도 정확히 표현할 수 있어요.
부동소수점 연산을 많이 하는 사람이라면 NumPy 패키지와 SciPy 프로젝트가 제공하는 다른 수많은 수학·통계 패키지도 살펴볼 만합니다.
float의 정확한 값을 아는 도구
정말 드물지만 float의 정확한 값을 알고 싶은 그런 경우에 도움이 되는 도구를 Python이 제공해요. float.as_integer_ratio() 메서드는 float의 값을 분수로 표현합니다:
>>> x = 3.14159
>>> x.as_integer_ratio()
(3537115888337719, 1125899906842624)
그 비율은 정확하므로, 이걸 쓰면 원래 값을 손실 없이 다시 만들 수 있어요:
>>> x == 3537115888337719 / 1125899906842624
True
float.hex() 메서드는 float를 16진수(밑 16)로 표현하는데, 역시 여러분 컴퓨터가 저장한 정확한 값을 알려줍니다:
>>> x.hex()
'0x1.921f9f01b866ep+1'
이 정밀한 16진 표현을 쓰면 float 값을 정확히 재구성할 수 있어요:
>>> x == float.fromhex('0x1.921f9f01b866ep+1')
True
이 표현은 정확하므로, 서로 다른 Python 버전 간에 값을 안정적으로 옮길 때(플랫폼 독립성)나 같은 형식을 지원하는 다른 언어(Java, C99 등)와 데이터를 주고받을 때 유용합니다.
도움이 되는 또 다른 도구는 sum() 함수인데, 합산 중 정밀도 손실을 완화해 줘요. 값을 누적 합계에 더할 때 중간 반올림 단계에 확장 정밀도(extended precision)를 사용합니다. 덕분에 오차가 쌓여 최종 합계에 영향을 주는 지점까지 가지 않고 전체 정확도가 좋아질 수 있어요:
>>> 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 == 1.0
False
>>> sum([0.1] * 10) == 1.0
True
math.fsum()은 한 걸음 더 나아가, 값을 누적 합계에 더하면서 "잃어버린 자릿수"를 모두 추적해서 결과에 반올림이 한 번만 일어나게 해요. sum()보다 느리지만, 큰 크기의 입력들이 대부분 서로 상쇄되어 최종 합이 0 근처로 남는 드문 경우에는 더 정확합니다:
>>> arr = [-0.10430216751806065, -266310978.67179024, 143401161448607.16,
... -143401161400469.7, 266262841.31058735, -0.003244936839808227]
>>> float(sum(map(Fraction, arr))) # Exact summation with single rounding
8.042173697819788e-13
>>> math.fsum(arr) # Single rounding
8.042173697819788e-13
>>> sum(arr) # Multiple roundings in extended precision
8.042178034628478e-13
>>> total = 0.0
>>> for x in arr:
... total += x # Multiple roundings in standard precision
...
>>> total # Straight addition has no correct digits!
-0.0051575902860057365
표현 오차 (Representation Error)
이 절은 "0.1" 예시를 자세히 설명하면서, 이런 사례를 직접 정확히 분석하는 법을 보여줄게요. 이진 부동소수점 표현에 대한 기본적인 친숙함은 있다고 가정할게요.
표현 오차란, 일부(사실 대부분의) 10진 분수가 2진(밑 2) 분수로 정확히 표현되지 않는 사실을 가리켜요. Python(그리고 Perl, C, C++, Java, Fortran 등 많은 언어)이 기대한 정확한 10진 숫자를 표시하지 못하는 주된 이유가 바로 이것입니다.
왜 그럴까요? 1/10은 2진 분수로 정확히 표현할 수 없어요. 적어도 2000년 이후로 거의 모든 머신이 IEEE 754 2진 부동소수점 산술을 사용하고, 거의 모든 플랫폼이 Python float를 IEEE 754 binary64 "배정밀도(double precision)" 값에 대응시킵니다. IEEE 754 binary64 값은 정밀도 53비트를 갖고 있어서, 컴퓨터는 입력 시 0.1을 정확히 53비트를 가진 정수 J를 쓴 J / 2**N 형태의 가장 가까운 분수로 변환하려고 해요.
1 / 10 ~= J / (2**N)
을
J ~= 2**N / 10
으로 다시 쓰고, J가 정확히 53비트(즉 >= 2**52이고 < 2**53)임을 상기하면, N의 최적값은 56입니다:
>>> 2**52 <= 2**56 // 10 < 2**53
True
즉 J를 정확히 53비트로 만드는 N값은 56뿐이에요. J의 최적값은 그 몫을 반올림한 값이 됩니다:
>>> q, r = divmod(2**56, 10)
>>> r
6
나머지가 10의 절반보다 크므로, 반올림(올림)해서 최상의 근사를 얻습니다:
>>> q+1
7205759403792794
따라서 IEEE 754 배정밀도에서 1/10에 대한 최상 근사는 다음과 같아요:
7205759403792794 / 2 ** 56
분자와 분모를 둘 다 2로 나누면 분수가 이렇게 줄어듭니다:
3602879701896397 / 2 ** 55
우리가 반올림을 했으므로, 이 값은 실제로 1/10보다 아주 조금 더 큽니다. 반올림을 하지 않았다면 몫이 1/10보다 아주 조금 작았을 거예요. 하지만 어떤 경우에도 정확히 1/10이 될 수는 없습니다!
그래서 컴퓨터는 결코 1/10을 "보지" 못합니다. 컴퓨터가 보는 것은 위에서 구한 정확한 분수, 즉 얻을 수 있는 최상의 IEEE 754 배정밀도 근사뿐이에요:
>>> 0.1 * 2 ** 55
3602879701896397.0
그 분수에 10**55를 곱하면, 55자리 10진수로 값을 볼 수 있어요:
>>> 3602879701896397 * 10 ** 55 // 2 ** 55
1000000000000000055511151231257827021181583404541015625
즉 컴퓨터에 저장된 정확한 숫자는 10진 값 0.1000000000000000055511151231257827021181583404541015625와 같습니다. 전체 10진 값을 표시하는 대신, 많은 언어(옛 Python 버전 포함)는 결과를 유효숫자 17자리로 반올림해요:
>>> format(0.1, '.17f')
'0.10000000000000001'
fractions와 decimal 모듈은 이런 계산을 쉽게 만들어 줍니다:
>>> from decimal import Decimal
>>> from fractions import Fraction
>>> Fraction.from_float(0.1)
Fraction(3602879701896397, 36028797018963968)
>>> (0.1).as_integer_ratio()
(3602879701896397, 36028797018963968)
>>> Decimal.from_float(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
>>> format(Decimal.from_float(0.1), '.17')
'0.10000000000000001'
더 알아보기 (Learn more)
- Examples of Floating Point Problems — 이진 부동소수점의 동작과 실제 흔한 문제 요약
- The Perils of Floating Point — 그 밖의 흔한 반전들에 대한 더 완전한 설명
- SciPy — 수학·통계용 패키지 (NumPy 포함)
- Python 공식 문서: 부동소수점 산술