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 6. 열공간과 영공간 — 파이썬 실습

Column Space and Nullspace — 실습

L6 서술 파트에서 열공간과 영공간을 정의하고, 해가 있다는 것이 b\vv{b} 가 열공간에 속한다는 뜻임을 보았다. 이 노트북에서는 그 집합들을 실제로 만들어 보고, 소속을 판정하는 함수를 직접 짜 보도록 하자.

서술 파트의 내용여기서 확인하는 방법
C(A)C(A)AxA\vv{x} 가 도달하는 모든 곳무작위 x\vv{x} 를 많이 만들어 어디에 떨어지는지 본다
해가 있다 \Leftrightarrow bC(A)\vv{b} \in C(A)판정 함수를 세 가지 방법으로 만든다
N(A)N(A)Ax=0A\vv{x} = \vv{0} 인 모든 x\vv{x}손으로 구한 답과 scipy 를 대조한다
두 공간은 서로 다른 곳에 산다2×32 \times 3 행렬로 크기를 확인한다
해집합 =xp+N(A)= \vv{x}_p + N(A)평행이동을 직접 만들어 검산한다

0. 준비

import numpy as np
import plotly.graph_objects as go

from linalg_viz import COLORS, arrow, plane, layout3d, spin_figure, show_matrix

np.set_printoptions(precision=3, suppress=True)
rng = np.random.default_rng(0)
print("numpy", np.__version__)
numpy 2.5.2
A = np.array([[1,  2, 3],
              [2,  5, 7],
              [6, -3, 3]], dtype=float)      # col3 = col1 + col2

print(show_matrix(A, "A ="))
print("col1 + col2 =", A[:, 0] + A[:, 1])
print("col3        =", A[:, 2])
print("rank(A) =", np.linalg.matrix_rank(A))
A =
[   1    2    3 ]
[   2    5    7 ]
[   6   -3    3 ]
col1 + col2 = [3. 7. 3.]
col3        = [3. 7. 3.]
rank(A) = 2

1. 열공간은 AxA\vv{x} 가 도달하는 곳 전부

정의를 그대로 실험해 보자. x\vv{x} 를 무작위로 아주 많이 만들어 AxA\vv{x} 를 계산하면, 그 결과들이 어디에 떨어지는가.

X = rng.standard_normal((500, 3))       # 무작위 x 500개
Y = X @ A.T                             # 각 행이 A x

법선 = np.cross(A[:, 0], A[:, 1])
print("두 열의 외적 (평면의 법선) :", 법선)
print()
print("모든 A x 가 그 평면 위에 있는가 :", np.allclose(Y @ 법선, 0))
print("법선과의 내적 중 최댓값 :", np.abs(Y @ 법선).max())
두 열의 외적 (평면의 법선) : [-36.  15.   1.]

모든 A x 가 그 평면 위에 있는가 : True
법선과의 내적 중 최댓값 : 6.439293542825908e-14

500개를 아무렇게나 만들었는데 전부 한 평면 위에 떨어졌다. x\vv{x} 는 3차원 전체를 훑었지만 AxA\vv{x} 는 평면을 벗어나지 못한다. 그림으로 보자.

표본 = Y[:250]

traces = [
    plane(spans=[A[:, 0], A[:, 1]], extent=9, color=COLORS["colspace"],
          opacity=0.30, name="C(A)"),
    go.Scatter3d(x=표본[:, 0], y=표본[:, 1], z=표본[:, 2], mode="markers",
                 marker=dict(size=2.5, color=COLORS["output"]), name="A x"),
]
traces += arrow([0, 0, 0], A[:, 0], color=COLORS["input"], name="col 1")
traces += arrow([0, 0, 0], A[:, 1], color=COLORS["second"], name="col 2")

spin_figure(traces, title="무작위 x 를 넣어도 A x 는 평면을 벗어나지 않는다", extent=10)
Loading...

2. b\vv{b} 가 열공간 안에 있는가

서술 파트에서 법선과의 내적으로 판정했다. 그런데 그 방법은 열공간이 평면일 때만 쓸 수 있다. 크기와 모양에 상관없이 쓸 수 있는 방법은 랭크를 비교하는 것이다.

b\vv{b}C(A)C(A) 안에 있다면 b\vv{b} 를 열로 하나 더 붙여도 열들이 만드는 공간이 넓어지지 않는다. 반대로 밖에 있다면 공간이 한 차원 넓어진다.

