Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Lecture 1. 연립방정식의 기하학 — 파이썬 실습

The Geometry of Linear Equations — 실습

L1 서술 파트에서 Row picture와 Column picture를 살펴보았다. 이 노트북에서는 같은 내용을 numpy 로 직접 계산하고, 그림을 회전시켜 가며 확인해 보도록 하자.

서술 파트의 그림은 한 각도에서 찍은 정지 화면이었다. 3차원 그림은 각도에 따라 다르게 보이므로, 여기서는 마우스로 돌려 볼 수 있는 형태로 다시 그린다.

이 노트북에서 확인할 것은 다음 네 가지이다.

서술 파트의 내용여기서 확인하는 방법
각 행은 직선 또는 평면이다2차원 직선과 3차원 평면을 직접 그려 본다
각 열은 벡터이고, 해는 결합 계수이다계수를 슬라이더로 바꿔 가며 도달점을 본다
AxA\vv{x} 는 열들의 선형결합이다두 가지 계산 방법을 각각 함수로 짜서 대조한다
열이 한 평면에 놓이면 해가 없을 수 있다특이행렬을 만들어 두 관점에서 모두 확인한다

셀을 하나씩 실행하면서 읽고, 숫자를 바꿔 가며 결과가 어떻게 달라지는지 확인해 보자.

0. 준비

모든 강의는 이 셀로 시작한다. linalg_viz 는 이 교재의 공통 시각화 모듈이며 저장소 안에 들어 있다 (pip install -r requirements.txt 가 함께 설치한다).

함수 이름은 영문이 기준이고, 한글 별칭(화살표, 평면, 무대 등)도 같이 제공한다. 같은 함수이므로 어느 쪽을 써도 된다.

import numpy as np
import plotly.graph_objects as go

from linalg_viz import (COLORS, arrow, arrow2d, plane, chain,
                        layout2d, layout3d, slider_figure, spin_figure)

np.set_printoptions(precision=3, suppress=True)
print("numpy", np.__version__)
numpy 2.5.2

1. 2×2 시스템

서술 파트에서 다룬 시스템이다.

2xy=0,x+2y=32x - y = 0, \qquad -x + 2y = 3

계수만 모아 행렬 A2 로, 우변은 벡터 b2 로 적는다. numpy 에서 A2[i]ii번째 이고 A2[:, j]jj번째 이다. 이 구분이 이번 강의의 전부라고 해도 된다.

A2 = np.array([[ 2, -1],
               [-1,  2]], dtype=float)
b2 = np.array([0, 3], dtype=float)

x2 = np.linalg.solve(A2, b2)
print("A2 =\n", A2)
print("b2 =", b2)
print("해  =", x2)
A2 =
 [[ 2. -1.]
 [-1.  2.]]
b2 = [0. 3.]
해  = [1. 2.]

1-1. Row picture

ii번째 행 [ai1  ai2][a_{i1}\ \ a_{i2}] 와 우변 bib_i 는 직선 ai1x+ai2y=bia_{i1}x + a_{i2}y = b_i 를 나타낸다. yy 에 대해 정리하면 y=(biai1x)/ai2y = (b_i - a_{i1}x) / a_{i2} 이므로 이대로 그리면 된다.

t = np.linspace(-2, 4, 200)
traces = []

for i, (color, name) in enumerate([(COLORS["input"],  "2x - y = 0"),
                                   (COLORS["second"], "-x + 2y = 3")]):
    a, c = A2[i], b2[i]                       # i번째 '행' 과 우변
    traces.append(go.Scatter(x=t, y=(c - a[0] * t) / a[1], mode="lines",
                             line=dict(color=color, width=3), name=name))

traces.append(go.Scatter(x=[x2[0]], y=[x2[1]], mode="markers+text",
                         marker=dict(size=12, color="red"),
                         text=[f"({x2[0]:.0f}, {x2[1]:.0f})"],
                         textposition="top center", name="해"))

go.Figure(data=traces, layout=layout2d("Row picture : 두 직선의 교점", extent=4))
Loading...

두 직선의 교점이 해이다. 여기까지는 서술 파트의 첫 번째 그림과 같은 내용이다.

1-2. Column picture

이번에는 같은 행렬을 열로 나눈다. 열벡터에 계수를 곱해 꼬리에 꼬리를 물고 이어 붙이면 어디에 도달하는지 볼 수 있다.

col1, col2 = A2[:, 0], A2[:, 1]
print("col1 =", col1)
print("col2 =", col2)
print("b2   =", b2)
print()
print("해의 계수로 결합하면:", x2[0] * col1 + x2[1] * col2)
col1 = [ 2. -1.]
col2 = [-1.  2.]
b2   = [0. 3.]

