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 10. 네 개의 기본 부분공간 — 파이썬 실습

The Four Fundamental Subspaces — 실습

L10 서술 파트에서 네 부분공간과 차원의 기본정리를 다루었다. 이 노트북에서는 네 기저를 모두 구해 보고, AA 가 행공간을 열공간으로 옮기고 영공간을 0으로 뭉갠다는 것을 분해로 확인해 보도록 하자.

서술 파트의 내용여기서 확인하는 방법
네 부분공간의 기저네 가지를 모두 구해 차원을 센다
차원의 기본정리r+(nr)=nr + (n-r) = n, r+(mr)=mr + (m-r) = m 을 확인한다
행랭크 == 열랭크무작위 행렬로 여러 번 시험한다
열공간 기저를 RR 에서 가져오면 안 된다잘못 가져왔을 때 무엇이 깨지는지 본다
AA 는 행공간을 옮기고 영공간을 뭉갠다x\vv{x} 를 두 조각으로 분해한다

0. 준비

import numpy as np
import sympy as sp
import plotly.graph_objects as go
from scipy.linalg import null_space

from linalg_viz import COLORS, plane, spin_figure, show_matrix

np.set_printoptions(precision=3, suppress=True)
rng = np.random.default_rng(0)
print("numpy", np.__version__, " sympy", sp.__version__)
numpy 2.5.2  sympy 1.14.0
A = np.array([[1, 2, 2,  2],
              [2, 4, 6,  8],
              [3, 6, 8, 10]], dtype=float)

m, n = A.shape
r = np.linalg.matrix_rank(A)
print(show_matrix(A, "A ="))
print(f"m = {m},  n = {n},  r = {r}")
A =
[   1    2    2    2 ]
[   2    4    6    8 ]
[   3    6    8   10 ]
m = 3,  n = 4,  r = 2

1. 네 부분공간의 기저 구하기

각 공간마다 구하는 방법이 다르다. 서술 파트의 표를 그대로 코드로 옮긴다.

R_sym, 피벗 = sp.Matrix(A.astype(int)).rref()
피벗열 = list(피벗)
R = np.array(R_sym.tolist(), dtype=float)

print(show_matrix(R, "R (RREF) ="))
print("피벗 열 :", 피벗열)
R (RREF) =
[   1    2    0   -2 ]
[   0    0    1    2 ]
[   0    0    0    0 ]
피벗 열 : [0, 2]
행공간기저 = R[:r].T                 # R 의 0이 아닌 행을 열로 세운다
열공간기저 = A[:, 피벗열]            # 원래 A 의 피벗 열
영공간기저 = null_space(A)
좌영공간기저 = null_space(A.T)

for 이름, B, 사는곳 in [
    ("행공간  C(A^T)", 행공간기저, f"R^{n}"),
    ("영공간  N(A)", 영공간기저, f"R^{n}"),
    ("열공간  C(A)", 열공간기저, f"R^{m}"),
    ("좌영공간 N(A^T)", 좌영공간기저, f"R^{m}"),
]:
    print(f"{이름:<16} {사는곳:>4} 안,  기저 {B.shape[1]}개,  각 벡터 길이 {B.shape[0]}")
행공간  C(A^T)       R^4 안,  기저 2개,  각 벡터 길이 4
영공간  N(A)         R^4 안,  기저 2개,  각 벡터 길이 4
열공간  C(A)         R^3 안,  기저 2개,  각 벡터 길이 3
좌영공간 N(A^T)       R^3 안,  기저 1개,  각 벡터 길이 3
print(show_matrix(행공간기저, "행공간 기저 (열마다 하나)"))
print(show_matrix(열공간기저, "열공간 기저"))
print(show_matrix(영공간기저, "영공간 기저"))
print(show_matrix(좌영공간기저, "좌영공간 기저"))
행공간 기저 (열마다 하나)
[   1    0 ]
[   2    0 ]
[   0    1 ]
[  -2    2 ]
열공간 기저
[  1   2 ]
[  2   6 ]
[  3   8 ]
영공간 기저
[    0.907     0.069 ]
[   -0.409     0.378 ]
[  -0.0893    -0.826 ]
[   0.0446     0.413 ]
좌영공간 기저
[  -0.577 ]
[  -0.577 ]
[   0.577 ]

