복사되었습니다.

Python에서 decimal의 precision 문제와 수의 표현 범위

Cover Image for Python에서 decimal의 precision 문제와 수의 표현 범위

Python의 기본 자료형인 float은 정밀한 수를 담거나 연산할 때 한계가 있다. 좀 더 정밀한 수를 다루기 위해서 decimal이라는 자료형을 사용하는데, 여전히 일부 연산에서는 precision 관련 오류가 발생한다. 어떤 문제가 있는지 살펴보자.

Decimal과 float의 차이

Python의 기본 자료형인 float은 2진수를 기반으로 동작한다. 기계가 사용하는 수의 체계를 따르는 것이다. 따라서, 사람이 이해하는 동작 방식과는 다소 차이가 발생한다. 이는 금융 분야와 같이 정확한 계산을 요구하는 곳에서 심각한 문제를 야기할 수 있다. 예를 들어, 다음과 같이 단순한 연산에도 미약하게 오차가 발생한다. 수많은 연산들을 연쇄적으로 처리해야 하는 인공지능 모델의 추론 과정이나, 은행의 복리 계산 등에서는 이러한 오차가 누적되어 더욱 크게 나타날 것이다.

>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False

Decimal은 10진수 기반의 자료형이기 떄문에 이러한 문제로부터 자유롭다. 문자 그대로 10진법을 따르기 때문에 인간에게 더욱 친숙하게 동작한다. Decimal에 대한 상세한 설명은 공식 문서에서 확인하도록 하고, 이 글에서는 앞선 float 예제와의 차이 정도만 느껴보자. 정밀한 값 계산을 위해서는 float이 아니라 decimal 자료형을 사용해야 한다는 것을 쉽게 알아차릴 수 있다.

>>> from decimal import Decimal
>>> Decimal('0.1') + Decimal('0.2')
Decimal('0.3')
>>> Decimal('0.1') + Decimal('0.2') == Decimal('0.3')
True

Decimal의 정밀도 한계와 연산 오류

Decimal 자료형은 10진수를 담을 때 문자열을 활용하기 때문에, 사실상 매우 긴 숫자도 담을 수 있다. IEEE 754 표준 부동소수점 방식의 표현 범위를 뛰어 넘는 매우 큰 숫자들끼리 사칙연산을 수행해도 별다른 오류가 발생하지 않는다. 연산 결과가 좀 이상하다는 느낌은 있다.

>>> d1 = Decimal('9' * 123 + '.' + '1' * 45)
>>> d1
Decimal('999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999.111111111111111111111111111111111111111111111')
>>> d2 = Decimal('9' * 12 + '.' + '2' * 345)
>>> d2
Decimal('999999999999.222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222')
>>> d1 + d2
Decimal('1.000000000000000000000000000E+123')
>>> d2 - d1
Decimal('-1.000000000000000000000000000E+123')
>>> d1 * d2
Decimal('9.999999999992222222222222222E+134')
>>> d1 / d2
Decimal('1.000000000000777777777778383E+111')

딱히 신경쓰지 않는다면 모르고 지나칠 수 있지만, 다음과 같이 반올림 작업을 수행할 때는 명백히 오류가 발생하는 것을 확인할 수 있다. 소수점 여섯째자리로 반올림하는 코드를 테스트해보자. d1을 반올림할 때는 실패하고, d2를 반올림할 때는 성공하는 이유는 무엇일까?

>>> from decimal import ROUND_HALF_UP
>>> d1.quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
decimal.InvalidOperation: [<class 'decimal.InvalidOperation'>]
>>> d2.quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
Decimal('999999999999.222222')

Decimal의 정밀도 설정

Decimal 사칙연산의 결과가 조금 어색하고, 반올림 연산에서는 아예 오류가 발생하는 이유는 decimal의 precision, 즉, 정밀도 때문이다. Decimal의 기본 정밀도는 28이며, context라는 객체를 통해 이를 확인할 수 있다.

