R

ReflexKR

AI의 일기장Reflex 공식 문서
드론 관제실의 수치해석 기초: NumPy로 근사해 풀기
8교시

강의 시간 5:50–6:40

지도가 커지면: LU 분해, 반복법, 조건수

운항 일지

#08 · 도시 전체 충전소

충전소 마흔 곳의 전력을 매시간 나누기

문제

해안 도로를 따라 늘어선 충전소 40곳이 이웃끼리 전력을 나눠 쓴다. 수요가 매시간 바뀌어 같은 행렬로 하루 24번 연립방정식을 풀어야 하고, 충전소는 계속 늘어난다. 행렬은 거의 다 0이다.

목표

한 번 분해해 여러 번 푸는 LU 분해와, 큰 희소 행렬을 조금씩 고쳐 가며 푸는 반복법(야코비·가우스-자이델)을 익힌다. 조건수로 답을 몇 자리까지 믿을 수 있는지 말한다.

하늘상자 새벽 배송 관제실

다음 날 아침, 민재 팀장이 도시 지도를 화이트보드에 붙입니다. 해안 도로를 따라 드론 충전소 40곳이 한 줄로 이어져 있습니다. "충전소끼리 전력선이 이웃으로 연결돼 있어서 전력을 나눠 써요. 전력 팀 규칙대로 배분량을 정해야 하는데, 수요가 매시간 바뀌니까 하루에 스물네 번 풀어야 해요. 내년엔 충전소가 400곳이 된대요."

서윤은 어제 만든 np.linalg.solve를 반복문에 넣으면 된다고 생각합니다. 그러다 화면에 찍힌 40 × 40 행렬을 보고 멈칫합니다. 1,600칸 중 0이 아닌 칸이 118개뿐입니다. "이 0들까지 매번 다 소거하는 거예요?"

오 박사가 웃습니다. "좋은 질문이야. 낭비가 두 군데 있어. 행렬이 매번 같은데 소거를 처음부터 다시 하는 것, 그리고 0을 계산하는 것. 어제 곱수 l을 계산하고 버렸지? 그걸 적어 두면 첫 번째 낭비가 사라져. 두 번째는 아예 다른 방법으로 풀지. 그리고 하나 더. 답을 얻으면 그 답을 몇 자리까지 믿을 수 있는지도 말할 줄 알아야 해."

전력 팀의 규칙은 이렇습니다. 충전소 $i$가 받는 전력 $x_i$(kW)는 자기 수요 $d_i$의 1/3에, 양옆 이웃이 받는 전력의 1/3씩을 더한 값입니다. 식으로 쓰고 양변에 3을 곱하면 충전소마다 식이 하나씩 나옵니다.

xi=di+xi−1+xi+13⟺−xi−1+3xi−xi+1=dix_i = \frac{d_i + x_{i-1} + x_{i+1}}{3} \quad\Longleftrightarrow\quad -x_{i-1} + 3x_i - x_{i+1} = d_i

양 끝 충전소는 이웃이 하나뿐입니다. 충전소 4곳이면 이런 연립방정식입니다. 대각선과 그 양옆에만 수가 있는 이 모양을 삼중대각 행렬(tridiagonal matrix) 이라고 합니다.

[3−100−13−100−13−100−13][x1x2x3x4]=[d1d2d3d4]\begin{bmatrix} 3 & -1 & 0 & 0 \\ -1 & 3 & -1 & 0 \\ 0 & -1 & 3 & -1 \\ 0 & 0 & -1 & 3 \end{bmatrix} \begin{bmatrix} x_1 \\ x_2 \\ x_3 \\ x_4 \end{bmatrix} = \begin{bmatrix} d_1 \\ d_2 \\ d_3 \\ d_4 \end{bmatrix}

8-1. 한 번 분해, 여러 번 풀기: LU 분해 (10분)

어제의 허브 행렬로 시작합니다. 소거하며 쓴 곱수는 l21=0.5l_{21} = 0.5, l31=0.5l_{31} = 0.5, l32=0.5l_{32} = 0.5였습니다. 이 곱수를 대각선이 1인 아래삼각 행렬 LL에 적어 두고, 소거가 끝난 위삼각 행렬을 UU라 하면 놀라운 일이 생깁니다.

[1000.5100.50.51]⏟L[421041.5003.75]⏟U=[421252235]⏟A\underbrace{\begin{bmatrix} 1 & 0 & 0 \\ 0.5 & 1 & 0 \\ 0.5 & 0.5 & 1 \end{bmatrix}}_{L} \underbrace{\begin{bmatrix} 4 & 2 & 1 \\ 0 & 4 & 1.5 \\ 0 & 0 & 3.75 \end{bmatrix}}_{U} = \underbrace{\begin{bmatrix} 4 & 2 & 1 \\ 2 & 5 & 2 \\ 2 & 3 & 5 \end{bmatrix}}_{A}

LL의 2행과 UU를 곱해 보면 0.5×(4,2,1)+1×(0,4,1.5)=(2,5,2)0.5 \times (4, 2, 1) + 1 \times (0, 4, 1.5) = (2, 5, 2)로 AA의 2행이 나옵니다. "2행에서 1행의 0.5배를 뺐더니 UU의 2행이 되었다"를 거꾸로 적은 것입니다. 이것이 LU 분해(LU decomposition) 입니다. 소거는 곧 행렬을 두 삼각 행렬의 곱으로 쪼개는 일이었습니다.

>>> A = np.array([[4, 2, 1],
...               [2, 5, 2],
...               [2, 3, 5]], dtype=float)
>>> def lu_nopivot(A):
...     n = len(A)
...     U = A.astype(float)
...     L = np.eye(n)
...     for k in range(n - 1):
...         for i in range(k + 1, n):
...             L[i, k] = U[i, k] / U[k, k]
...             U[i, k:] -= L[i, k] * U[k, k:]
...     return L, U
>>> L, U = lu_nopivot(A)
>>> L
array([[1. , 0. , 0. ],
       [0.5, 1. , 0. ],
       [0.5, 0.5, 1. ]])
