15. 부동 소수점 산술: 문제점 및 한계

부동 소수점 숫자는 컴퓨터 하드웨어에서 밑(base)이 2인(이진) 소수로 표현됩니다. 예를 들어, 소수

0.125

는 1/10 + 2/100 + 5/1000의 값을 가지며, 같은 방식으로 이진 소수

0.001

는 값 0/2 + 0/4 + 1/8을 가집니다. 이 두 소수는 같은 값을 가지며, 유일한 차이점은 첫 번째가 밑이 10인 분수 표기법으로 작성되었고 두 번째는 밑이 2라는 것입니다.

불행히도, 대부분의 십진 소수는 정확하게 이진 소수로 표현될 수 없습니다. 결과적으로, 일반적으로 입력하는 십진 부동 소수점 숫자가 실제로 기계에 저장될 때는 이진 부동 소수점 수로 근사 될 뿐입니다.

이 문제는 먼저 밑 10에서 따져보는 것이 이해하기 쉽습니다. 분수 1/3을 생각해봅시다. 이 값을 십진 소수로 근사할 수 있습니다:

0.3

또는, 더 정확하게,

0.33

또는, 더 정확하게,

0.333

등등. 아무리 많은 자릿수를 적어도 결과가 정확하게 1/3이 될 수 없지만, 점점 더 1/3에 가까운 근사치가 됩니다.

같은 방식으로, 아무리 많은 자릿수의 숫자를 사용해도, 십진수 0.1은 이진 소수로 정확하게 표현될 수 없습니다. 이진법에서, 1/10은 무한히 반복되는 소수입니다

0.0001100110011001100110011001100110011001100110011...

유한 한 비트 수에서 멈추면, 근삿값을 얻게 됩니다. 오늘날 대부분 기계에서, float는 이진 분수로 근사 되는 데, 최상위 비트로부터 시작하는 53비트를 분자로 사용하고, 2의 거듭제곱 수를 분모로 사용합니다. 1/10의 경우, 이진 분수는 3602879701896397 / 2 ** 55 인데, 실제 값 1/10과 거의 같지만 정확히 같지는 않습니다.

많은 사용자는 값이 표시되는 방식 때문에 근사를 인식하지 못합니다. 파이썬은 기계에 저장된 이진 근삿값의 진짜 십진 값에 대한 십진 근삿값을 인쇄할 뿐입니다. 대부분 기계에서, 만약 파이썬이 0.1로 저장된 이진 근삿값의 진짜 십진 값을 출력한다면 다음과 같이 표시해야 합니다

>>> 0.1
0.1000000000000000055511151231257827021181583404541015625

이것은 대부분 사람이 유용하다고 생각하는 것보다 많은 숫자이므로, 파이썬은 반올림된 값을 대신 표시하여 숫자를 다룰만하게 만듭니다

>>> 1 / 10
0.1

인쇄된 결과가 정확히 1/10인 것처럼 보여도, 실제 저장된 값은 가장 가까운 표현 가능한 이진 소수임을 기억하세요.

흥미롭게도, 가장 가까운 근사 이진 소수를 공유하는 여러 다른 십진수가 있습니다. 예를 들어, 0.10.100000000000000010.1000000000000000055511151231257827021181583404541015625 는 모두 3602879701896397 / 2 ** 55 로 근사 됩니다. 이 십진 값들이 모두 같은 근삿값을 공유하기 때문에 eval(repr(x)) == x 불변을 그대로 유지하면서 그중 하나를 표시할 수 있습니다.

역사적으로, 파이썬 프롬프트와 내장 repr() 함수는 유효 숫자 17개의 숫자인 0.10000000000000001 을 선택합니다. 파이썬 3.1부터, 이제 파이썬(대부분 시스템에서)이 가장 짧은 것을 선택할 수 있으며, 단순히 0.1 만 표시합니다.

