파이썬에서 부동소수점 오차 해결하기

Reading time ~6 minutes

실수형 값을 다룰 때 흔히 마주칠 수 있는 부동소수점 오차 문제를 파이썬에서 어떻게 해결할 수 있는지 알아보았다.

이 글은 2017년에 작성했던 글을 다듬고 내용을 추가해 다시 작성한 글입니다. 본래 글은 이 문서로 리다이렉트됩니다.

부동소수점의 문제

float 자료형을 쓸 때는 항상 주의해야 한다. 특히 if 문으로 값을 확인할 때 이를 간과하면 아래처럼 찾기 힘든 버그가 발생한다.

>>> 0.1 * 3 == 0.3
False
>>> 1.2 - 0.1 == 1.1
False
>>> 0.1 * 0.1 == 0.01
False

그동안 위와 같은 일이 발생해도 그저 ‘부동소수점 때문에 그래’ 정도로만 알고 있었는데 그 이유를 좀 더 자세히 알아보고 파이썬에서 어떻게 해결할 수 있는지 알아보았다.

부동소수점에 대한 설명은 다른 자료들에서 잘 다루고 있으니 여기선 위키피디아 문서로 대체한다.

부동소수점의 표현 방식

컴퓨터에서 부동소수점 숫자들은 2진 분수로 표현되기에 꽤 많은 값은 정확히 표현될 수 없다. 그렇기에 사람이 이해하기 쉽게 입력하는 10진 부동소수점 숫자는 2진 부동소수점 숫자로 근사 된다. 예를 들어 10진수 0.1은 2진 분수로 정확하게 표현될 수 없고 0.0001100110011001100110...처럼 무한히 반복되기에 특정 비트에서 멈추고 근사값을 얻는다. 0.1의 경우는 3602879701896397 / 2 ** 55이며 0.1에 가깝지만, 정확히 동일하지는 않다.

>>> decimal.Decimal(3602879701896397 / 2 ** 55)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
>>> decimal.Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')

다만 이를 곧이곧대로 길게 표현한다면 꽤 많은 상황에서 성가실 것이기에 파이썬에선 이런 부동소수점 값을 표현할 때 정확히 표현하지 않고 10진수 근삿값만을 표시한다.

>>> 0.1
0.1000000000000000055511151231257827021181583404541015625

0.1에 대해 실제 십진수 값을 출력한다면 위와 같겠지만 파이썬에선 반올림된 값을 표시한다.

>>> 1 / 10
0.1

이런 부동소수점의 한계는 파이썬뿐만 아니라 부동소수점을 지원하는 거의 모든 언어에서 찾아볼 수 있다.

각 언어의 부동소수점 처리 방식을 볼 수 있는 0.30000000000000004.com이란 사이트도 있다.

해결책

decimal.Decimal, math.fsum(), round(), float.as_integer_ratio(), math.is_close() 함수 혹은 다른 방법을 통해서 실수를 방지할 수 있다. 이 중 가장 추천하는 방법은 decimal 표준 라이브러리를 사용한 방법이고 그 외에도 존재하는 관련 함수들을 소개한다.

decimal.Decimal

>>> import decimal
>>> decimal.Decimal('0.1') * 3 == decimal.Decimal('0.3')
True
>>> decimal.Decimal('0.3') + 2
Decimal('2.3')

decimal 모듈을 사용하면 위 문제를 가장 깔끔하게 해결할 수 있다. 파이썬 문서에서도 언급하듯 빠르고 정확한 부동 소수 산술을 지원하며 float 자료형을 그대로 사용할 때 보다 여러 이점이 있다고 말한다.

다만 아래와 같이 사용한다면 여전히 문제가 발생하고 이를 처리하는 건 decimal 모듈의 책임이 아니기에 사용에 주의해야 한다.

>>> decimal.Decimal(0.1 * 3)
Decimal('0.3000000000000000444089209850062616169452667236328125')

math.fsum()

math 모듈의 fsum() 함수를 이용해 두 개 이상의 부동소수점 합계와 관련된 반올림 오류 누적을 제거하여 정밀도 손실을 방지할 수 있다.

>>> sum([.1] * 10)
0.9999999999999999
>>> math.fsum([.1] * 10)
1.0

다만 fsum()은 중간중간 누적된 오류를 해결하기 위함이지 정확한 부동소수점 연산을 위한 게 아니다 보니 유명한 0.1 + 0.2처럼 항상 원하는 결과를 기대할 수는 없다.

>>> math.fsum([.1, .2])
0.30000000000000004

round()

