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 29. 특이값 분해 (SVD) — 파이썬 실습

The Singular Value Decomposition — 실습

L29 서술 파트의 심장은 한 문장이었다. ATAA^{\mathsf T}A 가 언제나 대칭이고 준정부호이기 때문에 SVD에는 조건이 없다.

이 노트북에서는 그 유도를 그대로 코드로 옮겨 numpy.linalg.svd 와 대조하고, UU 를 따로 구하면 왜 어긋나는지 눈으로 보며, 이미지를 랭크별로 압축한다. 마지막으로 에카르트-영 정리가 정말 최선인지 무작위 행렬 수천 개와 겨뤄 본다.

서술 파트의 내용여기서 확인하는 방법
ATA=VΣTΣVTA^{\mathsf T}A = V\Sigma^{\mathsf T}\Sigma V^{\mathsf T}정의대로 SVD를 직접 만든다
조건이 없다직사각·랭크부족·영행렬 전부
UU 를 따로 구하면 안 된다500개 중 대부분이 깨진다
u\vv{u} 의 정규직교가 공짜UTU=IU^{\mathsf T}U = I
네 부분공간랭크로 갈라 기저를 채운다
σ1=A2\sigma_1 = \lVert A\rVert_2최대 증폭률
σi=detA\prod\sigma_i = \lvert\det A\rvertL20 회수
랭크 1 합하나씩 쌓으면 복원
에카르트-영무작위 랭크 kk 3000개와 겨루기
압축슬라이더로 랭크를 올려 가며
자연 vs 무작위잡음은 오히려 손해
numpy 함정VV 가 아니라 VTV^{\mathsf T} 를 준다

0. 준비

import numpy as np
import plotly.graph_objects as go

from linalg_viz import COLORS, layout2d, show_matrix, slider_figure

np.set_printoptions(precision=4, suppress=True)
rng = np.random.default_rng(29)
print("numpy", np.__version__)
numpy 2.5.2

1. 정의대로 SVD를 만든다

서술 파트의 유도를 그대로 옮기면 된다. ATAA^{\mathsf T}A 의 고유분해에서 VVσ\sigma 를 얻고, ui=Avi/σi\vv{u}_i = A\vv{v}_i/\sigma_iUU 를 만든다.

def 손으로SVD(A, 눈감아=None):
    """정의대로 SVD 를 구한다. np.linalg.svd 가 속으로 하는 일의 재현."""
    A = np.asarray(A, dtype=float)
    고윳값, V = np.linalg.eigh(A.T @ A)              # 대칭이므로 eigh
    순서 = np.argsort(고윳값)[::-1]                   # 큰 것부터
    고윳값, V = 고윳값[순서], V[:, 순서]
    특이값 = np.sqrt(np.maximum(고윳값, 0.0))         # 수치오차로 음수가 될 수 있다
    if 눈감아 is None:
        # A^T A 를 거치면 정밀도가 반으로 준다. 그래서 eps 가 아니라 sqrt(eps).
        눈감아 = (np.sqrt(max(A.shape) * np.finfo(float).eps) * 특이값[0]
                if 특이값.size else 0.0)
    r = int(np.sum(특이값 > 눈감아))
    U = np.zeros((A.shape[0], r))
    for i in range(r):
        U[:, i] = A @ V[:, i] / 특이값[i]            # 부호까지 올바르게 정해진다
    return U, 특이값[:r], V[:, :r]
C = np.array([[1.0, 1.0],
              [1.0, 0.0],
              [0.0, 1.0]])
print(show_matrix(C, "앵커 C  (3x2)"))
print(show_matrix(C.T @ C, "C^T C"))
print("C^T C 의 고윳값 :", np.linalg.eigvalsh(C.T @ C), "  (기대 1, 3)")
print()
U, s, V = 손으로SVD(C)
print("직접 구한 특이값 :", np.round(s, 6), "  = sqrt(3), 1 :",
      np.allclose(s, [np.sqrt(3), 1.0]))
print("np.linalg.svd    :", np.round(np.linalg.svd(C, compute_uv=False), 6))
print()
print("U^T U = I 인가 :", np.allclose(U.T @ U, np.eye(len(s))))
print("V^T V = I 인가 :", np.allclose(V.T @ V, np.eye(len(s))))
print("복원 오차       :", np.abs(U @ np.diag(s) @ V.T - C).max())
앵커 C  (3x2)
[  1   1 ]
[  1   0 ]
[  0   1 ]
C^T C
[  2   1 ]
[  1   2 ]
C^T C 의 고윳값 : [1. 3.]   (기대 1, 3)

직접 구한 특이값 : [1.7321 1.    ]   = sqrt(3), 1 : True
np.linalg.svd    : [1.7321 1.    ]

U^T U = I 인가 : True
V^T V = I 인가 : True
복원 오차       : 2.220446049250313e-16

서술 파트에서 손으로 구한 값과 맞는지 보자.