def 열공간에_있는가(A, b, 기준=1e-10):
    """b 가 C(A) 안에 있는지 판정한다.
    b 를 열로 덧붙여도 랭크가 그대로면 b 는 이미 열들의 결합으로 만들 수 있다."""
    확장 = np.column_stack([A, b])
    return np.linalg.matrix_rank(A) == np.linalg.matrix_rank(확장)
b1 = np.array([3.0, 7.0, 3.0])      # col1 + col2
b2 = np.array([6.0, 4.0, 2.0])

for 이름, b in [("b1", b1), ("b2", b2)]:
    확장 = np.column_stack([A, b])
    print(f"{이름} = {b}")
    print(f"  rank(A) = {np.linalg.matrix_rank(A)},"
          f"  rank([A | b]) = {np.linalg.matrix_rank(확장)}")
    print(f"  법선과의 내적 = {법선 @ b:.1f}")
    print(f"  판정 : {'열공간 안에 있다' if 열공간에_있는가(A, b) else '열공간 밖에 있다'}")
    print()
b1 = [3. 7. 3.]
  rank(A) = 2,  rank([A | b]) = 2
  법선과의 내적 = 0.0
  판정 : 열공간 안에 있다

b2 = [6. 4. 2.]
  rank(A) = 2,  rank([A | b]) = 3
  법선과의 내적 = -154.0
  판정 : 열공간 밖에 있다

세 번째 방법도 있다. np.linalg.lstsqAxA\vv{x}b\vv{b} 에 최대한 가깝게 만드는 x\vv{x} 를 찾아 준다. 해가 있다면 남는 오차가 0이고, 없다면 0이 아니다. 그리고 그 오차는 b\vv{b} 에서 열공간까지의 거리이다.

for 이름, b in [("b1", b1), ("b2", b2)]:
    x_ls, *_ = np.linalg.lstsq(A, b, rcond=None)
    남은오차 = np.linalg.norm(A @ x_ls - b)
    print(f"{이름} : |A x - b| 의 최솟값 = {남은오차:.3e}")
b1 : |A x - b| 의 최솟값 = 2.035e-15
b2 : |A x - b| 의 최솟값 = 3.947e+00

b1\vv{b}_1 은 오차가 사실상 0이고 b2\vv{b}_2 는 그렇지 않다. 서술 파트의 그림에서 본 빨간 점선의 길이가 이 값이다. 확인해 보자.

거리 = abs(법선 @ b2) / np.linalg.norm(법선)
print("법선으로 계산한 거리 :", 거리)

x_ls, *_ = np.linalg.lstsq(A, b2, rcond=None)
print("lstsq 가 남긴 오차   :", np.linalg.norm(A @ x_ls - b2))
법선으로 계산한 거리 : 3.9474205220528233
lstsq 가 남긴 오차   : 3.9474205220528233

3. 영공간 구하기

서술 파트에서 손으로 구한 답은 (1,1,1)(1, 1, -1) 방향의 직선이었다. scipy 로 확인해 보자.

from scipy.linalg import null_space

N = null_space(A)
print(show_matrix(N, "null_space(A) ="))
print("모양 :", N.shape, " -> 기저 벡터", N.shape[1], "개, 즉 직선")
print()
print("A @ N =", (A @ N).ravel(), " (영벡터)")
null_space(A) =
[  -0.577 ]
[  -0.577 ]
[   0.577 ]
모양 : (3, 1)  -> 기저 벡터 1 개, 즉 직선

A @ N = [0. 0. 0.]  (영벡터)

null_space 는 길이가 1인 기저를 돌려주므로 우리가 구한 (1,1,1)(1,1,-1) 과 크기가 다르다. 첫 성분이 1이 되도록 나눠 보면 같은 방향이라는 것을 알 수 있다.

방향 = N[:, 0] / N[0, 0]
print("정규화한 방향 :", 방향)
print("손으로 구한 값과 같은가 :", np.allclose(방향, [1, 1, -1]))
print()
print("A @ [1, 1, -1] =", A @ np.array([1.0, 1.0, -1.0]))
정규화한 방향 : [ 1.  1. -1.]
손으로 구한 값과 같은가 : True

A @ [1, 1, -1] = [0. 0. 0.]

영공간이 원점보다 크므로 이 행렬에는 역행렬이 없다.

print("det(A) =", np.linalg.det(A))
try:
    np.linalg.inv(A)
except np.linalg.LinAlgError as err:
    print("역행렬 없음 :", err)
det(A) = 0.0
역행렬 없음 : Singular matrix

4. 두 공간은 서로 다른 곳에 산다