좌영공간 기저를 첫 성분이 -1 이 되도록 맞추면 L8에서 손으로 구한 y=(1,1,1)\vv{y} = (-1,-1,1) 이 된다.

y = 좌영공간기저[:, 0]
y = y / y[0] * -1

print("y =", np.round(y, 10) + 0.0)
print("y^T A =", np.round(y @ A, 10) + 0.0, " (영벡터)")
y = [-1. -1.  1.]
y^T A = [0. 0. 0. 0.]  (영벡터)

2. 차원의 기본정리

dim_행 = 행공간기저.shape[1]
dim_영 = 영공간기저.shape[1]
dim_열 = 열공간기저.shape[1]
dim_좌영 = 좌영공간기저.shape[1]

print(f"정의역 쪽 :  dim C(A^T) + dim N(A)   = {dim_행} + {dim_영} = {dim_행 + dim_영}"
      f"   (n = {n})")
print(f"공역 쪽   :  dim C(A) + dim N(A^T)   = {dim_열} + {dim_좌영} = {dim_열 + dim_좌영}"
      f"   (m = {m})")
print()
print("네 차원 :", dim_행, dim_영, dim_열, dim_좌영, "  (r, n-r, r, m-r 과 같아야 한다)")
print("예상값   :", r, n - r, r, m - r)
정의역 쪽 :  dim C(A^T) + dim N(A)   = 2 + 2 = 4   (n = 4)
공역 쪽   :  dim C(A) + dim N(A^T)   = 2 + 1 = 3   (m = 3)

네 차원 : 2 2 2 1   (r, n-r, r, m-r 과 같아야 한다)
예상값   : 2 2 2 1

여러 크기의 행렬로도 확인해 보자. 랭크를 원하는 값으로 만들려면 얇은 두 행렬을 곱하면 된다. UUm×km \times k 이고 VVk×nk \times n 이면 UVUV 의 랭크는 보통 kk 이다.

print(f"{'m':>3}{'n':>4}{'r':>4}{'dimC^T':>9}{'dimN':>7}{'dimC':>7}{'dimN^T':>9}"
      f"{'합 = n':>9}{'합 = m':>9}")
for m_i, n_i, k in [(3, 4, 2), (5, 3, 2), (2, 6, 2), (4, 4, 4), (6, 2, 1)]:
    M = rng.standard_normal((m_i, k)) @ rng.standard_normal((k, n_i))
    r_i = np.linalg.matrix_rank(M)
    a, b = null_space(M).shape[1], null_space(M.T).shape[1]
    print(f"{m_i:>3}{n_i:>4}{r_i:>4}{r_i:>9}{a:>7}{r_i:>7}{b:>9}"
          f"{r_i + a:>9}{r_i + b:>9}")
  m   n   r   dimC^T   dimN   dimC   dimN^T    합 = n    합 = m
  3   4   2        2      2      2        1        4        3
  5   3   2        2      1      2        3        3        5
  2   6   2        2      4      2        0        6        2
  4   4   4        4      0      4        0        4        4
  6   2   1        1      1      1        5        2        6

3. 행랭크와 열랭크는 같다

서술 파트에서 증명한 것이다. 자명하지 않은 사실이므로 여러 행렬로 확인해 보자.

다른경우 = 0
for _ in range(300):
    m_i, n_i = rng.integers(1, 7, size=2)
    k = rng.integers(1, min(m_i, n_i) + 1)
    M = rng.standard_normal((m_i, k)) @ rng.standard_normal((k, n_i))
    if np.linalg.matrix_rank(M) != np.linalg.matrix_rank(M.T):
        다른경우 += 1

print(f"무작위 행렬 300개 중 rank(A) != rank(A^T) 인 경우 : {다른경우}")
무작위 행렬 300개 중 rank(A) != rank(A^T) 인 경우 : 0

소거로도 확인할 수 있다. 피벗 열의 개수와 RR 의 0이 아닌 행의 개수가 같아야 한다.

피벗개수 = len(피벗열)
영아닌행수 = int(np.sum(np.abs(R).sum(axis=1) > 1e-10))

print(f"피벗 열의 개수        : {피벗개수}")
print(f"R 의 0이 아닌 행 개수 : {영아닌행수}")
print(f"같은가 : {피벗개수 == 영아닌행수}")
피벗 열의 개수        : 2
R 의 0이 아닌 행 개수 : 2
같은가 : True