print(show_matrix(V, "V  (열이 v1, v2)"))
print("v1 = (1,1)/sqrt2 인가 :", np.allclose(np.abs(V[:, 0]), 1/np.sqrt(2)))
print()
print(show_matrix(U, "U  (열이 u1, u2)"))
print("u1 = (2,1,1)/sqrt6 인가 :",
      np.allclose(np.abs(U[:, 0]), np.abs(np.array([2, 1, 1]) / np.sqrt(6))))
print("u2 = (0,1,-1)/sqrt2 인가 :",
      np.allclose(np.abs(U[:, 1]), np.abs(np.array([0, 1, -1]) / np.sqrt(2))))
V  (열이 v1, v2)
[   0.707   -0.707 ]
[   0.707    0.707 ]
v1 = (1,1)/sqrt2 인가 : True

U  (열이 u1, u2)
[   0.816        0 ]
[   0.408   -0.707 ]
[   0.408    0.707 ]
u1 = (2,1,1)/sqrt6 인가 : True
u2 = (0,1,-1)/sqrt2 인가 : True

눈감아 주는 폭을 왜 ϵ\sqrt{\epsilon} 로 잡았는가

ATAA^{\mathsf T}A 를 만드는 순간 조건수가 제곱된다. σ\sigmaλ\lambda 의 제곱근이므로, λ\lambda 를 기계 정밀도 ϵ\epsilon 까지 맞게 구해도 σ\sigma 에는 ϵ\sqrt\epsilon 만큼의 오차가 남는다. L28에서 본 ϵ\sqrt\epsilon 법칙이 여기서도 나온다.

Q, _ = np.linalg.qr(rng.normal(size=(4, 4)))
W, _ = np.linalg.qr(rng.normal(size=(4, 4)))
print(f"{'참 sigma_min':>14}{'A^T A 를 거쳐서':>18}{'절대오차':>12}{'상대오차':>12}"
      f"{'np.linalg.svd':>18}{'절대오차':>12}")
for 작은값 in (1e-4, 1e-6, 1e-8, 1e-10, 1e-12):
    A0 = Q @ np.diag([1.0, 0.5, 0.1, 작은값]) @ W.T
    거쳐 = np.sqrt(np.maximum(np.linalg.eigvalsh(A0.T @ A0), 0))[0]
    바로 = np.linalg.svd(A0, compute_uv=False)[-1]
    print(f"{작은값:>14.0e}{거쳐:>18.3e}{abs(거쳐-작은값):>12.1e}"
          f"{abs(거쳐-작은값)/작은값:>12.1e}{바로:>18.3e}{abs(바로-작은값):>12.1e}")
print()
print(f"sqrt(eps) = {np.sqrt(np.finfo(float).eps):.3e}   <- 절대오차가 바닥을 치는 자리")
print()
A0 = Q @ np.diag([1.0, 0.5, 0.1, 1e-8]) @ W.T
print(f"cond(A)     = {np.linalg.cond(A0):.4e}")
print(f"cond(A^T A) = {np.linalg.cond(A0.T @ A0):.4e}   <- 제곱되었다")
   참 sigma_min       A^T A 를 거쳐서        절대오차        상대오차     np.linalg.svd        절대오차
         1e-04         1.000e-04     5.0e-14     5.0e-10         1.000e-04     2.9e-17
         1e-06         1.000e-06     1.6e-11     1.6e-05         1.000e-06     7.8e-18
         1e-08         9.636e-09     3.6e-10     3.6e-02         1.000e-08     5.9e-17
         1e-10         0.000e+00     1.0e-10     1.0e+00         1.000e-10     3.6e-17
         1e-12         0.000e+00     1.0e-12     1.0e+00         1.000e-12     2.0e-17

sqrt(eps) = 1.490e-08   <- 절대오차가 바닥을 치는 자리

cond(A)     = 1.0000e+08
cond(A^T A) = 4.2629e+15   <- 제곱되었다

ATAA^{\mathsf T}A 를 거친 쪽의 절대오차를 보자. 참값이 아무리 작아져도 오차는 10-9 언저리에서 더 내려가지 않는다. ϵ1.5×108\sqrt\epsilon \approx 1.5\times10^{-8} 이 바닥인 것이다. 그래서 σmin\sigma_{\min} 이 그 아래로 내려가는 순간 상대오차가 1을 넘어 값이 통째로 뜻을 잃는다. np.linalg.svd 쪽은 절대오차가 참값을 따라 함께 내려간다.

조건이 정말 없는가

직사각형이든, 랭크가 모자라든, 영행렬이든 전부 되는지 보자.

경우 = (("3x2", C),
        ("2x5 직사각", rng.normal(size=(2, 5))),
        ("랭크 1", np.outer(rng.normal(size=4), rng.normal(size=3))),
        ("영행렬 3x4", np.zeros((3, 4))),
        ("결함 [[3,1],[0,3]]", np.array([[3.0, 1.0], [0.0, 3.0]])),
        ("대칭 [[2,1],[1,2]]", np.array([[2.0, 1.0], [1.0, 2.0]])))