이것이 이진 부동 소수점의 본질임에 주목하세요: 파이썬의 버그는 아니며, 여러분의 코드에 있는 버그도 아닙니다. 하드웨어의 부동 소수점 산술을 지원하는 모든 언어에서 같은 종류의 것을 볼 수 있습니다 (일부 언어는 기본적으로 혹은 모든 출력 모드에서 차이를 표시하지 않을 수 있지만).

좀 더 만족스러운 결과를 얻으려면, 문자열 포매팅을 사용하여 제한된 수의 유효 숫자를 생성할 수 있습니다:

>>> format(math.pi, '.12g')  # 12자리 유효숫자
'3.14159265359'

>>> format(math.pi, '.2f')   # 소수점 뒤로 2자리
'3.14'

>>> repr(math.pi)
'3.141592653589793'

이것이, 진정한 의미에서, 환영임을 깨닫는 것이 중요합니다: 여러분은 단순히 진짜 기곗값의 표시 를 반올림하고 있습니다.

하나의 환상은 다른 환상을 낳을 수 있습니다. 예를 들어, 0.1은 정확히 1/10이 아니므로, 0.1의 세 개를 합한 것 역시 정확히 0.3이 아닙니다:

>>> .1 + .1 + .1 == .3
False

또한, 0.1은 1/10의 정확한 값에 더 가까워질 수 없고, 0.3도 3/10의 정확한 값에 더 가까워질 수 없으므로, round() 함수로 미리 반올림하는 것은 도움이 되지 않습니다:

>>> round(.1, 1) + round(.1, 1) + round(.1, 1) == round(.3, 1)
False

숫자를 의도한 정확한 값에 더 가깝게 만들 수는 없지만, round() 함수는 사후 반올림에 유용하여 부정확한 값을 가진 결과를 서로 비교할 수 있게 합니다:

>>> round(.1 + .1 + .1, 10) == round(.3, 10)
True

이진 부동 소수점 산술은 이처럼 많은 놀라움을 안겨줍니다. "0.1"의 문제는 아래의 "표현 오류" 섹션에서 자세하게 설명합니다. 부동 소수점의 위험 은 다른 흔히 만나는 놀라움에 대해 더욱 완전한 설명을 제공합니다.

끝이 가까이 오면 말하듯이, "쉬운 답은 없습니다." 아직, 부동 소수점수를 지나치게 경계할 필요는 없습니다! 파이썬 float 연산의 에러는 부동 소수점 하드웨어에서 상속된 것이고, 대부분 기계에서는 연산당 2**53분의 1을 넘지 않는 규모입니다. 이것은 대부분 작업에서 필요한 수준 이상입니다. 하지만, 십진 산술이 아니며 모든 float 연산에 새로운 반올림 에러가 발생할 수 있다는 점을 명심해야 합니다.

병리학적 경우가 존재하지만, 무심히 부동 소수점 산술을 사용하는 대부분은, 단순히 최종 결과를 기대하는 자릿수로 반올림해서 표시하면 기대하는 결과를 보게 될 것입니다. 보통 str() 만으로도 충분하며, 더 세밀하게 제어하려면 포맷 문자열 문법 에서 str.format() 메서드의 포맷 지정자를 보세요.

정확한 십진 표현이 필요한 사용 사례의 경우, 회계 응용 프로그램 및 고정밀 응용 프로그램에 적합한 십진 산술을 구현하는 decimal 모듈을 사용해보세요.

정확한 산술의 또 다른 형태는 유리수를 기반으로 산술을 구현하는 fractions 모듈에 의해 지원됩니다 (따라서 1/3과 같은 숫자는 정확하게 나타낼 수 있습니다).

부동 소수점 연산을 많이 하는 사용자면 Numerical Python 패키지와 SciPy 프로젝트에서 제공하는 수학 및 통계 연산을 위한 다른 많은 패키지를 살펴봐야 합니다. <https://scipy.org> 를 보세요.

파이썬은 여러분이 float의 정확한 값을 진짜로 알아야 하는 드문 경우를 지원할 수 있는 도구들을 제공합니다. 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