>>> from decimal import getcontext
>>> context = getcontext()
>>> context
Context(prec=28, rounding=ROUND_HALF_EVEN, Emin=-999999, Emax=999999, capitals=1, clamp=0, flags=[Inexact, InvalidOperation, Rounded], traps=[InvalidOperation, DivisionByZero, Overflow])
>>> context.prec
28

정밀도가 28이라는 뜻은 무엇일까? 정수부와 소수부를 포함한 숫자의 자리수가 28 이하인 수에 대해서만 정확성을 보장해준다는 의미이다. 앞서 살펴본 예제에서 d1을 반올림할 때 오류가 발생하는 이유는 연산 결과의 자리수가 28자리를 넘었기 때문이다. 반올림 결과가 딱 28자리가 되도록 정수부 22자리, 소수부 6자리로 만들면 오류가 발생하지 않는 것을 확인할 수 있다. 반올림 결과가 딱 29자리가 되는 순간부터 오류가 발생하는 것도 확인 가능하다.

>>> Decimal('9' * 22 + '.' + '3'* 100).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
Decimal('9999999999999999999999.333333')
>>> Decimal('9' * 23 + '.' + '3'* 100).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
decimal.InvalidOperation: [<class 'decimal.InvalidOperation'>]
>>> Decimal('9' * 22 + '.' + '9'* 100).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
decimal.InvalidOperation: [<class 'decimal.InvalidOperation'>]

그런데, 앞서 언급했듯이 decimal은 IEEE 754 표준의 수의 범위를 넘어 사실상 무한에 가까운 크기의 수를 나타낼 수 있기 때문에, 이 28이라는 정밀도는 상대적으로 부족하다고 느낄 수 있다. 다음과 같이 context 객체를 조작해서 정밀도를 변경할 수 있다. 정밀도를 29로 변경할 경우, 이전과 달리 반올림 결과가 정확히 29자리를 넘을 때 오류가 발생하는 것을 확인할 수 있다.

>>> context = getcontext()
>>> context.prec = 29
>>> Decimal('9' * 22 + '.' + '3'* 100).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
Decimal('9999999999999999999999.333333')
>>> Decimal('9' * 23 + '.' + '3'* 100).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
Decimal('99999999999999999999999.333333')
>>> Decimal('9' * 24 + '.' + '3'* 100).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
decimal.InvalidOperation: [<class 'decimal.InvalidOperation'>]

그렇다면, 이 정밀도를 사실상 무한까지 늘릴 수 있을까? 그렇다면 좋겠지만, 한계가 존재하긴 한다. 다음과 같이 MAX_PREC이라는 상수를 통해 최대로 설정할 수 있는 정밀도의 크기를 알 수 있다. 무한은 아니긴 하지만, 사실상 이 최대 정밀도라는 것은 수의 값 자체가 아니라 자리수 제한을 의미하기 때문에 인간이 상상하기 힘든 크기의 수를 다룰 수 있다는 것은 명백하다.

>>> from decimal import MAX_PREC
>>> MAX_PREC
999999999999999999

Decimal이 표현 가능한 유한한 수의 범위

Context 객체를 살펴보면 Emin=-999999, Emax=999999라는 부분이 있다. 이것은 decimal이 표현할 수 있는 수의 범위와 관련이 있다. 정확히는 부동 소수점 표현에서 지수부에 들어갈 수 있는 최소값과 최대값을 의미한다. 아래 코드처럼 context에 설정된 Emax 값보다 큰 수를 지수부에 할당하면 overflow 오류가 발생한다.

>>> context = getcontext()
>>> context.Emax
999999
>>> d = context.create_decimal('9e' + str(context.Emax))
>>> d
Decimal('9E+999999')
>>> context.create_decimal('9e'+str(context.Emax) + '1')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
decimal.Overflow: [<class 'decimal.Overflow'>]

그런데, 예상과 달리 context의 Emin 값보다 작은 수를 지수부에 넣어도 별다른 오류가 발생하지 않는다. 이는 기본적으로 context의 traps에서 underflow 오류를 무시하도록 설정되어 있기 때문이다. Context 객체의 traps을 수정하여 underflow가 발생하도록 만들 수 있다.