해의 계수로 결합하면: [0. 3.]
x, y = x2
end1 = x * col1
end2 = end1 + y * col2

traces  = arrow2d([0, 0], end1, color=COLORS["input"],  name=f"{x:.0f} x col1")
traces += arrow2d(end1, end2,   color=COLORS["second"], name=f"{y:.0f} x col2")
traces += arrow2d([0, 0], b2,   color=COLORS["output"], name="b", dashed=True)

go.Figure(data=traces, layout=layout2d("Column picture : 두 열을 결합해 b 에 도달", extent=4))
Loading...

파란 화살표 끝에서 초록 화살표가 출발해 주황 점선의 끝에 정확히 닿는다.

위 셀에서 x, y = x2x, y = 2.0, 1.0 으로 바꿔 실행해 보자. 화살표의 끝이 b\vv{b} 에서 벗어나는 것을 볼 수 있다. 벗어난 정도가 곧 그 계수가 해가 아니라는 뜻이다.

2. 3×3 시스템의 Row picture

미지수가 세 개인 시스템으로 넘어간다.

x+2y+3z=62x+5y+2z=46x3y+z=2\begin{aligned} x + 2y + 3z &= 6 \\ 2x + 5y + 2z &= 4 \\ 6x - 3y + z &= 2 \end{aligned}
A = np.array([[1,  2, 3],
              [2,  5, 2],
              [6, -3, 1]], dtype=float)
b = np.array([6, 4, 2], dtype=float)

x3 = np.linalg.solve(A, b)
print("해     =", x3)
print("det(A) =", np.linalg.det(A))
print("A @ 해 =", A @ x3, " (b 와 같아야 한다)")
해     = [-0. -0.  2.]
det(A) = -77.00000000000001
A @ 해 = [6. 4. 2.]  (b 와 같아야 한다)

서술 파트에서 이 시스템의 해가 (0,0,2)(0, 0, 2) 라고 하였는데, 출력에는 -0. 이 보인다. np.linalg.solve 는 소거 과정을 부동소수점으로 계산하므로 결과에 아주 작은 반올림 오차가 남는다. 잠시 뒤 3절에서는 같은 값이 -1.48e-16 처럼 보이기도 한다. 수학적으로는 0이지만 계산 결과는 정확히 0이 아니다.

따라서 두 값이 같은지 확인할 때는 == 가 아니라 np.allclose 를 쓴다. 이 교재에서 계속 그렇게 한다.

각 행 ai\vv{a}_i 에 대해 aix=bi\vv{a}_i \cdot \vv{x} = b_i 는 법선벡터가 ai\vv{a}_i 인 평면이다. 이 평면은 원점을 지나지 않으므로 평행이동해야 하는데, 원점에서 가장 가까운 평면 위의 점은

pi=biaiaiai\vv{p}_i = \frac{b_i}{\vv{a}_i \cdot \vv{a}_i}\, \vv{a}_i

이다. plane(normal=..., through=...) 에 그대로 넘기면 된다.

PLANE_COLORS = ["#17becf", "#9467bd", "#8c564b"]


def three_planes(A, b, extent=4):
    """각 행을 법선벡터로 갖는 평면 세 장을 만든다."""
    out = []
    for i in range(3):
        n = A[i]                                  # i번째 행 = 법선벡터
        p = n * b[i] / (n @ n)                    # 평면 위의 한 점
        out.append(plane(normal=n, through=p, extent=extent,
                         color=PLANE_COLORS[i], opacity=0.35, name=f"식 {i+1}"))
    return out
traces = three_planes(A, b)
traces.append(go.Scatter3d(x=[x3[0]], y=[x3[1]], z=[x3[2]], mode="markers+text",
                           marker=dict(size=7, color="red"),
                           text=["해"], textposition="top center", name="해"))

spin_figure(traces, title="Row picture : 세 평면의 교점", extent=4)
Loading...

3. 3×3 시스템의 Column picture

같은 시스템을 열로 묶으면 다음과 같다.

x[126]+y[253]+z[321]=[642]x \begin{bmatrix} 1 \\ 2 \\ 6 \end{bmatrix} + y \begin{bmatrix} 2 \\ 5 \\ -3 \end{bmatrix} + z \begin{bmatrix} 3 \\ 2 \\ 1 \end{bmatrix} = \begin{bmatrix} 6 \\ 4 \\ 2 \end{bmatrix}

아래 슬라이더는 xx 를 바꿔 가며 결합의 도달점을 보여 준다. yyzz 는 해의 값으로 고정해 두었다.