2×32 \times 3 행렬로 확인해 보자. 열공간은 R2\R^2 안에, 영공간은 R3\R^3 안에 있다.

B = np.array([[1, 2, 3],
              [2, 4, 6]], dtype=float)

NB = null_space(B)

print("B 의 크기 :", B.shape)
print("rank(B)   :", np.linalg.matrix_rank(B))
print()
print(f"C(B) : R^{B.shape[0]} 안,  차원 {np.linalg.matrix_rank(B)}  -> 직선")
print(f"N(B) : R^{B.shape[1]} 안,  차원 {NB.shape[1]}  -> 평면")
B 의 크기 : (2, 3)
rank(B)   : 1

C(B) : R^2 안,  차원 1  -> 직선
N(B) : R^3 안,  차원 2  -> 평면
print(show_matrix(NB, "N(B) 의 기저 (열이 기저 벡터)"))
print()
for j in range(NB.shape[1]):
    v = NB[:, j]
    print(f"  기저 {j + 1} : x1 + 2x2 + 3x3 = {v[0] + 2*v[1] + 3*v[2]:+.2e}")
print()
print("B @ NB =")
print(show_matrix(B @ NB))
N(B) 의 기저 (열이 기저 벡터)
[   0.945   -0.189 ]
[  -0.309   -0.787 ]
[  -0.109    0.588 ]

  기저 1 : x1 + 2x2 + 3x3 = +2.22e-16
  기저 2 : x1 + 2x2 + 3x3 = -2.22e-16

B @ NB =
[   2.22e-16   -2.22e-16 ]
[   4.44e-16   -4.44e-16 ]

두 기저 벡터 모두 x1+2x2+3x3=0x_1 + 2x_2 + 3x_3 = 0 을 만족한다. 서술 파트에서 구한 평면과 같다.

여러 크기의 행렬에 대해 두 공간이 어디에 사는지 한 번에 보자.

예제들 = {
    "A (3x3, rank 2)": A,
    "B (2x3, rank 1)": B,
    "3x2 (rank 2)": np.array([[1., 0], [0, 1], [1, 1]]),
    "4x2 (rank 1)": np.array([[1., 2], [2, 4], [3, 6], [4, 8]]),
}

print(f"{'행렬':<18}{'크기':>8}{'rank':>6}{'C 가 사는 곳':>14}{'N 이 사는 곳':>14}{'dim N':>7}")
for 이름, M in 예제들.items():
    m, n = M.shape
    r = np.linalg.matrix_rank(M)
    dimN = null_space(M).shape[1]
    print(f"{이름:<18}{f'{m}x{n}':>8}{r:>6}{f'R^{m}':>14}{f'R^{n}':>14}{dimN:>7}")
행렬                      크기  rank      C 가 사는 곳      N 이 사는 곳  dim N
A (3x3, rank 2)        3x3     2           R^3           R^3      1
B (2x3, rank 1)        2x3     1           R^2           R^3      2
3x2 (rank 2)           3x2     2           R^3           R^2      0
4x2 (rank 1)           4x2     1           R^4           R^2      1

rankdim N 을 더하면 언제나 열의 개수 nn 이 된다. 왜 그런지는 다음 강의에서 다룬다.

5. 영공간은 “같은 곳으로 가는 점들의 차이”

서술 파트에서 Au=AvA\vv{u} = A\vv{v}uvN(A)\vv{u} - \vv{v} \in N(A) 가 같은 말임을 보았다. 직접 만들어 확인해 보자.

u = rng.standard_normal(3)
v = u + 3.7 * 방향              # 영공간 방향으로 밀어낸 점

print("u =", u)
print("v =", v)
print()
print("A u =", A @ u)
print("A v =", A @ v)
print("같은 곳으로 가는가 :", np.allclose(A @ u, A @ v))
print()
print(f"v - u = {v - u}  ->  영공간 방향의 {(v - u)[0] / 방향[0]:.3f} 배")
u = [ 1.204 -0.588 -1.211]
v = [ 4.904  3.112 -4.911]

A u = [-3.605 -9.009  5.356]
A v = [-3.605 -9.009  5.356]
같은 곳으로 가는가 : True

v - u = [ 3.7  3.7 -3.7]  ->  영공간 방향의 3.700 배

서로 다른 두 점이 같은 곳으로 갔다. 도착점만 보고 어느 쪽에서 왔는지 알 수 없으므로 되돌리는 변환이 존재할 수 없다. 이것이 역행렬이 없다는 것의 의미이다.