표현이 정확하므로, 파이썬의 다른 버전 에 걸쳐 값을 신뢰성 있게 이식하고 (플랫폼 독립성), 같은 형식을 지원하는 다른 언어(자바나 C99 같은)와 데이터를 교환하는 데 유용합니다.

또 다른 유용한 도구는 math.fsum() 함수입니다. 이 함수는 합산 동안 정밀도 상실을 완화합니다. 누적 합계에 값이 더해지면서 "잃어버린 숫자들"을 추적합니다. 최종 합계에 영향을 주는 지점까지 에러가 누적되지 않아서 전체적인 정확도에 차이를 만들 수 있습니다:

>>> sum([0.1] * 10) == 1.0
False
>>> math.fsum([0.1] * 10) == 1.0
True

15.1. 표현 오류

이 섹션에서는 "0.1" 예제를 자세히 설명하고, 이러한 사례에 대한 정확한 분석을 여러분이 직접 수행하는 방법을 보여줍니다. 이진 부동 소수점 표현에 대한 기본 지식이 있다고 가정합니다.

표현 오류 (Representation error) 는 일부 (실제로는, 대부분의) 십진 소수가 이진(밑 2) 소수로 정확하게 표현될 수 없다는 사실을 나타냅니다. 이것이 파이썬(또는 펄, C, C++, 자바, 포트란 및 기타 여러 언어)이 종종 여러분이 기대하는 정확한 십진수를 표시하지 않는 주된 이유입니다.

왜 그럴까? 1/10은 이진 소수로 정확히 표현할 수 없습니다. 오늘날 (2000년 11월) 거의 모든 기계는 IEEE-754 부동 소수점 산술을 사용하고, 거의 모든 플랫폼은 파이썬 float를 IEEE-754 "배정밀도"에 매핑합니다. 754 배정밀도는 53비트의 정밀도가 포함되어 있어서, 입력 시 컴퓨터는 0.1을 J/2**N 형태의 가장 가까운 분수로 변환하려고 노력합니다. 여기서 J 는 정확히 53비트를 포함하는 정수입니다.:

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

즉, 56은 J 가 정확히 53비트가 되도록 만드는 N 의 유일한 값입니다. J 의 가능한 값 중 가장 좋은 것은 반올림한 몫입니다:

>>> q, r = divmod(2**56, 10)
>>> r
6

나머지가 10의 절반보다 크므로, 가장 가까운 근삿값은 올림 해서 얻어집니다:

>>> q+1
7205759403792794

따라서 754 배정밀도로 1/10 에 가장 가까운 근삿값은 다음과 같습니다:

7205759403792794 / 2 ** 56

분자와 분모를 둘로 나누면 다음과 같이 약분됩니다:

3602879701896397 / 2 ** 55

올림을 했기 때문에, 이것은 실제로 1/10 보다 약간 크다는 것에 유의하세요; 내림을 했다면, 몫이 1/10 보다 약간 작아졌을 것입니다. 그러나 어떤 경우에도 정확하게 1/10일 수는 없습니다!

따라서 컴퓨터는 결코 1/10을 "보지" 못합니다: 볼 수 있는 것은 위에서 주어진 정확한 분수, 얻을 수 있는 최선의 754 배정밀도 근삿값입니다:

>>> 0.1 * 2 ** 55
3602879701896397.0

그 분수에 10**55를 곱하면, 55개의 십진 숫자를 볼 수 있습니다.:

>>> 3602879701896397 * 10 ** 55 // 2 ** 55
1000000000000000055511151231257827021181583404541015625

이는 컴퓨터에 저장된 정확한 숫자가 십진수 0.1000000000000000055511151231257827021181583404541015625와 같음을 의미합니다. 전체 십진법 값을 표시하는 대신, 많은 언어(이전 버전의 파이썬 포함)는 결과를 17개의 유효숫자로 반올림합니다:

>>> format(0.1, '.17f')
'0.10000000000000001'

fractionsdecimal 모듈은 이 계산을 쉽게 만듭니다:

>>> 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'