print(f"{'':>22}{'크기':>10}{'랭크':>6}{'특이값':>30}{'복원 오차':>12}")
for 이름, M in 경우:
    Uu, ss, Vv = 손으로SVD(M)
    복원 = Uu @ np.diag(ss) @ Vv.T if len(ss) else np.zeros_like(M)
    보기 = ", ".join(f"{v:.3f}" for v in ss) if len(ss) else "(없음)"
    print(f"{이름:>22}{str(M.shape):>10}{len(ss):>6}{보기:>30}"
          f"{np.abs(복원 - M).max():>12.2e}")
                              크기    랭크                           특이값       복원 오차
                   3x2    (3, 2)     2                  1.732, 1.000    2.22e-16
               2x5 직사각    (2, 5)     2                  2.998, 1.440    8.88e-16
                  랭크 1    (4, 3)     1                         3.968    2.22e-15
               영행렬 3x4    (3, 4)     0                          (없음)    0.00e+00
      결함 [[3,1],[0,3]]    (2, 2)     2                  3.541, 2.541    4.44e-16
      대칭 [[2,1],[1,2]]    (2, 2)     2                  3.000, 1.000    2.22e-16

2. UU 를 따로 구하면 어긋난다

AATAA^{\mathsf T} 의 고유벡터도 UU 가 맞다. 그런데 부호가 제멋대로 정해진다. Avi=σiuiA\vv{v}_i = \sigma_i\vv{u}_i 라는 끈을 놓아 버리기 때문이다.

def 따로SVD(A):
    """U 를 A A^T 에서 따로 구한다. 하면 안 되는 방식."""
    값V, V = np.linalg.eigh(A.T @ A)
    값U, U = np.linalg.eigh(A @ A.T)
    V = V[:, np.argsort(값V)[::-1]]
    U = U[:, np.argsort(값U)[::-1]]
    s = np.sqrt(np.maximum(np.sort(값V)[::-1], 0.0))
    return U, s, V
A = np.array([[3.0, 0.0], [4.0, 5.0]])
B = np.array([[1.0, 2.0], [3.0, 4.0]])
for 이름, M in (("A = [[3,0],[4,5]]", A), ("B = [[1,2],[3,4]]", B)):
    Uf, sf, Vf = 따로SVD(M)
    Uo, so, Vo = 손으로SVD(M)
    print(f"{이름}")
    print(f"   따로 구한 것의 복원 오차   : {np.abs(Uf @ np.diag(sf) @ Vf.T - M).max():.3e}")
    print(f"   정의대로 만든 것의 복원 오차 : {np.abs(Uo @ np.diag(so) @ Vo.T - M).max():.3e}")
A = [[3,0],[4,5]]
   따로 구한 것의 복원 오차   : 8.882e-16
   정의대로 만든 것의 복원 오차 : 8.882e-16
B = [[1,2],[3,4]]
   따로 구한 것의 복원 오차   : 5.471e-01
   정의대로 만든 것의 복원 오차 : 8.882e-16
Uf, sf, Vf = 따로SVD(B)
print("따로 구한 U :\n", np.round(Uf, 6))
print("정의대로 만든 U :\n", np.round(손으로SVD(B)[0], 6))
print()
print("따로 구한 U 도 정규직교이기는 하다 :", np.allclose(Uf.T @ Uf, np.eye(2)))
print("특이값도 정확히 맞다 :", np.allclose(sf, np.linalg.svd(B, compute_uv=False)))
print()
print("그런데 A v_i = sigma_i u_i 라는 끈이 끊어져 있다.")
for i in range(2):
    print(f"   B v{i+1}      = {np.round(B @ Vf[:, i], 6)}")
    print(f"   sigma{i+1} u{i+1} = {np.round(sf[i] * Uf[:, i], 6)}   "
          f"맞는가 : {np.allclose(B @ Vf[:, i], sf[i] * Uf[:, i])}")
따로 구한 U :
 [[ 0.4046 -0.9145]
 [ 0.9145  0.4046]]
정의대로 만든 U :
 [[ 0.4046  0.9145]
 [ 0.9145 -0.4046]]

따로 구한 U 도 정규직교이기는 하다 : True
특이값도 정확히 맞다 : True

그런데 A v_i = sigma_i u_i 라는 끈이 끊어져 있다.
   B v1      = [2.2109 4.9978]
   sigma1 u1 = [2.2109 4.9978]   맞는가 : True
   B v2      = [ 0.3347 -0.1481]
   sigma2 u2 = [-0.3347  0.1481]   맞는가 : False

크기도 맞고, 정규직교도 맞고, 특이값도 정확하다. 웬만한 검사는 다 통과한다. 어긋난 것은 오직 부호 하나이고, 그 하나가 전부를 망친다.