columns = [A[:, 0], A[:, 1], A[:, 2]]
candidates = np.linspace(x3[0] - 3, x3[0] + 3, 25)    # 가운데(13번째)가 해
frames_data, labels = [], []

for x1 in candidates:
    traces, end = chain(columns, [x1, x3[1], x3[2]])
    traces += arrow([0, 0, 0], b, color=COLORS["output"], name="b")
    frames_data.append(traces)
    labels.append(f"{x1:.2f}")

slider_figure(frames_data, labels,
              layout3d("Column picture : 열을 결합해 b 에 도달", extent=9),
              prefix="x = ", initial=12)
Loading...
print("해의 첫 성분 x =", x3[0], "  (이론값은 0)")

reached = x3[0] * columns[0] + x3[1] * columns[1] + x3[2] * columns[2]
print("그때 도달점 =", reached)
print("b          =", b)
print("일치하는가 :", np.allclose(reached, b))
해의 첫 성분 x = -1.4802973661668753e-16   (이론값은 0)
그때 도달점 = [6. 4. 2.]
b          = [6. 4. 2.]
일치하는가 : True

서술 파트에서 본 것처럼 해는 (0,0,2)(0, 0, 2) 이다. 즉 첫 번째와 두 번째 열은 쓰지 않고 세 번째 열만 2배 하면 b\vv{b} 가 된다. 아래에서 직접 확인해 보자.

print("2 x col3 =", 2 * A[:, 2])
print("b        =", b)
2 x col3 = [6. 4. 2.]
b        = [6. 4. 2.]

4. AxA\vv{x} 를 두 가지 방법으로 계산하기

서술 파트에서 AxA\vv{x} 를 보는 두 가지 방법을 정리하였다. 하나는 행과 벡터의 내적으로 성분을 하나씩 만드는 방법이고, 다른 하나는 열들의 선형결합으로 결과 벡터를 한 번에 만드는 방법이다.

두 방법을 각각 함수로 짜서 결과가 같은지 확인해 보자.

def by_columns(A, x):
    """A x = x1*a1 + x2*a2 + ... (열들의 선형결합)"""
    out = np.zeros(A.shape[0])
    for j in range(A.shape[1]):
        out = out + x[j] * A[:, j]        # j번째 '열' 에 j번째 계수
    return out


def by_rows(A, x):
    """(A x)_i = i번째 행과 x의 내적"""
    return np.array([A[i, :] @ x for i in range(A.shape[0])])
x_test = np.array([1.0, -2.0, 0.5])

print("열 방식 :", by_columns(A, x_test))
print("행 방식 :", by_rows(A, x_test))
print("numpy   :", A @ x_test)
print()
print("셋이 같은가 :",
      np.allclose(by_columns(A, x_test), A @ x_test)
      and np.allclose(by_rows(A, x_test), A @ x_test))
열 방식 : [-1.5 -7.  12.5]
행 방식 : [-1.5 -7.  12.5]
numpy   : [-1.5 -7.  12.5]

셋이 같은가 : True

결과는 같다. 같은 계산을 다르게 정리한 것이므로 당연하다.

다만 by_rows 는 결과의 성분을 하나씩 만들어 내는 반면, by_columns 는 결과 벡터를 통째로 만들어 낸다. by_columnsfor 문 한 바퀴가 3절에서 이어 붙인 화살표 하나에 그대로 대응한다. 이 교재가 열 방식을 정의로 삼는 이유이다.

5. 열이 한 평면에 놓이는 경우

서술 파트 6절에서 다룬 행렬이다. 세 번째 열이 앞의 두 열의 합으로 되어 있다.

A=[123257633]A = \begin{bmatrix} 1 & 2 & 3 \\ 2 & 5 & 7 \\ 6 & -3 & 3 \end{bmatrix}
A_sing = np.array([[1,  2, 3],
                   [2,  5, 7],
                   [6, -3, 3]], dtype=float)

print("col1 + col2 =", A_sing[:, 0] + A_sing[:, 1])
print("col3        =", A_sing[:, 2])
print()
print("det  =", np.linalg.det(A_sing))
print("rank =", np.linalg.matrix_rank(A_sing))
col1 + col2 = [3. 7. 3.]
col3        = [3. 7. 3.]

det  = 0.0
rank = 2

행렬식이 0이고 랭크가 3이 아니라 2이다. 즉 세 열이 3차원을 다 채우지 못하고 평면 하나 안에 들어 있다. 이 경우 np.linalg.solve 는 해를 구하지 못한다.