>>> U
array([[4.  , 2.  , 1.  ],
       [0.  , 4.  , 1.5 ],
       [0.  , 0.  , 3.75]])
>>> np.allclose(L @ U, A)
True

7교시의 naive_gauss와 다른 점은 곱수를 L[i, k]에 적어 두는 것, 그리고 b\mathbf{b}를 건드리지 않는 것뿐입니다.

이제 Ax=bA\mathbf{x} = \mathbf{b}는 L(Ux)=bL(U\mathbf{x}) = \mathbf{b}입니다. UxU\mathbf{x}를 y\mathbf{y}라 부르면 두 번에 나눠 풀 수 있습니다.

  1. 전진 대입 Ly=bL\mathbf{y} = \mathbf{b}: 위에서 아래로. yi=bi−∑j<ilijyjy_i = b_i - \sum_{j<i} l_{ij} y_j
  2. 후진 대입 Ux=yU\mathbf{x} = \mathbf{y}: 아래에서 위로. 7교시와 같다.

둘 다 삼각 행렬이라 각각 n2n^2번 정도면 끝납니다. 비싼 2n3/32n^3/3은 분해할 때 한 번만 냅니다.

>>> def lu_solve(L, U, b):
...     n = len(b)
...     y = np.zeros(n)
...     for i in range(n):
...         y[i] = b[i] - L[i, :i] @ y[:i]
...     x = np.zeros(n)
...     for i in range(n - 1, -1, -1):
...         x[i] = (y[i] - U[i, i + 1:] @ x[i + 1:]) / U[i, i]
...     return x
>>> lu_solve(L, U, np.array([44.0, 60, 56]))
array([6., 8., 4.])
>>> demands = np.array([[44, 60, 56],
...                     [50, 60, 56],
...                     [40, 64, 60]], dtype=float)
>>> for d in demands:
...     print(d, "->", lu_solve(L, U, d))
[44. 60. 56.] -> [6. 8. 4.]
[50. 60. 56.] -> [7.9 7.4 3.6]
[40. 64. 60.] -> [4.2 9.2 4.8]

충전소 40곳(n=40n = 40)을 하루 24번 푼다면 얼마나 아낄까요?

방법연산 수 (약)
매번 처음부터 소거: 24×23n324 \times \frac{2}{3}n^31,024,000
한 번 분해 + 24번 대입: 23n3+24×2n2\frac{2}{3}n^3 + 24 \times 2n^2119,000

약 9배입니다. nn이 클수록 차이는 24배에 가까워집니다.

실제로는 7교시의 부분 피벗팅이 끼어듭니다. 행을 바꾼 기록을 치환 행렬(permutation matrix) PP에 적어 PA=LUPA = LU로 씁니다. PP는 단위행렬의 행 순서를 바꾼 것이고, 풀 때 b\mathbf{b}의 순서를 똑같이 바꿔 주기만 하면 됩니다. NumPy에는 LU 분해 함수가 없습니다. SciPy의 이름은 '실무에서는'에서 봅니다.

8-2. 조금씩 고쳐 가기: 야코비와 가우스-자이델 (10분)

전력 팀의 규칙을 다시 보세요. xi=(di+xi−1+xi+1)/3x_i = (d_i + x_{i-1} + x_{i+1})/3. 이웃 값을 알면 내 값이 정해지는 꼴입니다. 그렇다면 아무 값에서나 출발해 이 식으로 계속 고쳐 쓰면 어떨까요? 4교시와 5교시에서 근을 찾을 때 했던 "한 걸음씩 다가가기"를 연립방정식에 쓰는 것입니다.

야코비 방법(Jacobi method). 지난 회차의 값만 써서 모두를 한꺼번에 고칩니다. 일반적인 Ax=bA\mathbf{x} = \mathbf{b}에서는 ii번째 식을 xix_i에 대해 풀어 씁니다.

xi(k+1)=1aii(bi−∑j≠iaij xj(k))x_i^{(k+1)} = \frac{1}{a_{ii}}\Bigl(b_i - \sum_{j \ne i} a_{ij}\, x_j^{(k)}\Bigr)

가우스-자이델 방법(Gauss–Seidel method). 같은 식인데, 이번 회차에 이미 고친 값이 있으면 바로 씁니다. x1x_1을 고쳤으면 x2x_2를 고칠 때 새 x1x_1을 씁니다.

xi(k+1)=1aii(bi−∑j<iaij xj(k+1)−∑j>iaij xj(k))x_i^{(k+1)} = \frac{1}{a_{ii}}\Bigl(b_i - \sum_{j < i} a_{ij}\, x_j^{(k+1)} - \sum_{j > i} a_{ij}\, x_j^{(k)}\Bigr)

충전소 4곳, 수요 d=(20,22,16,16)\mathbf{d} = (20, 22, 16, 16) kW로 해 봅니다. 0에서 출발하면 야코비 1회차는 x1=(20+0)/3=6.667x_1 = (20 + 0)/3 = 6.667, x2=(22+0+0)/3=7.333x_2 = (22 + 0 + 0)/3 = 7.333입니다. 가우스-자이델은 x2x_2를 고칠 때 방금 구한 6.667을 써서 x2=(22+6.667+0)/3=9.556x_2 = (22 + 6.667 + 0)/3 = 9.556이 됩니다.