얼마나 자주 깨지는지 세어 보자.

def 복원오차(만드는법, X):
    U, s, V = 만드는법(X)
    return float(np.abs(U @ np.diag(s) @ V.T - X).max())

시험 = [rng.normal(size=(4, 4)) for _ in range(500)]
깨짐 = sum(복원오차(따로SVD, X) > 1e-8 for X in 시험)
바름 = sum(복원오차(손으로SVD, X) > 1e-8 for X in 시험)
print(f"무작위 4x4 행렬 500개")
print(f"  따로 구한 U   : 깨진 것 {깨짐}개 ({깨짐/5:.0f}%)")
print(f"  정의대로 만든 U : 깨진 것 {바름}개")
무작위 4x4 행렬 500개
  따로 구한 U   : 깨진 것 465개 (93%)
  정의대로 만든 U : 깨진 것 0개

3. 네 부분공간

랭크 rr 로 갈라 VVUU 의 앞뒤를 나누면 네 칸이 채워진다.

U뭉치, s뭉치, Vt뭉치 = np.linalg.svd(C, full_matrices=True)   # full 이어야 좌영공간까지
r = int(np.sum(s뭉치 > 1e-10))
m, n = C.shape
print(f"C 는 {m}x{n}, 랭크 r = {r}")
print(f"  행공간   기저 : V[:, :{r}]        ({r} 차원, R^{n} 안)")
print(f"  영공간   기저 : V[:, {r}:]        ({n-r} 차원)")
print(f"  열공간   기저 : U[:, :{r}]        ({r} 차원, R^{m} 안)")
print(f"  좌영공간 기저 : U[:, {r}:]        ({m-r} 차원)")
print()
u3 = U뭉치[:, r:]
print("좌영공간 기저 u3 :", np.round(u3.ravel(), 6))
print("  = (-1,1,1)/sqrt3 인가 :",
      np.allclose(np.abs(u3.ravel()), 1/np.sqrt(3)))
print("  C^T u3 =", np.round(C.T @ u3.ravel(), 12), "  <- 좌영공간이 맞다")
C 는 3x2, 랭크 r = 2
  행공간   기저 : V[:, :2]        (2 차원, R^2 안)
  영공간   기저 : V[:, 2:]        (0 차원)
  열공간   기저 : U[:, :2]        (2 차원, R^3 안)
  좌영공간 기저 : U[:, 2:]        (1 차원)

좌영공간 기저 u3 : [-0.5774  0.5774  0.5774]
  = (-1,1,1)/sqrt3 인가 : True
  C^T u3 = [ 0. -0.]   <- 좌영공간이 맞다
# 랭크가 모자란 경우로 네 칸을 다 채워 보자
D = np.array([[1.0, 2.0, 3.0],
              [2.0, 4.0, 6.0],
              [1.0, 1.0, 1.0],
              [0.0, 1.0, 2.0]])                        # 2행 = 1행의 2배
Ud, sd, Vtd = np.linalg.svd(D, full_matrices=True)
rd = int(np.sum(sd > 1e-10))
print(show_matrix(D, "D  (4x3)"))
print("특이값 :", np.round(sd, 6), "  랭크 :", rd)
print()
영공간 = Vtd[rd:].T                                    # 열이 기저
좌영공간 = Ud[:, rd:]
print(f"영공간 : {영공간.shape[1]} 차원 (3 - {rd})")
for i in range(영공간.shape[1]):
    v = 영공간[:, i]
    print(f"  기저 {np.round(v, 6)}  ->  D v = {np.round(D @ v, 12)}")
print(f"좌영공간 : {좌영공간.shape[1]} 차원 (4 - {rd})")
for i in range(좌영공간.shape[1]):
    u = 좌영공간[:, i]
    print(f"  기저 {np.round(u, 6)}  ->  D^T u = {np.round(D.T @ u, 12)}")
print()
print(f"차원 확인 : {rd} + {3-rd} = 3 (열 개수),  {rd} + {4-rd} = 4 (행 개수)")
D  (4x3)
[  1   2   3 ]
[  2   4   6 ]
[  1   1   1 ]
[  0   1   2 ]
특이값 : [8.7832 0.925  0.    ]   랭크 : 2

영공간 : 1 차원 (3 - 2)
  기저 [-0.4082  0.8165 -0.4082]  ->  D v = [-0. -0. -0.  0.]
좌영공간 : 2 차원 (4 - 2)
  기저 [ 0.9013 -0.4289 -0.0434 -0.0434]  ->  D^T u = [0. 0. 0.]
  기저 [-0.0769 -0.2979  0.6728  0.6728]  ->  D^T u = [ 0.  0. -0.]

차원 확인 : 2 + 1 = 3 (열 개수),  2 + 2 = 4 (행 개수)

4. 회전, 늘이기, 회전

σ1\sigma_1 은 최대 증폭률이고 σi\prod\sigma_i 는 부피 배율이다.