6. 해집합은 영공간을 평행이동한 것

b1=(3,7,3)\vv{b}_1 = (3, 7, 3) 에 대해 xp=(1,1,0)\vv{x}_p = (1, 1, 0) 이 해였다. 여기에 영공간의 아무 원소나 더해도 여전히 해여야 한다.

x_p = np.array([1.0, 1.0, 0.0])
print("A x_p =", A @ x_p, "  b1 =", b1)
print()

for t in (-2.0, 0.0, 1.0, 3.5):
    x = x_p + t * 방향
    print(f"t = {t:>5} :  x = {np.round(x, 3)}   A x = {A @ x}")
A x_p = [3. 7. 3.]   b1 = [3. 7. 3.]

t =  -2.0 :  x = [-1. -1.  2.]   A x = [3. 7. 3.]
t =   0.0 :  x = [1. 1. 0.]   A x = [3. 7. 3.]
t =   1.0 :  x = [ 2.  2. -1.]   A x = [3. 7. 3.]
t =   3.5 :  x = [ 4.5  4.5 -3.5]   A x = [3. 7. 3.]

tt 가 무엇이든 AxA\vv{x} 는 그대로이다. 해가 무수히 많다는 것이 이런 뜻이다. 그림으로 보면 두 직선이 나란히 놓인다.

t = np.array([-3.0, 3.0])
영선 = t[:, None] * 방향
해선 = x_p + t[:, None] * 방향

traces = [
    go.Scatter3d(x=영선[:, 0], y=영선[:, 1], z=영선[:, 2], mode="lines",
                 line=dict(color=COLORS["nullspace"], width=6, dash="dash"),
                 name="N(A)  (원점을 지난다)"),
    go.Scatter3d(x=해선[:, 0], y=해선[:, 1], z=해선[:, 2], mode="lines",
                 line=dict(color=COLORS["output"], width=6),
                 name="A x = b1 의 해집합"),
    go.Scatter3d(x=[0], y=[0], z=[0], mode="markers",
                 marker=dict(size=7, color="red"), name="원점"),
]
traces += arrow([0, 0, 0], x_p, color=COLORS["input"], name="x_p")

spin_figure(traces, title="해집합은 영공간을 x_p 만큼 옮긴 것", extent=4)
Loading...
다른해 = x_p + 2 * 방향
print("다른 해 :", 다른해, "  A x =", A @ 다른해)

# 두 표현이 같은 집합인지 확인 : 차이가 영공간 안에 있으면 같다
차이 = 다른해 - x_p
print("두 해의 차이 :", 차이)
print("영공간 안에 있는가 :", np.allclose(A @ 차이, 0))
다른 해 : [ 3.  3. -2.]   A x = [3. 7. 3.]
두 해의 차이 : [ 2.  2. -2.]
영공간 안에 있는가 : True

마치며...

서술 파트의 내용이 노트북의 코드
C(A)C(A)AxA\vv{x} 가 도달하는 곳무작위 X @ A.T 가 한 평면에만 떨어진다
소속 판정열공간에_있는가 의 랭크 비교
열공간까지의 거리lstsq 가 남기는 오차
영공간scipy.linalg.null_space
두 공간이 사는 곳4절의 표
Au=AvuvN(A)A\vv{u} = A\vv{v} \Leftrightarrow \vv{u}-\vv{v} \in N(A)5절
해집합 =xp+N(A)= \vv{x}_p + N(A)6절의 두 평행선

더 해 볼 것

  1. 열공간에_있는가 를 랭크 대신 lstsq 의 오차로 판정하도록 다시 짜 보자. 두 방법의 결과가 항상 같은가? 오차 기준을 얼마로 두어야 하는가?

  2. 4절 표에서 rank + dim N = n 이 성립했다. 무작위 행렬 여러 개로 이 관계가 늘 성립하는지 확인해 보자. 성립하지 않는 반례를 만들 수 있는가?

  3. AA 의 열을 섞어서 순서를 바꾸면 C(A)C(A) 는 달라지는가? N(A)N(A) 는 어떤가? 코드로 확인하고 왜 그런지 설명해 보자.

  4. C(A)C(A)C(AT)C(A^{\mathsf{T}}) 를 각각 구해 보자. 두 공간의 차원은 어떤 관계인가?

다음 강의에서는 Ax=0A\vv{x} = \vv{0} 을 소거로 푸는 방법을 다룬다. 영공간의 기저를 손으로 구하는 절차가 거기서 나온다.