>>> A4 = np.array([[3, -1, 0, 0],
...                [-1, 3, -1, 0],
...                [0, -1, 3, -1],
...                [0, 0, -1, 3]], dtype=float)
>>> d4 = np.array([20.0, 22, 16, 16])
>>> np.linalg.solve(A4, d4)
array([12., 16., 14., 10.])
>>> def jacobi(A, b, x0, iters):
...     D = np.diag(A)
...     R = A - np.diag(D)
...     x = x0.astype(float)
...     for _ in range(iters):
...         x = (b - R @ x) / D
...     return x
>>> def gauss_seidel(A, b, x0, iters):
...     x = x0.astype(float)
...     for _ in range(iters):
...         for i in range(len(b)):
...             s = A[i] @ x - A[i, i] * x[i]
...             x[i] = (b[i] - s) / A[i, i]
...     return x
>>> x0 = np.zeros(4)
>>> jacobi(A4, d4, x0, 1)
array([6.66666667, 7.33333333, 5.33333333, 5.33333333])
>>> gauss_seidel(A4, d4, x0, 1)
array([6.66666667, 9.55555556, 8.51851852, 8.17283951])

np.diag(A)는 대각 원소를 뽑고, np.diag(D)는 거꾸로 그 값을 대각선에 놓은 행렬을 만듭니다. 그래서 R은 AA에서 대각선만 0으로 지운 행렬입니다. 야코비는 x 전체를 새 배열로 바꾸고, 가우스-자이델은 x[i]를 그 자리에서 고쳐 다음 줄이 바로 쓰게 합니다. 차이는 그것뿐입니다.

몇 번 고치면 될까요? 반복 횟수마다 잔차 ∥b−Ax∥\|\mathbf{b} - A\mathbf{x}\|의 가장 큰 성분을 봅니다.

>>> for k in [1, 2, 4, 8, 16]:
...     rj = np.abs(d4 - A4 @ jacobi(A4, d4, x0, k)).max()
...     rg = np.abs(d4 - A4 @ gauss_seidel(A4, d4, x0, k)).max()
...     print(k, f"{rj:.1e}", f"{rg:.1e}")
1 1.3e+01 9.6e+00
2 6.7e+00 4.0e+00
4 2.0e+00 5.4e-01
8 1.7e-01 3.9e-03
16 1.2e-03 2.0e-07

둘 다 잔차가 일정한 비율로 줄어듭니다(로그로 보면 직선). 4교시 이분법처럼 1차 수렴입니다. 가우스-자이델은 같은 횟수에서 맞는 자릿수가 약 두 배라, 대개 야코비보다 두 배쯤 빠릅니다. 대신 야코비는 모든 xix_i를 서로 기다리지 않고 동시에 고칠 수 있어서 여러 코어나 GPU에 나눠 맡기기 좋습니다.

반복법의 진짜 장점은 크기입니다. 한 번 고칠 때 0이 아닌 칸만 계산하면 되니, 충전소마다 이웃이 둘뿐인 이 문제에서는 한 회차가 nn에 비례합니다. 소거의 n3n^3과 견주어 보세요. 정지 조건은 4교시처럼 정합니다. 잔차나 두 회차의 차이가 허용 오차보다 작아지면 멈춥니다.

8-3. 언제 수렴하나: 대각 우세 (6분)

반복법이 늘 수렴하지는 않습니다. 가장 쓰기 쉬운 판정법은 대각 우세(diagonal dominance) 입니다. 모든 행에서 대각 원소의 절댓값이 나머지 원소 절댓값의 합보다 크면 됩니다.

∣aii∣>∑j≠i∣aij∣(모든 i)|a_{ii}| > \sum_{j \ne i} |a_{ij}| \qquad (\text{모든 } i)

충전소 행렬은 모든 행에서 3>1+13 > 1 + 1이라 대각 우세이고, 야코비와 가우스-자이델 모두 반드시 수렴합니다. 뜻으로 읽으면 "각 식에서 자기 미지수의 영향이 이웃들 영향을 다 합친 것보다 크다"입니다. 그래서 이웃 값이 조금 틀려도 내 값은 덜 틀리게 고쳐지고, 고칠 때마다 오차가 줄어듭니다.

순서가 중요하다는 것도 보여 드리겠습니다. 실습대와 같은 2 × 2 연립방정식입니다.

>>> def diag_dominant(A):
...     d = np.abs(np.diag(A))
...     rest = np.abs(A).sum(axis=1) - d
...     return bool(np.all(d > rest))
>>> P = np.array([[3.0, 2], [1, 2]])
>>> q = np.array([7.0, 5])
>>> np.linalg.solve(P, q)
array([1., 2.])
>>> gauss_seidel(P, q, np.array([-3.0, -1]), 10)
array([1.00010161, 1.99994919])
>>> P_swap, q_swap = P[[1, 0]], q[[1, 0]]
>>> P_swap
array([[1., 2.],
       [3., 2.]])
>>> diag_dominant(P), diag_dominant(P_swap)
(True, False)
>>> gauss_seidel(P_swap, q_swap, np.array([-3.0, -1]), 10)
array([ 118099., -177145.])

두 식의 순서만 바꿨습니다. 같은 연립방정식이고 답도 (1, 2) 그대로인데, 가우스-자이델은 십만 단위로 튀어 나갑니다. 2 × 2에서는 가우스-자이델이 한 번 돌 때 오차가 ρ=∣a12a21a11a22∣\rho = \left|\dfrac{a_{12}a_{21}}{a_{11}a_{22}}\right|배가 됩니다. 처음 순서는 ρ=2⋅13⋅2=13\rho = \frac{2 \cdot 1}{3 \cdot 2} = \frac{1}{3}, 바꾼 순서는 ρ=2⋅31⋅2=3\rho = \frac{2 \cdot 3}{1 \cdot 2} = 3입니다. 3배씩 10번이면 310≈59,0003^{10} \approx 59{,}000배입니다.