A = np.array([[3.0, 0.0], [4.0, 5.0]])
Ua, sa, Vta = np.linalg.svd(A)
print("특이값 :", sa, "  = 3sqrt5, sqrt5 :",
      np.allclose(sa, [3*np.sqrt(5), np.sqrt(5)]))
print()
print("sigma1 =", sa[0], "   np.linalg.norm(A, 2) =", np.linalg.norm(A, 2))
print("곱 =", np.prod(sa), "   |det A| =", abs(np.linalg.det(A)))
print()
# 최대 증폭률을 무작위로 확인
최대 = 0.0
for _ in range(20000):
    x = rng.normal(size=2)
    x = x / np.linalg.norm(x)
    최대 = max(최대, float(np.linalg.norm(A @ x)))
print(f"단위벡터 20000개 중 |Ax| 의 최댓값 : {최대:.6f}   sigma1 = {sa[0]:.6f}")
print("v1 을 넣었을 때 :", np.linalg.norm(A @ Vta[0]))
특이값 : [6.7082 2.2361]   = 3sqrt5, sqrt5 : True

sigma1 = 6.70820393249937    np.linalg.norm(A, 2) = 6.70820393249937
곱 = 15.0    |det A| = 15.0

단위벡터 20000개 중 |Ax| 의 최댓값 : 6.708204   sigma1 = 6.708204
v1 을 넣었을 때 : 6.708203932499369
t = np.linspace(0, 2*np.pi, 300)
원 = np.vstack([np.cos(t), np.sin(t)])
단계 = (("① 단위원", np.eye(2)),
        ("② V^T 로 회전", Vta),
        ("③ Sigma 로 늘이기", np.diag(sa) @ Vta),
        ("④ U 로 회전 -> 최종", Ua @ np.diag(sa) @ Vta))
프레임 = []
for 이름, M in 단계:
    곡선 = M @ 원
    프레임.append([
        go.Scatter(x=원[0], y=원[1], mode="lines", name="단위원",
                   line=dict(color="#bbbbbb", width=1.5, dash="dot")),
        go.Scatter(x=곡선[0], y=곡선[1], mode="lines", name="현재",
                   line=dict(color=COLORS["output"], width=4)),
        go.Scatter(x=[0], y=[-7.0], mode="text", text=[이름],
                   textfont=dict(size=17), showlegend=False),
    ])
배치 = layout2d(f"sigma = {np.round(sa,3)},  곱 = {np.prod(sa):.0f} = |det A|",
               extent=7.8)
배치["height"] = 620
slider_figure(프레임, ["1", "2", "3", "4"], 배치, prefix="단계 ")
Loading...

②단계에서 도형이 여전히 이다. 회전은 원을 원으로 보낸다. ③단계의 타원은 두 축이 좌표축에 나란하고 반지름이 정확히 σ1,σ2\sigma_1, \sigma_2 이다.

5. 랭크 1 조각의 합

def 조각들(A):
    """A = sum sigma_i u_i v_i^T 로 쪼갠다."""
    U, s, Vt = np.linalg.svd(A, full_matrices=False)
    return [(s[i], np.outer(U[:, i], Vt[i])) for i in range(len(s)) if s[i] > 1e-12]
쌓음 = np.zeros_like(C)
for k, (sig, 조각) in enumerate(조각들(C), start=1):
    쌓음 = 쌓음 + sig * 조각
    print(f"조각 {k}개까지 (sigma = {sig:.4f})  복원 오차 {np.abs(쌓음 - C).max():.3e}")
print()
for k, (sig, 조각) in enumerate(조각들(C), start=1):
    print(show_matrix(sig * 조각, f"sigma{k} u{k} v{k}^T   (랭크 "
                                 f"{np.linalg.matrix_rank(조각)})"))
조각 1개까지 (sigma = 1.7321)  복원 오차 5.000e-01
조각 2개까지 (sigma = 1.0000)  복원 오차 5.551e-16

sigma1 u1 v1^T   (랭크 1)
[    1     1 ]
[  0.5   0.5 ]
[  0.5   0.5 ]
sigma2 u2 v2^T   (랭크 1)
[  -1.31e-16    1.31e-16 ]
[        0.5        -0.5 ]
[       -0.5         0.5 ]

6. 에카르트-영 — 정말 최선인가

kk 개만 남긴 AkA_k 가 랭크 kk 인 모든 행렬 중 가장 가깝다는 주장이다. 무작위 랭크 kk 행렬 3000개와 겨뤄 보자.

def 앞k개(A, k):
    """앞 k 개의 조각만 남긴 최적 근사."""
    U, s, Vt = np.linalg.svd(A, full_matrices=False)
    return (U[:, :k] * s[:k]) @ Vt[:k]