4. 열공간의 기저를 RR 에서 가져오면 어떻게 되는가

서술 파트에서 경고한 함정이다. 작은 예로 먼저 보자.

A2 = np.array([[1.0, 2], [3.0, 6]])
R2_sym, 피벗2 = sp.Matrix(A2.astype(int)).rref()
R2 = np.array(R2_sym.tolist(), dtype=float)

print(show_matrix(A2, "A ="))
print(show_matrix(R2, "R ="))
print()
print("C(A) 의 기저 (A 의 1열) :", A2[:, 0])
print("C(R) 의 기저 (R 의 1열) :", R2[:, 0])
A =
[  1   2 ]
[  3   6 ]
R =
[  1   2 ]
[  0   0 ]

C(A) 의 기저 (A 의 1열) : [1. 3.]
C(R) 의 기저 (R 의 1열) : [1. 0.]
b = np.array([2.0, 6.0])       # A 의 2열, 당연히 C(A) 안에 있다


def 열공간에_있는가(B, v):
    return np.linalg.matrix_rank(B) == np.linalg.matrix_rank(np.column_stack([B, v]))


print("b =", b)
print("  C(A) 안에 있는가 :", 열공간에_있는가(A2, b))
print("  C(R) 안에 있는가 :", 열공간에_있는가(R2, b), " <- 잘못된 기저로는 만들 수 없다")
b = [2. 6.]
  C(A) 안에 있는가 : True
  C(R) 안에 있는가 : False  <- 잘못된 기저로는 만들 수 없다

큰 예제에서도 확인해 보자. RR 의 피벗 열을 열공간의 기저로 잘못 쓰면 원래 열들을 만들지 못한다.

올바른기저 = A[:, 피벗열]
잘못된기저 = R[:, 피벗열]

for 이름, B in [("A 의 피벗 열 (올바름)", 올바른기저), ("R 의 피벗 열 (잘못됨)", 잘못된기저)]:
    붙임 = np.column_stack([B, A])
    같은공간 = np.linalg.matrix_rank(붙임) == np.linalg.matrix_rank(B)
    print(f"{이름:<22} rank {np.linalg.matrix_rank(B)},"
          f"  A 의 모든 열을 만들 수 있는가 : {같은공간}")
A 의 피벗 열 (올바름)         rank 2,  A 의 모든 열을 만들 수 있는가 : True
R 의 피벗 열 (잘못됨)         rank 2,  A 의 모든 열을 만들 수 있는가 : False

피벗이 어느 열에 있는지는 RR 에서 읽어도 되지만, 벡터 자체는 AA 에서 가져와야 한다. 열들 사이의 종속 관계가 보존되는 것은 영공간이 같기 때문이다.

print("N(A) 와 N(R) 이 같은 공간인가 :",
      np.linalg.matrix_rank(np.column_stack([null_space(A), null_space(R)]))
      == null_space(A).shape[1])
N(A) 와 N(R) 이 같은 공간인가 : True

5. 좌영공간을 소거에서 읽기

[AI][A \mid I] 를 소거하면 [RE][R \mid E] 가 되고 EA=REA = R 이다. RR 의 행이 0인 자리의 EE 의 행이 좌영공간에 들어간다.

확장 = np.hstack([A, np.eye(m)]).astype(int)
RE_sym, _ = sp.Matrix(확장).rref()
RE = np.array(RE_sym.tolist(), dtype=float)

R_부분, E_부분 = RE[:, :n], RE[:, n:]

print(show_matrix(R_부분, "왼쪽 블록 (= R)"))
print(show_matrix(E_부분, "오른쪽 블록 (= E)"))
print()
print("E A 가 R 과 같은가 :", np.allclose(E_부분 @ A, R_부분))
왼쪽 블록 (= R)
[   1    2    0   -2 ]
[   0    0    1    2 ]
[   0    0    0    0 ]
오른쪽 블록 (= E)
[    0    -4     3 ]
[    0   1.5    -1 ]
[    1     1    -1 ]

E A 가 R 과 같은가 : True
영인행 = [i for i in range(m) if np.allclose(R_부분[i], 0)]
print("R 에서 0이 된 행 :", 영인행)

for i in 영인행:
    행 = E_부분[i]
    print(f"  E 의 {i + 1}행 = {행}   ->  (그 행) A = {np.round(행 @ A, 10) + 0.0}")