대각 우세는 충분조건입니다. 대각 우세가 아니어도 수렴하는 행렬이 있습니다. 7교시의 허브 행렬은 3행이 5=2+35 = 2 + 3이라 엄밀하게는 대각 우세가 아니지만, 가우스-자이델이 잘 수렴합니다. 반대로 대각 우세가 아니면 수렴을 장담할 수 없으니, 행 순서를 바꿔 큰 수를 대각선에 모으는 것이 먼저입니다. 7교시 부분 피벗팅과 같은 생각입니다.

아래 실습대의 두 직선이 연립방정식의 두 식입니다. 청록 직선이 식 ①(x를 고칠 때 쓰는 식), 진회색 직선이 식 ②(y를 고칠 때 쓰는 식)이고, 회색 점 (1, 2)가 교점, 곧 참값입니다. 검은 손잡이로 시작점을 끌어 옮길 수 있습니다(키보드 화살표도 됩니다). 가우스-자이델은 시작점에서 가로로 움직여 직선 ①에 닿고(y는 그대로 두고 x를 고침), 거기서 세로로 움직여 직선 ②에 닿습니다(새 x로 y를 고침). 그래서 남색 길이 계단 모양으로 교점을 향합니다. 호박색이 지금 단계입니다. 야코비로 바꾸면 이전 점에서 두 직선까지 가로·세로로 잰 값을 한꺼번에 써서 대각선으로 건너뜁니다. 슬라이더는 식 ①의 계수 a₁₁, a₁₂를 바꿉니다. 직선 ①은 늘 참값 (1, 2)를 지나도록 b₁이 따라 바뀝니다.

-2-1012345-4-20246xy①②
  • ① 3x + 2y = 7
  • ② x + 2y = 5
  • 반복이 지나간 길
  • 참값 (1, 2)

예제

방법

식 ①의 계수

단계 0 / 15

0번째: (-3, -1)
참값까지 거리: 5
수렴 비율 ρ = 0.333

대각 우세: ① |3| > |2| ✓ ② |2| > |1| ✓

조건수 cond(A)
4.27
잃는 자릿수 ≈ 0.6

참값으로 빠르게 다가갑니다. 대각 우세라 매번 오차가 ρ배로 줄어듭니다.

NumPy로 확인하기

실습대에서 값을 정할 때마다, 서버의 NumPy가 같은 숫자로 계산한 결과입니다.

>>> import numpy as np
>>> A = np.array([[3.0, 2.0], [1.0, 2.0]])
>>> b = np.array([7.0, 5.0])
>>> f"{np.linalg.cond(A):.4g}"
'4.266'
>>> np.linalg.solve(A, b)
array([1., 2.])
# 가우스-자이델: x를 고치자마자 그 x로 y를 고친다
>>> x = np.array([-3.0, -1.0])
>>> for k in range(15):
...     x[0] = (b[0] - A[0, 1] * x[1]) / A[0, 0]
...     x[1] = (b[1] - A[1, 0] * x[0]) / A[1, 1]
...
>>> np.round(x, 6)
array([1., 2.])
# 15번 반복한 뒤 참값과의 거리
>>> np.round(np.linalg.norm(x - np.linalg.solve(A, b)), 6)
np.float64(0.0)
# 수렴 비율 |a12·a21 / (a11·a22)| (1보다 작아야 수렴, 야코비는 이 값의 제곱근)
>>> round(abs(A[0, 1] * A[1, 0] / (A[0, 0] * A[1, 1])), 4)
np.float64(0.3333)
# b₁을 1% 바꾸면 답은 얼마나 움직일까 (조건수가 크면 많이)
>>> np.round(np.linalg.solve(A, b * [1.01, 1]), 4)
array([1.035 , 1.9825])
먼저 '재생'으로 계단이 교점에 붙는 모습을 보세요. 그다음 '식 순서 바꾸기'를 누르세요. 두 직선은 그대로인데 계단이 바깥으로 튀어 나가고 수렴 비율 ρ가 3이 됩니다. 다시 '기본'으로 돌아와 a₁₁을 1로 줄이고 a₁₂를 1.5, 2.5, 3으로 키워 보세요. a₁₂가 a₁₁보다 커져 대각 우세가 깨져도 한동안은 수렴합니다(대각 우세는 충분조건). ρ = |a₁₂·a₂₁ / (a₁₁·a₂₂)|가 1을 넘는 a₁₂ = 2 너머에서 발산으로 바뀝니다. 야코비로 바꾸면 ρ가 가우스-자이델 값의 제곱근이 되어 더 느립니다.

8-4. 0이 대부분인 행렬: 희소 행렬 (6분)

충전소 40곳의 행렬을 실제로 만들어 봅시다. np.eye(n, k=1)은 대각선 바로 위 칸만 1인 행렬입니다.

>>> n = 40
>>> T = 3 * np.eye(n) - np.eye(n, k=1) - np.eye(n, k=-1)
>>> T[:5, :5]
array([[ 3., -1.,  0.,  0.,  0.],
       [-1.,  3., -1.,  0.,  0.],
       [ 0., -1.,  3., -1.,  0.],
       [ 0.,  0., -1.,  3., -1.],
       [ 0.,  0.,  0., -1.,  3.]])
>>> np.count_nonzero(T), T.size
(np.int64(118), 1600)
>>> T.nbytes
12800

1,600칸 중 118칸만 0이 아닙니다. 이렇게 대부분이 0인 행렬을 희소 행렬(sparse matrix) 이라고 합니다. 충전소마다 이웃이 둘뿐이니 한 행에 0이 아닌 수가 많아야 3개입니다. 도로망, 전력망, 소셜 그래프, 미분방정식을 격자로 푼 행렬(12교시)이 모두 이런 모양입니다.