M = rng.normal(size=(8, 6))
s = np.linalg.svd(M, compute_uv=False)
print(f"{'k':>4}{'|A - A_k|_2':>16}{'sigma_(k+1)':>16}{'무작위 3000개 최선':>22}{'졌는가':>10}")
for k in (1, 2, 3, 4, 5):
    Ak = 앞k개(M, k)
    오차 = np.linalg.norm(M - Ak, 2)
    최선 = np.inf
    for _ in range(3000):
        X = rng.normal(size=(8, k)) @ rng.normal(size=(k, 6))
        최선 = min(최선, float(np.linalg.norm(M - X, 2)))
    print(f"{k:>4}{오차:>16.6f}{s[k]:>16.6f}{최선:>22.6f}"
          f"{str(최선 < 오차 - 1e-9):>10}")
   k     |A - A_k|_2     sigma_(k+1)          무작위 3000개 최선       졌는가
   1        3.501435        3.501435              4.036448     False
   2        2.746201        2.746201              3.965556     False
   3        1.887975        1.887975              4.260257     False
   4        1.321255        1.321255              5.153628     False
   5        0.408793        0.408793              5.937477     False

한 번도 이기지 못한다. 그리고 오차가 정확히 σk+1\sigma_{k+1} 이다. 버린 것 중 가장 큰 것이 곧 오차라는 서술 파트의 말 그대로이다.

# 더 똑똑한 후보로도 겨뤄 보자 : A 의 앞 k 열 / 무작위 k 열
k = 3
Ak = 앞k개(M, k)
print(f"랭크 {k} 후보들의 |A - X|_2")
print(f"  A_k (SVD 앞 {k}개)          : {np.linalg.norm(M - Ak, 2):.6f}   <- 최선")
앞열 = M.copy(); 앞열[:, k:] = 0
print(f"  A 의 앞 {k}열만 남기기       : {np.linalg.norm(M - 앞열, 2):.6f}")
Q, _ = np.linalg.qr(M[:, :k])
print(f"  앞 {k}열의 공간으로 투영     : "
      f"{np.linalg.norm(M - Q @ (Q.T @ M), 2):.6f}")
print()
print("'앞 k 열을 남기는 것' 은 저계수 근사가 아니다.")
랭크 3 후보들의 |A - X|_2
  A_k (SVD 앞 3개)          : 1.887975   <- 최선
  A 의 앞 3열만 남기기       : 3.139932
  앞 3열의 공간으로 투영     : 2.344345

'앞 k 열을 남기는 것' 은 저계수 근사가 아니다.

7. 이미지 압축

def 합성사진(n=192, seed=29):
    """자연스러운 구조를 가진 시험용 이미지."""
    rng2 = np.random.default_rng(seed)
    y, x = np.mgrid[0:n, 0:n] / (n - 1)
    그림 = 0.42 + 0.16 * np.cos(2.0*x + 0.5) * np.cos(1.5*y - 0.2)
    그림 += 0.26 * np.exp(-((x-0.28)**2 + (y-0.30)**2) / 0.014)
    그림 -= 0.22 * np.exp(-((x-0.74)**2 + (y-0.70)**2) / 0.010)
    그림 += 0.22 * ((x > 0.58) & (x < 0.92) & (y > 0.08) & (y < 0.34))
    그림 -= 0.28 * (np.abs(y - x - 0.18) < 0.035)
    그림 += 0.16 * (np.abs(y + x - 1.55) < 0.028)
    그림 += 0.09 * np.sin(24*x) * ((y > 0.78) & (y < 0.96))
    그림 += 0.012 * rng2.standard_normal((n, n))
    return np.clip(그림, 0, 1)
사진 = 합성사진()
n = 사진.shape[0]
Ui, Si, Vti = np.linalg.svd(사진, full_matrices=False)
print(f"{n} x {n} = {n*n:,} 개의 수")
print("특이값 상위 8 :", np.round(Si[:8], 3))
print()
print(f"{'k':>5}{'저장할 수':>12}{'원본 대비':>10}{'상대 오차':>12}{'담은 에너지':>12}")
전체 = (Si**2).sum()
for k in (1, 3, 10, 25, 60):
    Ak = 앞k개(사진, k)
    저장 = k * (2*n + 1)
    print(f"{k:>5}{저장:>12,}{저장/(n*n):>10.1%}"
          f"{np.linalg.norm(사진-Ak)/np.linalg.norm(사진):>12.4f}"
          f"{(Si[:k]**2).sum()/전체:>12.4f}")
192 x 192 = 36,864 개의 수
특이값 상위 8 : [85.593  9.696  5.985  5.232  4.327  4.117  3.58   3.513]

    k       저장할 수     원본 대비       상대 오차      담은 에너지
    1         385      1.0%      0.1962      0.9615
    3       1,155      3.1%      0.1465      0.9785
   10       3,850     10.4%      0.0866      0.9925
   25       9,625     26.1%      0.0510      0.9974
   60      23,100     62.7%      0.0310      0.9990