R 에서 0이 된 행 : [2]
  E 의 3행 = [ 1.  1. -1.]   ->  (그 행) A = [0. 0. 0. 0.]

null_space(A.T) 가 준 기저와 같은 공간을 만드는지 확인해 보자.

E기저 = E_부분[영인행].T
붙임 = np.column_stack([E기저, 좌영공간기저])
print("같은 공간인가 :",
      np.linalg.matrix_rank(붙임) == 좌영공간기저.shape[1])
같은 공간인가 : True

6. AA 는 행공간을 옮기고 영공간을 뭉갠다

서술 파트의 결론이다. 정의역의 아무 벡터나 잡아 행공간 조각과 영공간 조각으로 나눠 보자.

행공간 기저와 영공간 기저를 나란히 붙이면 Rn\R^n 전체의 기저가 된다. 차원의 합이 nn 이고 두 공간이 원점만 공유하기 때문이다. 그러므로 어떤 x\vv{x} 든 두 조각의 합으로 쓸 수 있다.

전체기저 = np.column_stack([행공간기저, 영공간기저])
print("붙인 행렬의 크기 :", 전체기저.shape)
print("랭크 :", np.linalg.matrix_rank(전체기저), f"  (n = {n} 이면 R^{n} 전체의 기저)")
붙인 행렬의 크기 : (4, 4)
랭크 : 4   (n = 4 이면 R^4 전체의 기저)
x = rng.standard_normal(n)
계수, *_ = np.linalg.lstsq(전체기저, x, rcond=None)

x_행 = 행공간기저 @ 계수[:r]         # 행공간 조각
x_영 = 영공간기저 @ 계수[r:]         # 영공간 조각

print("x     =", x)
print("x_행  =", x_행)
print("x_영  =", x_영)
print()
print("x_행 + x_영 = x 인가 :", np.allclose(x_행 + x_영, x))
print("A x_영 = 0 인가      :", np.allclose(A @ x_영, 0))
print("A x_행 = A x 인가    :", np.allclose(A @ x_행, A @ x))
x     = [-0.736  1.205  0.554 -0.907]
x_행  = [ 0.428  0.855  0.09  -0.675]
x_영  = [-1.164  0.35   0.464 -0.232]

x_행 + x_영 = x 인가 : True
A x_영 = 0 인가      : True
A x_행 = A x 인가    : True

영공간 조각은 통째로 사라지고, 행공간 조각만 AA 를 통과한다. 서술 파트에서 말한 "행공간을 열공간으로 옮기고 영공간을 0으로 뭉갠다"가 이것이다.

행공간의 서로 다른 두 벡터가 서로 다른 곳으로 가는지도 확인해 보자.

for _ in range(3):
    c1, c2 = rng.standard_normal(r), rng.standard_normal(r)
    u, v = 행공간기저 @ c1, 행공간기저 @ c2
    print(f"두 입력이 다른가 : {not np.allclose(u, v)},"
          f"   두 출력이 다른가 : {not np.allclose(A @ u, A @ v)},"
          f"   거리 {np.linalg.norm(A @ u - A @ v):.3f}")
두 입력이 다른가 : True,   두 출력이 다른가 : True,   거리 139.275
두 입력이 다른가 : True,   두 출력이 다른가 : True,   거리 57.332
두 입력이 다른가 : True,   두 출력이 다른가 : True,   거리 85.216

반대로 영공간 방향으로만 다른 두 벡터는 같은 곳으로 간다.

x2 = x + 영공간기저 @ rng.standard_normal(영공간기저.shape[1])
print("x 와 x2 가 다른가        :", not np.allclose(x, x2))
print("A x 와 A x2 가 같은가    :", np.allclose(A @ x, A @ x2))
x 와 x2 가 다른가        : True
A x 와 A x2 가 같은가    : True

7. 네 부분공간을 그려 보기

정사각 행렬을 쓰면 네 공간이 모두 R3\R^3 안에 있어 그릴 수 있다. L1에서 다룬 특이행렬을 쓴다.

A3 = np.array([[1.0, 2, 3],
               [2.0, 5, 7],
               [6.0, -3, 3]])          # rank 2

