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

부동 소수점 숫자는 컴퓨터 하드웨어에서 밑(base)이 2인(이진) 소수로 표
현됩니다. 예를 들어, **십진** 소수 "0.625"는 값 6/10 + 2/100 + 5/1000
를 가지며, 같은 방식으로 **이진** 소수 "0.101" 는 값 1/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.1" 과 "0.10000000000000001" 및
"0.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이 아닙니다:

   >>> 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"의 문제
는 아래의 "표현 오류" 섹션에서 자세하게 설명합니다. 이진 부동 소수점의
작동 방식과 실무에서 흔히 발생하는 문제 유형에 대한 재미있는 요약은 부
동 소수점 문제의 예를 참조하세요. 또한 다른 일반적인 놀라움에 대한 더
완전한 설명은 부동 소수점의 위험을 참조하세요.

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

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

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

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

부동 소수점 연산을 많이 하는 사용자면 NumPy 패키지와 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 같은)와
데이터를 교환하는 데 유용합니다.

또 다른 유용한 도구는 "sum()" 함수입니다. 이 함수는 합산하는 동안 정밀
도 상실을 완화합니다. 값이 누계에 더해질 때 중간 반올림 단계에서 확장
정밀도를 사용합니다. 최종 합계에 영향을 주는 지점까지 에러가 누적되지
않아서 전체적인 정확도에 차이를 만들 수 있습니다:

   >>> 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)))   # 정확한 함계에 단일 반올림
   8.042173697819788e-13
   >>> math.fsum(arr)                   # 단일 반올림
   8.042173697819788e-13
   >>> sum(arr)                         # 확장 정밀도로 다중 반올림
   8.042178034628478e-13
   >>> total = 0.0
   >>> for x in arr:
   ...     total += x                   # 표준 정밀도로 다중 반올림
   ...
   >>> total                            # 단순 합산은 올바른 결과를 주지 않습니다!
   -0.0051575902860057365


15.1. 표현 오류
===============

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

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

왜 그럴까? 1/10은 이진 소수로 정확히 표현할 수 없습니다. 적어도 2000년
이후, 거의 모든 기계는 IEEE 754 이진 부동 소수점 산술을 사용하고, 거의
모든 플랫폼은 파이썬 float를 IEEE 754 binary64 "배정밀도"에 매핑합니다
. IEEE 754 binary64 값은 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

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

   7205759403792794 / 2 ** 56

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

   3602879701896397 / 2 ** 55

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

따라서 컴퓨터는 결코 1/10을 "보지" 못합니다: 볼 수 있는 것은 위에서 주
어진 정확한 분수, 얻을 수 있는 최선의 IEEE 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'

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