보기 = [1, 2, 3, 5, 8, 12, 20, 30, 45, 60, 90, 130]
프레임 = []
for k in 보기:
    Ak = np.clip(앞k개(사진, k), 0, 1)
    프레임.append([go.Heatmap(z=Ak[::-1], colorscale="gray", zmin=0, zmax=1,
                             showscale=False)])
배치 = dict(title=dict(text="랭크를 올려 가며"),
           xaxis=dict(visible=False, scaleanchor="y"),
           yaxis=dict(visible=False),
           height=560, margin=dict(l=40, r=40, t=60, b=40))
slider_figure(프레임, 보기, 배치, prefix="랭크 k = ", initial=4)
Loading...

왜 압축이 되는가 — 무작위와 견주기

무작위 = rng.random((n, n))
S무 = np.linalg.svd(무작위, compute_uv=False)
전체무 = (S무**2).sum()

print(f"{'':>10}{'99% 에너지에 필요한 k':>24}{'그때 저장량':>14}")
for 이름, sv, 합 in (("사진", Si, 전체), ("무작위", S무, 전체무)):
    k = int(np.searchsorted(np.cumsum(sv**2)/합, 0.99) + 1)
    print(f"{이름:>10}{k:>24}{k*(2*n+1)/(n*n):>14.1%}")
print()
print("무작위 잡음은 SVD 로 압축하면 원본보다 커진다.")
print("자연의 데이터가 압축되는 것은 그것이 무작위가 아니기 때문이다.")

go.Figure(
    data=[go.Scatter(x=np.arange(1, n+1), y=Si, mode="lines", name="사진",
                     line=dict(color=COLORS["output"], width=3)),
          go.Scatter(x=np.arange(1, n+1), y=S무, mode="lines", name="무작위 잡음",
                     line=dict(color="#999999", width=2, dash="dash"))],
    layout=go.Layout(title=dict(text="특이값 스펙트럼"),
                     xaxis=dict(title=dict(text="i")),
                     yaxis=dict(title=dict(text="sigma_i"), type="log"),
                     height=420, margin=dict(l=70, r=20, t=60, b=50)))
                    99% 에너지에 필요한 k        그때 저장량
        사진                       8          8.4%
       무작위                     123        128.5%

무작위 잡음은 SVD 로 압축하면 원본보다 커진다.
자연의 데이터가 압축되는 것은 그것이 무작위가 아니기 때문이다.
Loading...

8. numpy 함정과 조건수

U3, s3, Vt3 = np.linalg.svd(D, full_matrices=False)   # D 는 4x3
print("np.linalg.svd 가 돌려주는 세 번째 것의 크기 :", Vt3.shape)
print("  이것은 V 가 아니라 V^T 이다.")
print()
print("올바른 복원 : U @ diag(s) @ Vt        오차",
      np.abs(U3 @ np.diag(s3) @ Vt3 - D).max())
print("틀린 복원   : U @ diag(s) @ Vt.T      오차",
      np.abs(U3 @ np.diag(s3) @ Vt3.T - D).max())
print()
print("정사각이 아니면 아예 곱해지지도 않아 금방 들킨다.")
E = rng.normal(size=(2, 5))
Ue, se, Vte = np.linalg.svd(E, full_matrices=False)
try:
    Ue @ np.diag(se) @ Vte.T
except ValueError as 오류:
    print("  2x5 에서는 :", str(오류).split(",")[0])
print("정사각일 때가 오히려 위험하다. 조용히 틀린 답이 나온다.")
print()
print("full_matrices 의 차이")
for 값 in (True, False):
    Uf, sf, Vtf = np.linalg.svd(C, full_matrices=값)
    print(f"  full_matrices={값!s:>5} : U {Uf.shape}, Vt {Vtf.shape}"
          f"   좌영공간까지 주는가 : {Uf.shape[1] == C.shape[0]}")
np.linalg.svd 가 돌려주는 세 번째 것의 크기 : (3, 3)
  이것은 V 가 아니라 V^T 이다.

올바른 복원 : U @ diag(s) @ Vt        오차 2.6645352591003757e-15
틀린 복원   : U @ diag(s) @ Vt.T      오차 10.533434468311064

정사각이 아니면 아예 곱해지지도 않아 금방 들킨다.
  2x5 에서는 : matmul: Input operand 1 has a mismatch in its core dimension 0
정사각일 때가 오히려 위험하다. 조용히 틀린 답이 나온다.

full_matrices 의 차이
  full_matrices= True : U (3, 3), Vt (2, 2)   좌영공간까지 주는가 : True
  full_matrices=False : U (3, 2), Vt (2, 2)   좌영공간까지 주는가 : False
print("조건수 = sigma1 / sigma_n")
for 이름, M in (("[[2,1],[1,2]]", np.array([[2.0, 1.0], [1.0, 2.0]])),
                ("[[3,0],[4,5]]", A),
                ("힐베르트 6x6", np.array([[1.0/(i+j+1) for j in range(6)]
                                          for i in range(6)]))):
    sv = np.linalg.svd(M, compute_uv=False)
    print(f"  {이름:>16} : sigma1 {sv[0]:>10.4g}, sigma_n {sv[-1]:>10.4g}, "
          f"조건수 {sv[0]/sv[-1]:>12.4g}")