40곳일 때는 12.8 KB라 상관없지만, 충전소가 10만 곳이면 이야기가 다릅니다.

>>> 100_000**2 * 8 / 1e9
80.0
>>> 3 * 100_000 * 8 / 1e6
2.4

모든 칸을 float64(8바이트)로 저장하면 80 GB, 대각선 세 줄만 저장하면 2.4 MB입니다. 소거는 더 나쁩니다. 연산이 n3n^3이고, 소거하는 동안 0이던 칸이 0이 아닌 수로 채워지기도 합니다(fill-in).

반복법은 행렬을 만들 필요조차 없습니다. "이웃의 값을 더해 3으로 나누기"만 할 수 있으면 됩니다. 아래 jacobi_chain은 T를 쓰지 않고 이웃 값을 한 칸씩 밀어서 가져옵니다. np.r_[0, x[:-1]]은 왼쪽 이웃(맨 앞은 0), np.r_[x[1:], 0]은 오른쪽 이웃입니다.

>>> def jacobi_chain(d, iters):
...     x = np.zeros(len(d))
...     for _ in range(iters):
...         left = np.r_[0, x[:-1]]
...         right = np.r_[x[1:], 0]
...         x = (d + left + right) / 3
...     return x
>>> rng = np.random.default_rng(8)
>>> d = rng.uniform(5, 30, size=n).round(1)
>>> d[:6]
array([13.2, 29.7, 13. , 24.7, 26.7, 14.8])
>>> x = jacobi_chain(d, 60)
>>> np.round(x[:6], 2)
array([10.92, 19.55, 18.03, 21.55, 21.92, 17.5 ])
>>> np.abs(d - T @ x).max() < 1e-6
np.True_
>>> np.allclose(x, np.linalg.solve(T, d))
True

한 회차에 덧셈 몇 번과 나눗셈 한 번이 충전소 수만큼, 60회차면 충분합니다. 충전소가 10만 곳이어도 메모리는 배열 몇 개, 계산은 수백만 번입니다. 소거라면 23(105)3≈7×1014\frac{2}{3}(10^5)^3 \approx 7 \times 10^{14}번입니다.

8-5. 답을 얼마나 믿을 수 있나: 조건수 (10분)

1교시에 오차의 출처로 데이터 오차를 적고 "8교시(조건수)"라고 미뤄 두었습니다. 수요 b\mathbf{b}는 계량기로 잰 값이라 늘 조금씩 틀립니다. 그 작은 틀림이 답을 얼마나 흔들까요?

두 직선이 거의 평행한 연립방정식을 봅시다. 두 식의 계수가 0.0001만 다릅니다.

x+2y=51.0001x+2y=5.0001\begin{aligned} x + 2y &= 5\\ 1.0001x + 2y &= 5.0001 \end{aligned}

답은 (1,2)(1, 2)입니다. 이제 첫 식의 오른쪽 5를 0.01% 바꿔 5.0005로 해 봅니다.

>>> C = np.array([[1.0, 2.0], [1.0001, 2.0]])
>>> c = C @ np.array([1.0, 2.0])
>>> c
array([5.    , 5.0001])
>>> np.linalg.solve(C, c)
array([1., 2.])
>>> c_bumped = c * np.array([1.0001, 1.0])
>>> c_bumped
array([5.0005, 5.0001])
>>> np.round(np.linalg.solve(C, c_bumped), 4)
array([-4.    ,  4.5002])

입력을 만분의 일 바꿨더니 답이 (1,2)(1, 2)에서 (−4,4.5)(-4, 4.5)로 갔습니다. 계산이 틀린 것이 아닙니다. 두 직선이 거의 평행하면, 한 직선을 아주 조금만 옮겨도 교점이 직선을 따라 멀리 미끄러집니다. 문제 자체가 예민한 것입니다. 이런 문제를 나쁜 조건(ill-conditioned) 이라고 합니다.

얼마나 예민한지를 숫자 하나로 나타낸 것이 조건수(condition number) κ(A)\kappa(A)입니다. 입력의 상대 오차가 답에서 최대 몇 배로 커질 수 있는지를 말합니다.

∥Δx∥∥x∥  ≤  κ(A) ∥Δb∥∥b∥,κ(A)=∥A∥ ∥A−1∥\frac{\|\Delta \mathbf{x}\|}{\|\mathbf{x}\|} \;\le\; \kappa(A)\, \frac{\|\Delta \mathbf{b}\|}{\|\mathbf{b}\|}, \qquad \kappa(A) = \|A\|\,\|A^{-1}\|

∥⋅∥\|\cdot\|는 벡터의 길이(노름)입니다. 행렬의 노름은 "벡터를 최대 몇 배로 늘이는가"입니다. 그래서 κ(A)\kappa(A)는 AA가 가장 많이 늘이는 배율을 가장 적게 늘이는 배율로 나눈 값입니다. 선형대수 강의 14교시 SVD의 말로 하면 가장 큰 특이값 ÷ 가장 작은 특이값입니다. 가장 작은 쪽이 0이면(특이 행렬) 조건수는 무한대입니다.

>>> f"{np.linalg.cond(C):.1e}"
'5.0e+04'
>>> round(np.linalg.cond(P), 2)
np.float64(4.27)
>>> round(np.linalg.cond(A), 2), round(np.linalg.cond(A4), 2), round(np.linalg.cond(T), 2)
(np.float64(3.87), np.float64(3.34), np.float64(4.96))

C는 5만이고, 허브 행렬·충전소 행렬·실습대의 기본 행렬은 모두 5 아래입니다. 실제로 C에서 입력의 상대 변화는 약 7×10−57 \times 10^{-5}, 답의 상대 변화는 2.5(250%)였으니 약 35,000배로 커졌습니다. 조건수 5만 안쪽입니다.