r3 = np.linalg.matrix_rank(A3)
행3 = null_space(A3.T).shape[1]
print("rank =", r3)
print(f"정의역 R^3 : 행공간 {r3}차원 + 영공간 {null_space(A3).shape[1]}차원")
print(f"공역   R^3 : 열공간 {r3}차원 + 좌영공간 {행3}차원")
rank = 2
정의역 R^3 : 행공간 2차원 + 영공간 1차원
공역   R^3 : 열공간 2차원 + 좌영공간 1차원

정의역 쪽 — 행공간과 영공간

from scipy.linalg import orth

행3기저 = orth(A3.T)
영3기저 = null_space(A3)
d = 영3기저[:, 0]

traces = [
    plane(spans=[행3기저[:, 0], 행3기저[:, 1]], extent=2.2,
          color=COLORS["colspace"], opacity=0.4, name="행공간 (2차원)"),
    go.Scatter3d(x=[-2.4 * d[0], 2.4 * d[0]], y=[-2.4 * d[1], 2.4 * d[1]],
                 z=[-2.4 * d[2], 2.4 * d[2]], mode="lines",
                 line=dict(color=COLORS["nullspace"], width=7), name="영공간 (1차원)"),
    go.Scatter3d(x=[0], y=[0], z=[0], mode="markers",
                 marker=dict(size=7, color="red"), name="원점"),
]
spin_figure(traces, title="정의역 R^3 = 행공간 + 영공간", extent=2.6)
Loading...

공역 쪽 — 열공간과 좌영공간

열3기저 = orth(A3)
좌영3기저 = null_space(A3.T)
e = 좌영3기저[:, 0]

traces = [
    plane(spans=[열3기저[:, 0], 열3기저[:, 1]], extent=2.2,
          color=COLORS["colspace"], opacity=0.4, name="열공간 (2차원)"),
    go.Scatter3d(x=[-2.4 * e[0], 2.4 * e[0]], y=[-2.4 * e[1], 2.4 * e[1]],
                 z=[-2.4 * e[2], 2.4 * e[2]], mode="lines",
                 line=dict(color=COLORS["nullspace"], width=7), name="좌영공간 (1차원)"),
    go.Scatter3d(x=[0], y=[0], z=[0], mode="markers",
                 marker=dict(size=7, color="red"), name="원점"),
]
spin_figure(traces, title="공역 R^3 = 열공간 + 좌영공간", extent=2.6)
Loading...

직선 방향과 평면의 두 기저 벡터의 내적을 재 보면 미리 엿볼 수는 있다.

print("영공간 방향과 행공간 기저의 내적 :")
for j in range(행3기저.shape[1]):
    print(f"  {d @ 행3기저[:, j]:+.2e}")
print()
print("좌영공간 방향과 열공간 기저의 내적 :")
for j in range(열3기저.shape[1]):
    print(f"  {e @ 열3기저[:, j]:+.2e}")
영공간 방향과 행공간 기저의 내적 :
  -5.55e-17
  +2.95e-16

좌영공간 방향과 열공간 기저의 내적 :
  +1.53e-16
  -1.04e-17

마치며...

서술 파트의 내용이 노트북의 코드
행공간 기저R[:r] (RREF 의 0이 아닌 행)
열공간 기저A[:, 피벗열] (원래 AA 에서)
영공간 기저null_space(A)
좌영공간 기저null_space(A.T) 또는 [AI][A \mid I] 소거의 EE
차원의 기본정리2절의 표
행랭크 == 열랭크무작위 300개 시험
열공간 기저의 함정4절의 열공간에_있는가
AA 의 작용6절의 “행공간 조각 ++ 영공간 조각” 분해

더 해 볼 것

  1. 6절의 분해에서 lstsq 대신 np.linalg.solve 를 쓸 수 있는가? 전체기저가 정사각행렬인 경우와 아닌 경우를 나눠 생각해 보자.

  2. 7절 마지막의 내적이 전부 10-16 수준이었다. 우연인가? 다른 행렬로도 해 보자.

  3. AAATA^{\mathsf{T}} 의 네 부분공간을 비교해 보자. 어느 것과 어느 것이 짝이 되는가?

  4. 랭크가 mm 인 행렬을 만들어 보자. 좌영공간은 어떻게 되는가? 그때 Ax=bA\vv{x} = \vv{b} 는 모든 b\vv{b} 에 대해 풀리는가?

다음 강의에서는 랭크가 1인 행렬을 다룬다. 네 부분공간이 가장 단순해지는 경우이고, 모든 행렬을 쌓아 올리는 벽돌이 된다.