print()
print("np.linalg.cond 와 같은가 :",
      np.isclose(np.linalg.cond(A), np.linalg.svd(A, compute_uv=False)[0]
                 / np.linalg.svd(A, compute_uv=False)[-1]))
조건수 = sigma1 / sigma_n
     [[2,1],[1,2]] : sigma1          3, sigma_n          1, 조건수            3
     [[3,0],[4,5]] : sigma1      6.708, sigma_n      2.236, 조건수            3
          힐베르트 6x6 : sigma1      1.619, sigma_n  1.083e-07, 조건수    1.495e+07

np.linalg.cond 와 같은가 : True

특이값은 고윳값의 절댓값이 아니다

print(f"{'':>22}{'|고윳값|':>20}{'특이값':>20}{'같은가':>8}{'A A^T = A^T A':>16}")
for 이름, M in (("대칭 [[2,1],[1,2]]", np.array([[2.0, 1.0], [1.0, 2.0]])),
                ("회전 90도", np.array([[0.0, -1.0], [1.0, 0.0]])),
                ("결함 [[3,1],[0,3]]", np.array([[3.0, 1.0], [0.0, 3.0]])),
                ("[[3,0],[4,5]]", A)):
    고 = np.sort(np.abs(np.linalg.eigvals(M)))[::-1]
    특 = np.linalg.svd(M, compute_uv=False)
    print(f"{이름:>22}{str(np.round(고,4)):>20}{str(np.round(특,4)):>20}"
          f"{str(np.allclose(고, 특)):>8}{str(np.allclose(M @ M.T, M.T @ M)):>16}")
                                     |고윳값|                 특이값     같은가   A A^T = A^T A
      대칭 [[2,1],[1,2]]             [3. 1.]             [3. 1.]    True            True
                회전 90도             [1. 1.]             [1. 1.]    True            True
      결함 [[3,1],[0,3]]             [3. 3.]     [3.5414 2.5414]   False           False
         [[3,0],[4,5]]             [5. 3.]     [6.7082 2.2361]   False           False

마지막 칸이 답이다. σi=λi\sigma_i = \lvert\lambda_i\rvert 가 되는 것은 AAT=ATAAA^{\mathsf T} = A^{\mathsf T}A 일 때, 곧 정규행렬일 때이다. 대칭행렬은 그중 한 종류일 뿐이고, 회전행렬도 정규행렬이라 λ=σ=1\lvert\lambda\rvert = \sigma = 1 이다.

σ\sigmaATAA^{\mathsf T}A 를, λ\lambdaAA 를 본다. 두 행렬이 같은 고유벡터를 가질 때에만 두 눈이 같은 것을 보는 셈이다.

마치며...

서술 파트의 내용이 노트북의 코드
정의대로 SVD손으로SVDnp.linalg.svd 와 일치
조건이 없다직사각·랭크부족·영행렬·결함 전부 통과
UU 를 따로 구하면무작위 500개 중 대부분이 깨진다
ATAA^{\mathsf T}A 를 거치는 값ϵ\sqrt\epsilon 만큼 정밀도가 준다
네 부분공간rr 로 갈라 네 칸을 채운다
σ1\sigma_1단위벡터 20000개 중 최댓값과 일치
σi\prod\sigma_idetA\lvert\det A\rvert
세 단계슬라이더. ②에서 여전히 원
랭크 1 합하나씩 쌓으면 오차가 0으로
에카르트-영무작위 3000개가 한 번도 못 이긴다
“앞 k 열” 은 아니다오차가 훨씬 크다
압축슬라이더로 랭크를 올려 가며
자연 vs 무작위잡음은 저장량이 100%를 넘는다
numpy 함정VTV^{\mathsf T} 를 준다
σλ\sigma \neq \lvert\lambda\rvert정규행렬일 때만 같다

더 해 볼 것

  1. 7절의 사진에서 대각선 띠를 빼고 다시 해 보자. 필요한 랭크가 줄어드는가? 대각선이 SVD에 왜 비싼가?

  2. AAATA^{\mathsf T} 의 특이값을 비교해 보자. 같은가? UUVV 는 어떻게 되는가?

  3. 잡음이 섞인 저계수 행렬을 만들어 보자. 특이값 스펙트럼에 무릎이 보이는가? 그 자리에서 자르면 잡음이 걷히는가?

  4. 6절의 겨루기를 프로베니우스 노름(ord='fro')으로 다시 해 보자. 에카르트-영이 그쪽에서도 성립하는가? 그때 오차는 얼마인가?

다음 강의에서는 지금까지의 전부를 한 층 위에서 다시 본다. 기저를 고른다는 것이 무슨 뜻이고, 행렬이란 도대체 무엇인가.