여기서 7교시에 미뤄 둔 이야기가 나옵니다. 흔들린 답 (−4,4.5)(-4, 4.5)를 원래 식에 넣어 보면 잔차가 아주 작습니다.

>>> x_b = np.linalg.solve(C, c_bumped)
>>> np.round(np.abs(C @ x_b - c).max(), 6)
np.float64(0.0005)

잔차 0.0005인데 답은 250% 틀렸습니다. 조건수가 크면 잔차가 작아도 답이 맞다는 보장이 없습니다. 잔차 확인과 조건수 확인은 짝으로 합니다.

자릿수로 읽는 법. 2교시에서 float64의 기계 엡실론이 ε≈2.2×10−16\varepsilon \approx 2.2 \times 10^{-16}, 곧 유효숫자 약 16자리라고 했습니다. 입력을 저장하는 순간 생기는 반올림 오차만으로도 답의 상대 오차는 κ⋅ε\kappa \cdot \varepsilon까지 커질 수 있습니다. 그래서 실무의 어림은 이렇습니다.

답에서 잃는 자릿수 ≈ log⁡10κ(A)\log_{10} \kappa(A). 16에서 이 값을 빼면 믿을 수 있는 자릿수.

악명 높은 예가 힐베르트 행렬(Hilbert matrix) Hij=1/(i+j+1)H_{ij} = 1/(i + j + 1)입니다(파이썬처럼 0부터 셈). 생긴 것은 얌전한데 크기를 키우면 조건수가 폭발합니다. 답이 모두 1이 되게 b\mathbf{b}를 만들고 풀어 봅니다.

>>> H4 = 1 / (np.arange(4)[:, None] + np.arange(4) + 1)
>>> np.round(H4, 3)
array([[1.   , 0.5  , 0.333, 0.25 ],
       [0.5  , 0.333, 0.25 , 0.2  ],
       [0.333, 0.25 , 0.2  , 0.167],
       [0.25 , 0.2  , 0.167, 0.143]])
>>> for n in [4, 8, 12]:
...     H = 1 / (np.arange(n)[:, None] + np.arange(n) + 1)
...     x = np.linalg.solve(H, H @ np.ones(n))
...     print(n, f"{np.linalg.cond(H):.1e}", f"{np.abs(x - 1).max():.1e}")
4 1.6e+04 6.6e-14
8 1.5e+10 1.2e-07
12 1.8e+16 7.0e-01
nnκ\kappa잃는 자릿수 (어림)남는 자릿수 (어림)실제 오차
41.6×1041.6 \times 10^{4}4126.6×10−146.6 \times 10^{-14}
81.5×10101.5 \times 10^{10}1061.2×10−71.2 \times 10^{-7}
121.8×10161.8 \times 10^{16}1600.7

12 × 12에서는 맞는 자릿수가 하나도 남지 않습니다. np.linalg.solve는 오류도 없이 답을 냈고, 잔차도 작습니다. 7교시의 부분 피벗팅은 계산 방법 때문에 생기는 오차를 막는 장치이고, 조건수는 문제 자체의 예민함입니다. 피벗팅으로는 조건수를 고칠 수 없습니다. 실제 오차가 어림보다 조금 작게 나온 것은 κ⋅ε\kappa \cdot \varepsilon이 "최악이면 이만큼"이라는 위쪽 한계이기 때문입니다.

오 박사의 한마디

계량기 수요가 소수 둘째 자리까지 맞는데 조건수가 10⁶이면, 답은 한 자리도 못 믿어. 그때는 더 정밀하게 계산할 게 아니라 문제를 바꿔야 해. 거의 같은 정보를 주는 식 두 개 대신 서로 다른 정보를 주는 식을 찾는 거지.

실습대에서 직접 확인해 보세요. 오른쪽의 큰 숫자가 지금 행렬의 조건수입니다. 30을 넘으면 호박색, 1000을 넘으면 붉은색으로 바뀝니다. 'b₁을 1% 바꿔 보기'를 켜면 직선 ①이 b₁ +1%만큼 옮겨진 붉은 점선과, 그때의 새 교점(붉은 점)이 나타납니다.

-2-1012345-4-20246xy①②
  • ① 3x + 2y = 7
  • ② x + 2y = 5
  • 반복이 지나간 길
  • 참값 (1, 2)

예제

방법

식 ①의 계수

단계 0 / 15

0번째: (-3, -1)
참값까지 거리: 5
수렴 비율 ρ = 0.333

대각 우세: ① |3| > |2| ✓ ② |2| > |1| ✓

조건수 cond(A)
4.27
잃는 자릿수 ≈ 0.6

참값으로 빠르게 다가갑니다. 대각 우세라 매번 오차가 ρ배로 줄어듭니다.

NumPy로 확인하기

실습대에서 값을 정할 때마다, 서버의 NumPy가 같은 숫자로 계산한 결과입니다.

>>> import numpy as np
>>> A = np.array([[3.0, 2.0], [1.0, 2.0]])
>>> b = np.array([7.0, 5.0])
>>> f"{np.linalg.cond(A):.4g}"
'4.266'
>>> np.linalg.solve(A, b)
array([1., 2.])
# 가우스-자이델: x를 고치자마자 그 x로 y를 고친다
>>> x = np.array([-3.0, -1.0])
>>> for k in range(15):
...     x[0] = (b[0] - A[0, 1] * x[1]) / A[0, 0]
...     x[1] = (b[1] - A[1, 0] * x[0]) / A[1, 1]
...
>>> np.round(x, 6)
array([1., 2.])
# 15번 반복한 뒤 참값과의 거리
>>> np.round(np.linalg.norm(x - np.linalg.solve(A, b)), 6)
np.float64(0.0)
# 수렴 비율 |a12·a21 / (a11·a22)| (1보다 작아야 수렴, 야코비는 이 값의 제곱근)
>>> round(abs(A[0, 1] * A[1, 0] / (A[0, 0] * A[1, 1])), 4)
np.float64(0.3333)
# b₁을 1% 바꾸면 답은 얼마나 움직일까 (조건수가 크면 많이)
>>> np.round(np.linalg.solve(A, b * [1.01, 1]), 4)
array([1.035 , 1.9825])
'b₁을 1% 바꿔 보기'를 켜고 '기본'과 '거의 평행'을 번갈아 눌러 보세요. 기본(조건수 4.27)에서는 붉은 점이 거의 움직이지 않지만, 거의 평행(조건수 약 106)에서는 답이 수십 % 움직입니다. 거의 평행에서 '재생'을 누르면 가우스-자이델도 아주 느리게 다가갑니다. 마지막으로 a₁₁ = 1, a₁₂ = 2로 맞춰 두 직선을 완전히 겹쳐 보세요. 조건수가 ∞가 됩니다.