빌트인 함수로 있는 round()로 반올림해 해결하는 방법도 있다.

>>> round(0.1 + 0.1 + 0.1, 10) == round(0.3, 10)
True
>>> 0.1 + 0.1 + 0.1 == 0.3
False
# 0.1 + 0.1 + 0.1은 0.3과 다르다.
>>> decimal.Decimal(0.1 + 0.1 + 0.1)
Decimal('0.3000000000000000444089209850062616169452667236328125')
>>> decimal.Decimal(0.3)
Decimal('0.299999999999999988897769753748434595763683319091796875')

당연하게도 사전 round()는 도움이 되지 않는데 0.1round(0.1, 1) 등과 같기에 각각을 반올림하고 더해주는건 아무 의미 없다.

>>> round(0.1, 1) + round(0.1, 1) + round(0.1, 1) == round(0.3, 1)
False

반올림 모드

우리는 반올림을 할 때 5부터는 올림으로 그 밑은 내림으로 한다고 알고 있지만 파이썬에선 여러 가지 반올림 모드 옵션을 제공한다. 기본적으로 설정된 모드 상으론 값에 따라 어떤건 0.5더라도 내림이 되기도 하고 올림이 되기도 한다.

>>> round(0.125, 2)
0.12
>>> round(0.135, 2)
0.14

이 반올림 모드엔 여러 가지가 있는데 기본적으로 ROUND_HALF_EVEN이라는 모드로 지정되어 있고 이는 짝수랑 가까워지는 방향으로 동작한다. 현재 적용된 반올림 방식이 무엇인지는 deciaml.getcontext()로 확인해 볼 수 있다.

>>> import decimal
>>> decimal.getcontext()
Context(prec=28, rounding=ROUND_HALF_EVEN, Emin=-999999, Emax=999999, ...)
  • ROUND_CEILING
  • ROUND_DOWN
  • ROUND_FLOOR
  • ROUND_HALF_DOWN
  • ROUND_HALF_UP
  • ROUND_UP
  • ROUND_05UP

위와 같이 8가지 방식이 존재하며 런타임에 이를 아래처럼 변경할 수 있다.

>>> ctx = decimal.getcontext()
>>> ctx.rounding = decimal.ROUND_UP  # 혹은 'ROUND_UP' 문자열도 사용 가능

float.as_integer_ratio()

파이썬 float 자료형에는 as_integer_ratio() 함수가 내장되어 있다.

>>> x = 3.141592
>>> x.as_integer_ratio()
(3537118140137533, 1125899906842624)
>>> x == 3537118140137533 / 1125899906842624
True

연산의 오차 보정용이라기 보다는 원래 값을 손실 없이 다시 만드는 데 유용하다.

math.is_close()

파이썬 3.5에서 새로 추가된 math 모듈의 함수다. isclose(a, b, rel_tol=1e-9, abs_tol=0.0)같은 형태이며 ab는 비교할 값들, rel_tol은 relative tolerance, abs_tol은 absolute tolerance다. decimal을 사용할 만큼 엄격한 상황이 아니라면 위에 나온 방법들보다 제일 간단하고 직관적이다.

다만 파이썬 3.5 이상부터 내장되어 있으며 그보다 하위 버전에서는 직접 is_close() 함수를 만들어 사용해야 한다.

# 간단한 버전
def is_close(a, b, rel_tol=1e-09, abs_tol=0.0):
    return abs(a-b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol)

기타

가장 쉽고 널리 사용되는 방법으로는 아마 abs(a - b) <= 1e-9 정도가 아닐까 싶다.

정리

float 자료형을 다룰 땐 항상 부동소수점 오차를 의식해야 한다. 위에서 나온 해결책 말고도 다양한 해결책이 있을 수 있겠지만 일단 회계나 금융의 엄격함이 요구된다면 decimal 모듈을 그렇지 않다면 마지막의 짧은 snippet이나 math.is_close(), round() 정도만 사용해도 크게 문제는 없으리라 생각한다.

참고

파이썬 중급자를 위한 책 추천

파이썬을 처음 접했을 땐 크롤링 스크립트 정도에 일단 돌아가긴 하는 코드에 만족했다. 그러다 어느 순간부터는 이게 맞는 건가 싶고 더 나은 코드를 짜고 싶은 욕심이 있었다. 시간이 지나며 글과 책을 읽고 프로젝트와 라이브러리를 만들고 다른 개발자...… Continue reading

2019년 회고, 이렇게 살았습니다

Published on December 31, 2019

파이썬으로 Reactivity 따라하기

Published on September 15, 2018