try:
    np.linalg.solve(A_sing, b)
except np.linalg.LinAlgError as err:
    print("풀 수 없다 :", err)
풀 수 없다 : Singular matrix

5-1. b\vv{b} 가 그 평면 위에 있는지 확인하기

두 열의 외적을 구하면 평면의 법선벡터가 된다. 법선과 b\vv{b} 의 내적이 0이면 b\vv{b} 는 그 평면 위에 있다.

n = np.cross(A_sing[:, 0], A_sing[:, 1])     # 평면의 법선벡터
print("법선 n =", n)
print()
for name, v in [("col1", A_sing[:, 0]), ("col2", A_sing[:, 1]),
                ("col3", A_sing[:, 2]), ("b", b)]:
    print(f"n . {name:<5} = {n @ v:>8.2f}")
법선 n = [-36.  15.   1.]

n . col1  =     0.00
n . col2  =     0.00
n . col3  =     0.00
n . b     =  -154.00

세 열은 모두 법선과의 내적이 0이므로 같은 평면 위에 있다. 반면 b\vv{b} 는 0이 아니므로 그 평면 밖에 있다. 그래서 해가 존재하지 않는다.

traces = [plane(spans=[A_sing[:, 0], A_sing[:, 1]], extent=7,
                color=COLORS["colspace"], opacity=0.35, name="열들이 만드는 평면")]
traces += arrow([0, 0, 0], A_sing[:, 0], color=COLORS["input"],  name="col1")
traces += arrow([0, 0, 0], A_sing[:, 1], color=COLORS["second"], name="col2")
traces += arrow([0, 0, 0], A_sing[:, 2], color=COLORS["third"],  name="col3")
traces += arrow([0, 0, 0], b,            color=COLORS["output"], name="b")

spin_figure(traces, title="열은 평면 안에, b 는 평면 밖에", extent=8)
Loading...

5-2. b\vv{b} 를 평면 안으로 옮기면

b\vv{b} 를 평면 위의 벡터로 바꾸면 해가 생긴다. 가장 간단한 예로 첫 번째 열 자신을 우변으로 두면 (1,0,0)(1, 0, 0) 이 해가 된다. 그런데 세 번째 열이 앞의 두 열로 만들어지므로, 같은 곳에 도달하는 결합 방법이 하나가 아니다.

b_in = A_sing[:, 0].copy()          # b 를 col1 로 두면 평면 위에 있다
print("n . b_in =", n @ b_in, " (0 이므로 평면 위)")
print()

for x_try in ([1, 0, 0], [0, -1, 1], [2, 1, -1]):
    x_try = np.array(x_try, dtype=float)
    print(f"x = {x_try}  ->  A x = {A_sing @ x_try}")
n . b_in = 0.0  (0 이므로 평면 위)

x = [1. 0. 0.]  ->  A x = [1. 2. 6.]
x = [ 0. -1.  1.]  ->  A x = [1. 2. 6.]
x = [ 2.  1. -1.]  ->  A x = [1. 2. 6.]

서로 다른 세 개의 x\vv{x} 가 모두 같은 b\vv{b} 를 만든다. 즉 해가 무수히 많다.

정리하면 특이행렬의 경우 b\vv{b} 가 열들이 만드는 평면 밖에 있으면 해가 없고, 평면 위에 있으면 해가 무수히 많다. 해가 정확히 하나인 경우는 없다.

마치며...

이번 실습에서 확인한 것을 정리하면 다음과 같다.

서술 파트의 내용이 노트북의 코드
각 행은 직선 또는 평면이다plane(normal=A[i], through=p)
각 열은 벡터이다A[:, j]
AxA\vv{x} 는 열들의 선형결합이다by_columns(A, x)for
해는 결합 계수이다3절의 슬라이더
열이 한 평면에 있으면 해가 없을 수 있다5절의 n @ b

더 해 볼 것

  1. A 의 세 번째 (열이 아니라)을 앞의 두 행의 합으로 바꿔 보자. 행렬식과 랭크는 어떻게 되는가? Row picture와 Column picture 중 어느 쪽이 먼저 이상해 보이는가?

  2. 2×2로 돌아가 A2 = [[2, -1], [-4, 2]] 로 바꿔 보자. 두 직선은 어떻게 배치되는가? 두 열벡터는 어떤 관계인가?

  3. by_columnsfor 문 없이 한 줄로 써 보자. A @ x 는 쓰지 않는다. 힌트: A * x 가 무엇을 하는지 먼저 확인해 보자.

다음 강의에서는 연립방정식을 기계적으로 푸는 방법인 소거법(Elimination)을 다룬다.