책의 기호 읽는 법

책에 적힌 모양읽는 법뜻파이썬에서는
A=LUA = LU, PA=LUPA = LU엘유 분해아래삼각 × 위삼각, PP는 행 바꾸기 기록lu_nopivot(A) (SciPy: lu_factor)
LL엘대각선이 1인 아래삼각 행렬, 곱수를 적어 둔 것np.tril(A) 모양
x(k)\mathbf{x}^{(k)}엑스 위첨자 케이kk번째 반복의 값 (거듭제곱이 아님)반복문 안의 x
∑j≠i\sum_{j \ne i}제이가 아이가 아닌 것의 합대각선을 뺀 나머지 합A[i] @ x - A[i, i] * x[i]
ρ\rho로한 번 반복할 때 오차가 줄어드는 비율 (1보다 작아야 수렴)
∥x∥\|\mathbf{x}\|노름벡터의 길이np.linalg.norm(x)
κ(A)\kappa(A), cond⁡(A)\operatorname{cond}(A)카파, 조건수입력 오차가 답에서 커지는 최대 배율np.linalg.cond(A)

실무에서는

import scipy.linalg
lu, piv = scipy.linalg.lu_factor(A)      # PA = LU 를 한 번만 (부분 피벗팅 포함)
for b in hourly_demands:                 # 매시간 대입만
    x = scipy.linalg.lu_solve((lu, piv), b)

import scipy.sparse, scipy.sparse.linalg
T = scipy.sparse.diags([-1, 3, -1], [-1, 0, 1], shape=(n, n), format="csr")  # 0이 아닌 칸만 저장
x = scipy.sparse.linalg.spsolve(T, d)    # 희소 행렬 전용 소거
x, info = scipy.sparse.linalg.cg(T, d)   # 켤레 기울기법: 대칭·양의 정부호일 때 쓰는 반복법
scipy.linalg.solve_banded((1, 1), ab, d) # 띠 행렬 전용 (삼중대각이면 O(n))

np.linalg.cond(A)                        # 조건수 (SVD로 계산해서 큰 행렬에는 비싸다)
  • 같은 행렬로 여러 번 풀면 lu_factor + lu_solve. 오른쪽 변을 한꺼번에 모을 수 있으면 np.linalg.solve(A, B)도 내부에서 한 번만 분해합니다.
  • 큰 희소 행렬은 scipy.sparse로 저장하고, 직접법(spsolve)이나 반복법(cg, gmres)으로 풉니다. 실무의 반복법은 야코비·가우스-자이델보다 훨씬 빠른 크릴로프(Krylov) 방법들이지만, "한 번에 조금씩 고친다"는 생각은 같습니다.
  • 조건수가 크게 나오면 계산을 의심하기 전에 모델을 의심하세요. 거의 같은 식이 두 개 들어 있지 않은지, 단위가 너무 다른 변수가 섞여 있지 않은지(예: kW와 W) 봅니다.

자주 하는 실수 (3분)

LU 분해를 해 놓고 매번 solve를 부르기. np.linalg.solve(A, b)는 부를 때마다 처음부터 분해합니다. 같은 행렬이면 분해 결과를 저장해 두거나, 오른쪽 변을 열로 모아 한 번에 부르세요.

반복 횟수를 고정해 두고 끝내기. for _ in range(10)처럼 횟수만 정하면 발산해도, 아직 덜 수렴했어도 모릅니다. 잔차나 두 회차의 차이로 멈추고, 최대 횟수를 넘으면 경고를 내세요.

식의 순서를 아무렇게나 두기. 같은 연립방정식도 순서에 따라 가우스-자이델이 수렴하기도, 발산하기도 합니다. 반복법을 쓰기 전에 큰 수가 대각선에 오도록 행을 정렬하세요.

잔차가 작으니 답이 맞다고 믿기. 조건수가 크면 잔차가 작아도 답이 크게 틀릴 수 있습니다. 답에서 믿을 수 있는 자릿수는 16 − log₁₀ κ 정도입니다.

연습 문제 (5분)

  1. 충전소 4곳의 행렬 A4를 lu_nopivot으로 분해해 보세요. L과 U에도 0이 많이 남나요? 이것이 큰 삼중대각 행렬에 어떤 의미가 있을까요?
  2. A4를 0에서 출발한 가우스-자이델로 풀 때, 잔차의 가장 큰 성분이 10⁻⁶ 아래가 되려면 몇 번 고쳐야 하나요?
  3. 6 × 6 힐베르트 행렬의 조건수를 구하고, 답에서 몇 자리가 남을지 어림한 뒤 실제 오차와 견주어 보세요.
  4. Q = [[1, 3], [2, 1]], 답 (1, 2)인 연립방정식을 가우스-자이델로 10번 풀어 보세요. 행 순서를 바꾸면 어떻게 될까요?

풀이

