드론 관제실의 수치해석 기초: NumPy로 근사해 풀기
·
강의 개요
1
공식이 없으면 한 걸음씩: 수치해석이란
2
0.1 + 0.2가 0.3이 아닌 날: 컴퓨터 속 실수
3
근사의 대가: 테일러 급수와 절단 오차
4
구간을 반으로: 이분법과 가위치법
5
접선을 타고 내려가기: 뉴턴-랩슨과 할선법
6
가장 멀리 나는 속도: 1차원 최적화
7
배송 허브 세 곳의 물량 맞추기: 가우스 소거와 피벗
8
지도가 커지면: LU 분해, 반복법, 조건수
9
센서 기록에 선 긋기: 최소제곱 회귀
10
비어 있는 시각 채우기: 보간과 스플라인
11
얼마나 날았나: 수치 적분
12
처음 문제로 돌아가기: 미분방정식의 수치해
운항 일지
#02 · 정산 합계
문제
어제 배송 2,000건의 요금을 정산 서버와 장부 프로그램이 각각 더했는데, 소수점 아래 끝자리가 서로 다르다. 정산 팀은 어느 쪽이 맞는지, 이 숫자를 장부에 그대로 적어도 되는지 묻는다.
목표
컴퓨터가 실수를 저장하는 방식(float64)을 이해하고, 반올림 오차가 생기고 쌓이는 곳을 찾아, 정산 합계를 정확하게 내는 방법을 정한다.
하늘상자 새벽 배송 관제실
서윤의 둘째 날 아침, 민재 팀장이 출력한 정산표 두 장을 책상에 내려놓습니다. "어제 요금 합계예요. 정산 서버는 10,902,373.799999986원, 장부 프로그램은 10,902,373.799999999원. 둘 다 파이썬으로 짠 건데 왜 다르죠? 감사 때 이런 숫자가 보이면 곤란해요."
요금은 거리별 기본 요금에서 0.1원 단위 할인을 뺀 값입니다. 진짜 합계는 소수점 아래 한 자리로 딱 떨어져야 합니다. 서윤은 파이썬을 열어 가장 간단한 덧셈을 쳐 봅니다. 0.1 + 0.2의 답이 0.30000000000000004로 나옵니다. "파이썬 버그 아니에요?"
오 박사가 고개를 젓습니다. "버그가 아니라 약속이야. 컴퓨터 속 실수는 자릿수가 정해진 이진수거든. 어제는 기울기를 h초 동안 그대로 두느라 생긴 오차를 봤지? 오늘은 숫자를 담는 그릇에서 생기는 오차야. 어디서 얼마나 틀리는지 알면 피할 수 있어."
이번 교시에는 1교시 오차 표의 마지막 줄, 반올림 오차(round-off error) 를 다룹니다. 0.1이 왜 정확히 저장되지 않는지, 저장된 수들 사이의 간격이 얼마인지 재 보고, 오차가 쌓이거나 커지는 세 가지 상황(누적, 큰 수 + 작은 수, 자릿수 상쇄)과 그 처방을 익힙니다.
십진법에서도 정확히 적을 수 없는 수가 있습니다. 은 3이 끝없이 이어집니다. 십진 소수는 의 합이라서, 분모가 2와 5로만 이루어진 분수만 끝이 납니다. 3은 10을 나누지 못하니 끝나지 않습니다.
컴퓨터는 이진법(binary) 을 씁니다. 이진 소수의 자리는 입니다.
이진 소수는 분모가 2로만 이루어진 분수만 끝납니다. , 은 끝나지만, 은 분모에 5가 있어서 끝나지 않습니다.
손으로 확인해 봅시다. 소수를 이진수로 바꾸려면 2를 곱하고, 정수 부분(0 또는 1)을 다음 자리로 적고, 소수 부분만 남기기를 반복합니다.
| 단계 | 2를 곱하면 | 적는 자리 | 남는 소수 |
|---|---|---|---|
| 1 | 0.1 × 2 = 0.2 | 0 | 0.2 |
| 2 | 0.2 × 2 = 0.4 | 0 | 0.4 |
| 3 | 0.4 × 2 = 0.8 | 0 | 0.8 |
| 4 | 0.8 × 2 = 1.6 | 1 | 0.6 |
| 5 | 0.6 × 2 = 1.2 | 1 | 0.2 |
| 6 | 0.2 × 2 = 0.4 | 0 | 0.4 (2단계로 돌아옴) |
5단계에서 남는 소수가 0.2로 돌아왔으니, 그 뒤로는 0011이 끝없이 되풀이됩니다. 입니다. 반올림이 섞이지 않게 분수(Fraction)로 같은 계산을 해 봅니다.
>>> from fractions import Fraction
>>> x = Fraction(1, 10)
>>> digits = ""
>>> for _ in range(24):
... x = x * 2
... digits += str(int(x))
... x = x - int(x)
>>> digits
'000110011001100110011001'컴퓨터는 끝없는 자릿수를 담을 수 없으니 어딘가에서 잘라 가장 가까운 값으로 반올림해 저장합니다. 저장된 값을 끝까지 보려면 decimal.Decimal에 float를 그대로 넣습니다.
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
>>> from decimal import Decimal
>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
>>> Decimal(0.5), Decimal(0.375)
(Decimal('0.5'), Decimal('0.375'))저장된 0.1은 참값보다 쯤 큽니다. 0.2도 0.3도 모두 조금씩 어긋나 있고, 0.1과 0.2를 더한 값은 하필 0.3에 가장 가까운 float가 아니라 그 바로 다음 float에 떨어집니다. 그래서 ==가 False입니다.
그런데 print(0.1)은 왜 0.1로 보일까요? 파이썬은 float를 보여 줄 때 같은 float로 되돌아가는 가장 짧은 십진수를 고릅니다. 0.1이라고 다시 입력하면 같은 float가 되니 0.1로 보여 줄 뿐, 저장된 값은 Decimal(0.1)이 보여 준 그 긴 수입니다.
과학 시간에 아보가드로 수를 처럼 적었습니다. 부호, 유효숫자(6.02), 지수(23) 세 부분입니다. 컴퓨터의 실수도 같은 꼴을 이진법으로 씁니다. 이런 표현을 부동소수점(floating point) 이라고 합니다. 소수점의 자리가 지수에 따라 떠다닌다는 뜻입니다.
파이썬의 float와 NumPy의 기본 실수 np.float64는 IEEE 754 표준의 배정밀도(double precision) 로, 64비트를 셋으로 나눠 씁니다.
| 부분 | 비트 수 | 뜻 | 0.1에서는 |
|---|---|---|---|
| 부호(sign) | 1 | 0이면 양수, 1이면 음수 | 0 |
| 지수(exponent) | 11 | 2를 몇 번 곱하나. 1023을 빼서 음수 지수도 담는다 | 1019 → |
| 가수(mantissa, fraction) | 52 | 소수점 아래 이진 자릿수. 맨 앞의 1은 늘 1이라 저장하지 않는다 | 1001 1001 … 1010 |
손으로 맞춰 봅시다. 이니 지수 칸에는 가 들어갑니다. 1.6의 소수 부분 0.6을 이진수로 바꾸면 2-1의 표처럼 1001 1001 …이 되풀이되고, 52자리에서 잘리며 마지막이 …1010으로 올림됩니다. 저장된 0.1이 참값보다 조금 큰 이유입니다.
>>> b = f"{np.float64(0.1).view(np.uint64):064b}"
>>> b[0], b[1:12], b[12:]
('0', '01111111011', '1001100110011001100110011001100110011001100110011010')
>>> int(b[1:12], 2), int(b[1:12], 2) - 1023
(1019, -4)
>>> (0.1).hex()
'0x1.999999999999ap-4'view(np.uint64)는 같은 64비트를 정수로 읽게 하는 방법이고, :064b는 그 정수를 0과 1 64자리로 적습니다. hex()는 가수를 16진수로 보여 줍니다. 1.999…9a의 끝 a가 올림된 자리입니다.
가수 52비트에 숨은 1비트를 더하면 유효숫자가 이진수로 53자리입니다. 이니 십진수로는 15~16자리를 믿을 수 있습니다. np.finfo가 이런 값을 모아 알려 줍니다.
>>> fi = np.finfo(np.float64)
>>> fi.bits, fi.nmant, fi.nexp
(64, 52, 11)
>>> fi.precision
15
>>> fi.max, fi.tiny
(np.float64(1.7976931348623157e+308), np.float64(2.2250738585072014e-308))
>>> np.finfo(np.float32).precision, np.finfo(np.float32).eps
(6, np.float32(1.1920929e-07))| 값 | 뜻 | 넘으면 |
|---|---|---|
fi.max ≈ 1.8e308 | 담을 수 있는 가장 큰 수 | inf (넘침, overflow) |
fi.tiny ≈ 2.2e-308 | 정밀도를 다 지키는 가장 작은 양수 | 점점 정밀도를 잃다가 0 (아래 넘침, underflow) |
fi.precision = 15 | 늘 믿을 수 있는 십진 자릿수 |
32비트 실수 np.float32(단정밀도)는 가수가 23비트라 믿을 자릿수가 6자리 남짓입니다. 메모리를 아끼는 GPU나 드론에 들어가는 작은 칩이 주로 씁니다. 다음 교시의 드론 칩 이야기에서 다시 만납니다.
오 박사의 한마디
실수 하나에 유효숫자 열여섯 자리. 측정값은 대개 서너 자리밖에 안 맞으니 넉넉해 보이지? 문제는 한 번이 아니라 수백만 번 계산한다는 거야. 자리를 어디서 잃는지 알아야 해.
float64로 담을 수 있는 수는 유한합니다. 수직선 위에 점으로 찍으면 촘촘하지만 띄엄띄엄하고, 그 간격도 고르지 않습니다. 1과 2 사이에는 개의 float가 같은 간격 로 놓입니다. 2와 4 사이에도 똑같이 개가 있는데, 구간이 두 배로 넓으니 간격도 두 배입니다. 지수가 하나 오를 때마다 간격이 두 배가 됩니다.
1 바로 다음 float까지의 간격을 기계 엡실론(machine epsilon) 라고 합니다.
어떤 수 바로 다음 float까지의 간격을 ULP(unit in the last place, 마지막 자리 한 칸)라고 부릅니다. 대략 , 즉 간격은 수의 크기에 비례합니다. 반올림은 가장 가까운 float로 가니 저장할 때 생기는 상대 오차는 늘 이하입니다.
>>> eps = np.finfo(float).eps
>>> eps == 2.0**-52
np.True_
>>> 1 + eps > 1, 1 + eps / 2 > 1
(np.True_, np.False_)
>>> np.nextafter(1.0, 2.0)
np.float64(1.0000000000000002)
>>> for x in [0.1, 1.0, 1000.0, 1e8, 1e16]:
... print(x, np.spacing(x), np.spacing(x) / x)
0.1 1.3877787807814457e-17 1.3877787807814457e-16
1.0 2.220446049250313e-16 2.220446049250313e-16
1000.0 1.1368683772161603e-13 1.1368683772161603e-16
100000000.0 1.4901161193847656e-08 1.4901161193847657e-16
1e+16 2.0 2e-16np.spacing(x)는 에서 다음 float까지의 간격, np.nextafter(x, y)는 에서 쪽으로 바로 다음 float입니다. 1 + eps / 2는 1과 그다음 float의 딱 가운데라서, 반올림 규칙(짝수 쪽으로)에 따라 1로 돌아갑니다.
표의 마지막 열이 상대 간격입니다. 수가 0.1이든 1억이든 늘 언저리입니다. 반면 간격 자체는 크게 달라서, 1억(1e8)원 근처에서는 1.5e-8원 간격이라 충분히 촘촘하지만, 근처에서는 간격이 2입니다. 1을 더해도 표현할 곳이 없습니다.
>>> 1e16 + 1 == 1e16
True
>>> 2.0**53, 2.0**53 + 1
(9007199254740992.0, 9007199254740992.0)float64는 까지의 정수만 빠짐없이 담습니다. 자바스크립트의 숫자도 float64라서, Number.MAX_SAFE_INTEGER가 바로 이 값에서 1을 뺀 수입니다. 주문 번호처럼 큰 정수 ID를 JSON으로 주고받을 때 끝자리가 바뀌는 사고가 여기서 납니다.
아래 실습대의 '간격 확대경'에서 x를 1e-10부터 1e20까지 옮겨 보세요. 위쪽 막대는 x의 크기(로그 눈금)이고, 호박색 점에서 퍼지는 렌즈가 그 둘레를 확대합니다. 아래 수직선의 남색 눈금이 실제로 존재하는 float64이고, 굵은 눈금이 저장된 x입니다. x가 정확히 저장되지 않을 때는 회색 점이 참값의 자리입니다. 그래프 밑의 띠는 x를 이루는 64비트로, 호박색이 부호, 청록이 지수, 남색이 가수입니다. 조작판의 '보기'에서 '0.1을 n번 더하기'를 누르면 2-4절에서 다룰 누적 실험으로 바뀝니다.
x = (−1)^0 × 1.6000000000000000888 × 2^(1019 − 1023)
x = 0.1
저장된 값 ≈ 0.10000000000000000555
위 간격 u (ULP) = 1.39e-17
상대 간격 u / x = 1.39e-16
x = 0.1: 이진수로 끝나지 않아 정확히 저장할 수 없습니다. 회색 점(참값)에서 가장 가까운 남색 눈금이 저장되고, 그 차이는 5.55e-18입니다.
NumPy로 확인하기
실습대에서 값을 정할 때마다, 서버의 NumPy가 같은 숫자로 계산한 결과입니다.
>>> import numpy as np
>>> from decimal import Decimal
>>> x = 1e-1
# 실제로 저장된 값 (끝까지 정확하게)
>>> Decimal(x)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
# x 바로 위 float까지의 간격(ULP)과 양옆 이웃
>>> np.spacing(x)
np.float64(1.3877787807814457e-17)
>>> np.nextafter(x, 0), np.nextafter(x, np.inf)
(np.float64(0.09999999999999999), np.float64(0.10000000000000002))
# 상대 간격은 x의 크기와 상관없이 늘 1e-16 안팎
>>> np.spacing(x) / x
np.float64(1.3877787807814457e-16)
>>> x + 1 == x
False
# 기계 엡실론: 1.0 바로 위 float까지의 간격
>>> np.finfo(float).eps
np.float64(2.220446049250313e-16)덧셈 한 번의 결과도 float에 담아야 하니 반올림됩니다. 컴퓨터가 계산한 덧셈 는 참값과 이렇게 다릅니다.
한 번은 아주 작습니다. 그런데 합계를 낼 때는 덧셈을 수천 번 이어 하고, 매번 생긴 가 고스란히 다음 덧셈으로 넘어갑니다. 게다가 합이 커질수록 그 크기의 ULP도 커지니 덧셈 한 번의 오차도 점점 커집니다. 0.1을 1,000번 더해 봅시다.
>>> s = 0.0
>>> for _ in range(1000):
... s += 0.1
>>> s
99.9999999999986
>>> import math
>>> math.fsum([0.1] * 1000)
100.0
>>> np.sum(np.full(1000, 0.1))
np.float64(100.00000000000001)차례로 더한 s는 100에서 1.4e-12만큼 모자랍니다. math.fsum은 덧셈 도중의 오차를 따로 모아 두었다가 마지막에 한 번만 반올림하므로, 넣은 수들의 정확한 합에 가장 가까운 float를 돌려줍니다. np.sum은 이웃끼리 짝지어 더하고 그 결과를 다시 짝지어 더하는 짝지어 더하기(pairwise summation) 를 써서, 합이 한쪽으로 커지는 일을 줄입니다. 차례로 더하기보다 오차가 훨씬 작지만 늘 정확하지는 않습니다. (파이썬 3.12부터는 내장 sum()도 보정 덧셈을 써서 sum([0.1] * 1000)이 100.0입니다. 반복문의 s += 0.1은 여전히 오차가 쌓입니다.)
이제 어제 정산 문제를 재현해 봅시다. 요금 2,000건을 만들어 정산 서버처럼 반복문으로, 장부 프로그램처럼 np.sum으로 더합니다.
>>> rng = np.random.default_rng(8)
>>> base = rng.integers(3000, 8000, size=2000).astype(float)
>>> disc = rng.integers(0, 500, size=2000) / 10
>>> fees = base - disc
>>> fees[:4]
array([6556.3, 4610.4, 4152.6, 7911.9])
>>> total = 0.0
>>> for f in fees.tolist():
... total += f
>>> total
10902373.799999986
>>> float(np.sum(fees))
10902373.799999999
>>> math.fsum(fees)
10902373.8정산표 두 장의 숫자가 그대로 나왔습니다. 둘 다 틀렸고, 장부 프로그램 쪽이 덜 틀렸습니다. 참값 10,902,373.8원에는 math.fsum만 닿습니다.
오차가 커지는 또 하나의 상황은 아주 큰 수에 아주 작은 수를 더할 때입니다. 작은 수가 큰 수의 ULP보다 작으면 통째로 사라집니다.
>>> big = 1e16
>>> (big + 1.0 + 1.0 + 1.0 + 1.0) - big
0.0
>>> (1.0 + 1.0 + 1.0 + 1.0 + big) - big
4.0같은 다섯 수인데 순서만 바꿨더니 답이 0과 4로 갈립니다. 큰 수에 1을 한 번씩 더하면 매번 반올림으로 사라지지만, 작은 수끼리 먼저 모아 4를 만들면 살아남습니다. 그래서 크기가 크게 다른 수를 더할 때는 작은 수부터 더하는 것이 요령입니다. 위 실습대에서 '0.1을 n번 더하기'를 고르고 재생을 눌러 보세요. 남색 선(차례로 더한 합의 오차)이 n과 함께 빠르게 자라고, 청록 점선(0.1 자체의 저장 오차만 남은 경우)과의 거리가 수천 배로 벌어집니다.
뺄셈에는 더 무서운 함정이 있습니다. 거의 같은 두 수를 빼면 앞의 같은 자릿수들이 서로 지워지고, 뒤에 남은 몇 자리(이미 반올림 오차가 섞인 자리)만 결과가 됩니다. 이것을 자릿수 상쇄(catastrophic cancellation) 라고 합니다.
예를 들어 는 가 0에 다가가면 에 다가갑니다. 작은 로 계산해 봅시다.
>>> x = np.array([1e-2, 1e-4, 1e-6, 1e-8])
>>> (1 - np.cos(x)) / x**2
array([0.49999583, 0.5 , 0.50004445, 0. ])에서 0.50004, 에서는 아예 0입니다. 손으로 따져 보면 이유가 보입니다. 입니다. 저장된 값에는 1 근처 float의 간격만큼, 즉 정도의 반올림 오차가 붙어 있습니다. 1에서 빼면 이 남는데, 여기에 붙은 오차 정도는 그대로 남습니다. 결과의 상대 오차는 입니다. 이던 상대 오차가 2조 배(배) 부풀었습니다. 에서는 이 1과 구별되지 않아 1로 저장되고, 뺄셈 결과가 0이 됩니다.
뺄셈 자체는 정확했습니다. 문제는 이미 있던 작은 오차가, 결과가 작아지면서 상대적으로 커진 것입니다. 처방은 뺄셈이 없는 식으로 고쳐 쓰는 것입니다. 삼각함수 공식 를 쓰면 됩니다.
>>> 2 * np.sin(x / 2)**2 / x**2
array([0.49999583, 0.5 , 0.5 , 0.5 ])모든 에서 0.5가 나옵니다. 자주 쓰는 고쳐 쓰기를 모아 두면 이렇습니다.
| 위험한 식 (작은 또는 큰 ) | 고쳐 쓴 식 | NumPy 함수 |
|---|---|---|
| (큰 ) | ||
np.expm1(x) | ||
np.log1p(x) |
다음 교시의 테일러 급수도 같은 처방을 줍니다. 작은 에서 처럼 뺄셈 없이 근삿값을 쓸 수 있습니다.
오 박사의 한마디
비슷한 두 수를 빼는 순간 앞자리가 다 날아가. 뺄셈 앞에서는 늘 '두 수가 얼마나 가까운가'를 물어봐. 가깝다면 식을 고쳐 써야 해.
float 두 개가 같은지 ==로 묻는 것은 위험합니다. 대신 허용 오차 안에서 가까운지를 묻습니다.
>>> 0.1 + 0.2 == 0.3
False
>>> math.isclose(0.1 + 0.2, 0.3)
True
>>> np.isclose(0.1 + 0.2, 0.3)
np.True_
>>> np.isclose(1e-10, 0.0), math.isclose(1e-10, 0.0)
(np.True_, False)math.isclose(a, b)는 상대 오차가 rel_tol=1e-09 이하인지 봅니다. np.isclose(a, b)는 atol rtol (기본 rtol=1e-05, atol=1e-08)를 봅니다. 마지막 줄처럼 둘의 기본값이 다르고, 0 근처에서는 np.isclose의 atol이 너무 너그러울 수 있습니다. 비교하는 수의 크기에 맞게 허용 오차를 직접 적는 습관을 들이세요.
돈은 다릅니다. 0.1원이 틀려도 장부가 안 맞습니다. 돈은 가장 작은 단위의 정수로 저장하는 것이 정석입니다. 이 회사라면 0.1원을 1로 치는 정수입니다. 정수끼리의 덧셈은 반올림이 없으니 몇 번을 더해도 정확합니다.
>>> fees_x10 = np.round(fees * 10).astype(np.int64)
>>> fees_x10[:4]
array([65563, 46104, 41526, 79119])
>>> int(fees_x10.sum())
109023738
>>> Decimal("0.1") + Decimal("0.2") == Decimal("0.3")
True합계 109,023,738은 0.1원 단위이니 10,902,373.8원입니다. 소수 그대로 다뤄야 한다면 십진 소수를 정확히 담는 decimal.Decimal을 씁니다. 이때 Decimal(0.1)처럼 float를 넣으면 이미 어긋난 값이 들어가니, 반드시 문자열 Decimal("0.1")로 만듭니다.
서윤은 정산 서버의 요금을 0.1원 단위 정수로 바꾸는 수정을 올렸습니다. 민재 팀장에게는 이렇게 답했습니다. "두 프로그램 모두 소수점 아래 여덟째, 아홉째 자리에서 틀렸습니다. 원 단위로는 둘 다 맞지만, 앞으로는 정수로 더해서 끝자리까지 맞추겠습니다."
| 책에 적힌 모양 | 읽는 법 | 뜻 | 파이썬에서는 |
|---|---|---|---|
| 이진수 영 점 일영일 | 아래 첨자는 진법 | (0.625).hex() | |
| 플로트 엑스 | 를 가장 가까운 float로 반올림한 값 | float(x), Decimal(x)로 확인 | |
| 부호, 가수, 지수 | float64의 구조 | x.hex() | |
| 기계 엡실론 | 1 다음 float까지의 간격, | np.finfo(float).eps | |
| 유엘피 | 다음 float까지의 간격 | np.spacing(x) | |
| 델타의 절댓값 | 연산 한 번의 상대 반올림 오차 한계 | ||
| 근사적으로 같다 | 허용 오차 안에서 같다 | math.isclose(a, b), np.isclose(a, b) |
math.fsum(xs) # 정확한 합 (마지막에 한 번만 반올림)
np.sum(a) # 짝지어 더하기: 반복문보다 훨씬 정확
np.isclose(a, b, rtol=1e-9, atol=1e-12) # 허용 오차를 직접 적기
np.allclose(A, B) # 배열 전체가 가까운가
np.testing.assert_allclose(actual, expected, rtol=1e-7) # 테스트에서
np.expm1(x), np.log1p(x) # 작은 x에서 e^x - 1, ln(1 + x)를 정확하게
decimal.Decimal("0.1") # 돈·세금처럼 십진 소수를 정확히
fractions.Fraction(1, 10) # 분수를 정확히 (느리지만 오차 없음)데이터베이스에서도 같습니다. 금액 열은 FLOAT가 아니라 DECIMAL(15, 1)이나 정수(가장 작은 단위)로 둡니다.
float를 ==로 비교하기. if total == expected: 는 계산 순서만 바뀌어도 깨집니다. math.isclose나 np.isclose를 쓰고, 허용 오차를 적어 두세요.
Decimal(0.1)로 정확해졌다고 믿기. float를 넣으면 이미 반올림된 값이 정확하게 옮겨질 뿐입니다. Decimal("0.1")처럼 문자열로 만드세요.
round(x, 1)이면 정확해진다고 믿기. round(0.1 + 0.2, 1)은 화면에 0.3으로 보이지만 저장된 값은 여전히 0.3에 가장 가까운 float입니다. 계산 중간마다 반올림해도 돈 문제는 풀리지 않습니다. 정수로 바꿔 계산하세요.
np.isclose(작은 값, 0)을 믿기. 기본 atol=1e-08 때문에 1e-10과 0이 같다고 나옵니다. 아주 작은 값을 다룰 때는 atol을 직접 정하세요.
np.float32로 0.1을 10,000번 차례로 더하면 얼마가 될까요? float64의 결과(1000.0000000001588)와 비교해 보세요.math.fsum과 비교해 보세요.>>> np.spacing(1.7e9)
np.float64(2.384185791015625e-07)
>>> np.cumsum(np.full(10000, 0.1, dtype=np.float32))[-1]
np.float32(999.9029)
>>> x = 1e12
>>> np.sqrt(x + 1) - np.sqrt(x)
np.float64(5.00003807246685e-07)
>>> 1 / (np.sqrt(x + 1) + np.sqrt(x))
np.float64(4.999999999998749e-07)
>>> k = np.arange(1, 100001)
>>> terms = 1 / k**2
>>> big_first = np.cumsum(terms)[-1]
>>> small_first = np.cumsum(terms[::-1])[-1]
>>> exact = math.fsum(terms)
>>> abs(big_first - exact), abs(small_first - exact)
(np.float64(1.5987211554602254e-14), np.float64(0.0))1번: 간격이 약 2.4e-7초(0.24마이크로초)라 1마이크로초는 구별됩니다. 하지만 두 시각을 빼서 짧은 시간 간격을 구할 때는 이 크기의 오차가 그대로 남습니다(자릿수 상쇄). 기록 장치가 나노초까지 다룬다면 시각을 정수 나노초(np.int64)로 저장합니다.
2번: float32는 믿을 자릿수가 7자리 남짓이라 1만 번 만에 약 0.1이나 어긋납니다. 오차가 float64보다 6억 배쯤 큽니다.
3번: 그대로 계산하면 앞 자릿수가 상쇄되어 5.00003807e-07이 나옵니다. 상대 오차가 약 8e-6이니 열여섯 자리 중 열한 자리를 잃었습니다. 고쳐 쓴 식은 뺄셈이 없어 끝자리까지 맞습니다(참값 4.99999999999875e-07).
4번: 큰 항부터 더하면 합이 금방 1.6 근처가 되고, 그 뒤 작은 항들은 ULP보다 작아 끝자리가 잘려 나갑니다. 작은 항부터 더하면 작은 항들이 먼저 모여 살아남습니다. 2-4의 '작은 수부터'가 그대로 확인됩니다.
오 박사의 계산 노트
0.1처럼 분모에 5가 있는 소수는 이진수로 끝나지 않는다. float는 가장 가까운 값을 저장한다(Decimal(x)로 끝까지 확인).
float64 = 부호 1 + 지수 11 + 가수 52비트. 유효숫자 15~16자리, 기계 엡실론 2.2e-16 (np.finfo(float).eps).
수 사이의 간격은 크기에 비례한다: np.spacing(x) ≈ eps·|x|. 2⁵³을 넘으면 정수도 빠진다(1e16 + 1 == 1e16).
덧셈마다 반올림이 생기고 쌓인다. 작은 수부터 더하고, 합계는 math.fsum이나 np.sum으로.
비슷한 두 수의 뺄셈(자릿수 상쇄)은 식을 고쳐 피한다: 1 − cos x = 2 sin²(x/2), np.expm1, np.log1p.
같은지는 math.isclose / np.isclose로 묻고, 돈은 가장 작은 단위의 정수나 Decimal("0.1")로 다룬다.
정산 수정을 올리고 나가려는데 정비 팀에서 메시지가 옵니다. "새 드론의 작은 칩은 exp()를 부를 여유가 없어요. 배터리 남은 양을 덧셈·곱셈만으로 계산할 수 있을까요? 그리고 지금 1분에 몇 Wh씩 닳는지, 순간 소모율도 알고 싶어요." 다음 교시에는 함수를 다항식으로 바꾸는 테일러 급수와, 그 근사의 대가인 절단 오차를 봅니다. 오늘 배운 반올림 오차도 다시 나와 절단 오차와 줄다리기를 합니다.
새 교시·새 강의 소식 받기
이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.
R
ReflexKR
눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.
ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.
© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
질문과 답변
이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.