>>> from decimal import Underflow
>>> context.traps[Underflow]
False
>>> d = context.create_decimal('9e' + str(context.Emin) + '1')
>>> d
Decimal('0E-1000026')
>>> context.traps[Underflow] = True
>>> d = context.create_decimal('9e' + str(context.Emin) + '1')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
decimal.Underflow: [<class 'decimal.Underflow'>]

그렇다면, 정밀도와 마찬가지로 이 지수값의 범위도 변경할 수 있을까? 답은 당연히 yes다. Context 객체의 EmaxEmin 값을 변경하면 된다.

>>> my_context = Context(Emax=99, Emin=-99)
>>> my_context.traps[Underflow] = True
>>> my_context.create_decimal('9e' + str(my_emax))
Decimal('9E+99')
>>> my_context.create_decimal('9e' + str(my_emax) + '1')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
decimal.Overflow: [<class 'decimal.Overflow'>]
>>> my_context.create_decimal('9e' + str(my_emin))
Decimal('9E-99')
>>> my_context.create_decimal('9e' + str(my_emin) + '1')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
decimal.Underflow: [<class 'decimal.Underflow'>]

정밀도의 경우와 유사하게 EmaxEmin 값을 변경할 때에도 최대 변경 가능한 범위가 제한되어 있다. MAX_EMAX보다 큰 Emax 값을 사용하거나, MIN_EMIN보다 작은 Emin 값을 사용할 경우 유효한 범위를 벗어났다는 ValueError를 만나게 된다.

>>> from decimal import MAX_EMAX, MIN_EMIN
>>> Context(Emax=MAX_EMAX + 1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: valid range for Emax is [0, MAX_EMAX]
>>> Context(Emin=MIN_EMIN - 1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: valid range for Emin is [MIN_EMIN, 0]

MAX_EMAX 값과 MIN_EMIN 값은 C언어의 데이터 타입으로 변환할 때의 수 표현 범위를 고려한 것으로 파악된다. 순수 python 버전 구현체인 _pydecimal을 사용할 경우 수의 표현 범위에 제한은 없다. 그런데, 기본적으로 C언어 버전 구현체인 decimal을 사용하므로 지나치게 큰 값을 입력하면 ValueError가 아니라 OverflowError가 발생한다는 특징이 있다.

>>> Context(Emax=MAX_EMAX * 9)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: valid range for Emax is [0, MAX_EMAX]
>>> Context(Emax=MAX_EMAX * 10)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
OverflowError: Python int too large to convert to C ssize_t
>>> Context(Emin=MIN_EMIN * 9)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: valid range for Emin is [MIN_EMIN, 0]
>>> Context(Emin=MIN_EMIN * 10)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
OverflowError: Python int too large to convert to C ssize_t

순수 python 버전의 구현체인 _pydecimal을 사용하면 수의 표현 범위 제약이 사라지기 때문에, 다음과 같이 ValueError 또는 OverflowError가 발생하지 않는다.

>>> from _pydecimal import Context, MAX_EMAX, MIN_EMIN
>>> MAX_EMAX, MIN_EMIN
(999999999999999999, -999999999999999999)
>>> Context(Emax=MAX_EMAX * 10)
Context(prec=28, rounding=ROUND_HALF_EVEN, Emin=-999999, Emax=9999999999999999990, capitals=1, clamp=0, flags=[], traps=[DivisionByZero, Overflow, InvalidOperation])
>>> Context(Emin=MIN_EMIN * 10)
Context(prec=28, rounding=ROUND_HALF_EVEN, Emin=-9999999999999999990, Emax=999999, capitals=1, clamp=0, flags=[], traps=[DivisionByZero, Overflow, InvalidOperation])

그렇다면, 유한한 수 중에서 decimal이 표현할 수 있는 최대값은 얼마일까? Context 객체에 설정된 Emax와 정밀도에 따라 달라진다. 가수부에 점을 제외하고 prec에 설정된 자리수만큼의 9를 채우고, 지수부에 Emax 값을 채우면 해당 context에서 표현할 수 있는 최대값이 된다. Decimal 객체에 정의된 is_finitenext_plus 메서드를 통해 쉽게 이해할 수 있다.

>>> context = getcontext()
>>> context.prec = 3
>>> context.Emax = 99
>>> d = Decimal('9.' + '9' * (context.prec - 1) + 'e' + str(context.Emax))
>>> d
Decimal('9.99E+99')
>>> d.is_finite()
True
>>> d.next_plus()
Decimal('Infinity')

유한한 수의 최대값을 판단하는 핵심은 반올림 결과 가수부가 prec 값에 설정된 자리수를 넘는지 여부이다. 아래 코드에서 간단한 덧셈 연산 결과를 통해 이해해보자. 첫 번째 연산은 9.994E+99prec에 설정된 자리수를 고려하여 반올림했을 때 9.99E+99가 되므로 문제가 없다. 그러나, 두 번째 연산은 9.995E+99를 반올림한 결과 가수부가 10.00이 되어 자리수가 4가 된다. 이는 prec 값인 3보다 크기 때문에 Overflow 오류가 발생한다. 지수부 관점에서 생각해보면, 반올림 결과를 부동소수점 표현으로 변경했을 때 지수부가 Emax보다 큰 값으로 올라가기 때문에 Overflow 오류가 발생한다고 볼 수도 있다.

>>> d + Decimal('0.004E+99')
Decimal('9.99E+99')
>>> d + Decimal('0.005E+99')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
decimal.Overflow: [<class 'decimal.Overflow'>]

Decimal 객체를 생성할 때 주의할 점

앞선 예제들을 보면 Decimal 클래스를 직접 사용하여 인스턴스화하는 경우도 있고, context 객체의 create_decimal 메서드를 사용하는 경우도 있다. 되도록이면 혼동을 피하기 위해 명시적으로 context에 prec, Emax, Emin 값들을 설정해두고 create_decimal 메서드를 사용하도록 통일하는 것을 권장한다. Global context와 local context 등 다양한 context가 혼재될 수 있기 때문에, 코드가 복잡해지면 의도한 수의 범위를 벗어나는 경우가 생긴다.

예를 들어, 다음과 같이 getcontext를 통해 default context 객체를 가져온 후 값을 변경하더라도 decimal 객체를 생성하는 방식에 따라 Overflow 오류 발생 여부가 달라진다. 따라서, context 객체를 통해 decimal 객체를 생성하는 습관을 들이자.

>>> context = getcontext()
>>> context.prec = 3
>>> context.Emax = 99
>>> context.prec, context.Emax
(3, 99)
>>> Decimal('1E+100')
Decimal('1E+100')
>>> context.create_decimal('1E+100')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
decimal.Overflow: [<class 'decimal.Overflow'>]

Comments

    More Posts

    Validation 코드는 어디에 작성해야 할까? - The 3 types of validation logics

    개발자들은 다양한 validation 코드들을 작성하는데 많은 시간을 소비한다. 이곳저곳에 덕지덕지 붙어있는 validation 코드들을 바라보면, 과연 이 코드들이 여기에 있어도 되는 것인지 의문이 생긴다. 다양한 종류의 validation 코드들을 어디에 작성해야 하는지 정리해보자.

    Python으로 kubernetes 환경에 app 배포하기 - Kubernetes deployment with python

    Kubectl 명령어를 통해 kubernetes 환경에 다양한 구성요소들을 배포할 수 있지만, python으로 템플릿 코드를 짜두면 개발자들도 편리하게 배포를 자동화할 수 있다. Jinja2와 kubernetes 패키지를 통해 사용자 요청에 따라 kubernetes 환경에 pod들을 띄우는 기능을 만들어보자.

    Domain entity와 ORM을 따로 구분해서 정의하는 방법 - Domain entity with imperative ORM

    Domain entity를 정의할 때 declarative 방식의 ORM을 사용하면 domain model에 DB 관련 정보가 노출되는 문제가 있다. Python sqlalchemy를 활용하여 imperative mapping style로 domain entity로부터 ORM을 분리해보자.

    Font Size