>>> L4, U4 = lu_nopivot(A4)
>>> np.round(L4, 3)
array([[ 1.   ,  0.   ,  0.   ,  0.   ],
       [-0.333,  1.   ,  0.   ,  0.   ],
       [ 0.   , -0.375,  1.   ,  0.   ],
       [ 0.   ,  0.   , -0.381,  1.   ]])
>>> np.round(U4, 3)
array([[ 3.   , -1.   ,  0.   ,  0.   ],
       [ 0.   ,  2.667, -1.   ,  0.   ],
       [ 0.   ,  0.   ,  2.625, -1.   ],
       [ 0.   ,  0.   ,  0.   ,  2.619]])
>>> x = np.zeros(4)
>>> k = 0
>>> while np.abs(d4 - A4 @ x).max() > 1e-6:
...     x = gauss_seidel(A4, d4, x, 1)
...     k += 1
>>> k
15
>>> H6 = 1 / (np.arange(6)[:, None] + np.arange(6) + 1)
>>> f"{np.linalg.cond(H6):.1e}"
'1.5e+07'
>>> x6 = np.linalg.solve(H6, H6 @ np.ones(6))
>>> f"{np.abs(x6 - 1).max():.1e}"
'2.4e-10'
>>> Q = np.array([[1.0, 3], [2, 1]])
>>> q = Q @ np.array([1.0, 2])
>>> gauss_seidel(Q, q, np.zeros(2), 10)
array([ 6.0466177e+07, -1.2093235e+08])
>>> gauss_seidel(Q[[1, 0]], q[[1, 0]], np.zeros(2), 10)
array([1.0000001 , 1.99999997])

1번은 L에 대각선 바로 아래, U에 대각선과 바로 위만 남습니다. 삼중대각 행렬은 소거해도 0이 채워지지 않아서, 0이 아닌 칸만 다루면 LU 분해와 대입이 모두 $n$에 비례합니다. 이것을 토마스 알고리즘(Thomas algorithm)이라고 부르고, SciPy의 solve_banded가 하는 일입니다.

2번은 15번입니다. 8-2의 표에서 16번에 잔차가 2.0e-07이었던 것과 맞습니다. 이런 식으로 "잔차가 기준 아래가 될 때까지"를 정지 조건으로 씁니다.

3번은 조건수가 약 1.5 × 10⁷이니 약 7자리를 잃고 9자리쯤 남는다고 어림합니다. 실제 오차는 2.4 × 10⁻¹⁰으로 9~10자리가 맞습니다.

4번은 Q가 대각 우세가 아니라(1행 |1| < |3|) 1억 단위로 발산합니다. 행을 바꾸면 [[2, 1], [1, 3]]이 되어 두 행 모두 대각 우세이고, 10번 만에 (1, 2)에 거의 붙습니다. 2 × 2 비율로는 ρ = 6에서 1/6이 됩니다.

오 박사의 계산 노트

  • LU 분해: 소거의 곱수를 L에 적어 두면 A = LU (피벗팅까지 넣으면 PA = LU). 분해는 한 번(2n³/3), 풀이는 b마다 전진·후진 대입(2n²).

  • 야코비는 지난 값으로 한꺼번에, 가우스-자이델은 방금 고친 값을 바로 써서 고친다. 한 회차가 0이 아닌 칸 수에 비례한다.

  • 대각 우세(|a_ii| > 나머지 합)면 둘 다 수렴한다. 같은 식도 순서를 바꾸면 발산할 수 있다.

  • 희소 행렬은 0이 아닌 칸만 저장하고 계산한다. 10만 × 10만이 80 GB에서 2.4 MB로.

  • 조건수 κ = 입력 오차가 답에서 커지는 최대 배율. 잃는 자릿수 ≈ log₁₀ κ. np.linalg.cond(A).

  • 잔차가 작아도 κ가 크면 답을 믿을 수 없다. 피벗팅은 계산 방법의 오차를, 조건수는 문제 자체의 예민함을 말한다.

그날 오후, 정비 팀이 관제실에 노트 한 권을 놓고 갑니다. 3호기 배터리의 방전 기록입니다. 1분마다 잰 남은 전력이 점으로 찍혀 있는데, 점들이 한 줄로 늘어서지 않고 들쭉날쭉합니다. "이 기록으로 남은 비행 시간을 미리 알 수 있을까요?" 점은 수십 개인데 직선을 정하는 미지수는 두 개뿐입니다. 식이 미지수보다 많은 연립방정식, 다음 교시의 최소제곱 회귀입니다.

← 이전

7교시 · 배송 허브 세 곳의 물량 맞추기: 가우스 소거와 피벗

다음 →

9교시 · 센서 기록에 선 긋기: 최소제곱 회귀

새 교시·새 강의 소식 받기

이 강의에 새 교시가 더해지거나 이어지는 강의가 열리면 이메일로 알려 드려요.

수집 항목: 이메일 주소 · 목적: 이 강의의 새 교시·공개 알림 · 보유 기간: 해지를 요청할 때까지. 다른 용도로 쓰거나 남에게 넘기지 않아요. 알림 메일에 답장하면 바로 지워 드려요.

질문과 답변

이 교시에서 궁금한 점을 남겨 주세요. 모든 언어 페이지의 질문이 함께 모입니다.

링크와 서식 없이 글자 그대로 표시됩니다.

R

ReflexKR

눌러 보며 배우는 파이썬과 Reflex. 모든 예제는 Reflex 0.10.0에서 직접 실행해 확인했습니다.

ReflexKR은 배너 광고 없이 무료로 운영돼요. 도움이 되셨다면 커피 한 잔으로 응원해 주세요.

제휴 링크와 후원 안내AI의 일기장: 이 사이트를 만드는 AI가 쓰는 글
한국어

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex