[Zero to ML Master #09] 고유값·SVD·PCA 쉽게 이해하기 — 300페이지 책을 3페이지로 (1부)

오톨로리즘·3일 전
post-thumbnail

300페이지짜리 소설을 3페이지로 요약해야 한다면, 무엇을 남기고 무엇을 버릴까요? 📚 가장 중요한 줄거리만 남기고 나머지는 과감히 버리겠죠.

행렬에도 똑같은 일을 할 수 있습니다. 어떤 행렬이든 A=UΣV⊤A=U\Sigma V^{\top} 세 조각으로 쪼개고(SVD), 작은 특잇값을 버리면 정보를 거의 잃지 않고 데이터를 압축할 수 있어요. 1부에서는 그 출발점인 고유벡터부터 SVD, PCA까지 다룹니다.

📘 이 글은 위키독스 도서 Zero to 머신러닝 딥러닝 Master 의 00-06 SVD·PCA — 300페이지 책을 3페이지로 (1부) 을 블로그용으로 다듬은 글입니다.
💻 실습 코드: GitHub · 🏠 mldict.net · ✍️ 기술 블로그

💻 이 글의 코드: 00-06-svd-pca · Open in Colab — 핵심 코드와 실제 실행 결과는 글 아래 💻 GitHub 실습 섹션에 있습니다.

⬅️ 이전 글: #08 정규분포·소프트맥스·교차 엔트로피 — AI는 확률로 대답한다 (2부) \
➡️ 다음 글: #10 SVD 추천 시스템부터 미니 신경망(XOR)까지 — 수포자 수학 6개 장 총정리 (2부)

TL;DR — 3줄 요약

  • 고유벡터는 행렬을 곱해도 방향이 변하지 않는 벡터(Av=λvA\mathbf{v}=\lambda\mathbf{v})이고, 고유값이 클수록 그 방향이 지배적입니다.
  • SVD는 어떤 행렬이든(직사각형도) UΣV⊤U\Sigma V^{\top}로 분해하고, 작은 특잇값을 버린 근사는 수학적으로 최적입니다(에카르트–영 정리).
  • PCA는 SVD를 분산이 가장 큰 방향 찾기에 응용한 차원 축소 기법입니다.

✏️ 꼭 알아야 할 TIP

🎯 핵심 개념: 어떤 행렬이든 A=UΣV⊤A=U\Sigma V^{\top}로 분해할 수 있고(SVD), 작은 특잇값을 버리면 정보를 거의 잃지 않고 데이터를 압축할 수 있습니다.

📖 한 줄 비유: SVD는 "300페이지 소설을 핵심 줄거리만 남겨 3페이지로 요약하는 것"입니다.

🔑 이 장을 관통하는 한 줄 공식:

A  ≈  Ak=U:,:k Σk V:k,:⊤A \;\approx\; A_k = U_{:,:k}\,\Sigma_k\,V^{\top}_{:k,:}

코드에서는 U[:, :k] @ np.diag(S[:k]) @ Vt[:k, :] 딱 한 줄입니다. 이 장 전체가 이 한 줄의 의미를 파헤치는 여정입니다.

⭐ 가장 중요한 포인트: 고유벡터는 "행렬을 곱해도 방향이 바뀌지 않는 화살표"이며, 이 방향들이 곧 데이터의 가장 본질적인 패턴입니다. SVD·PCA는 이 패턴을 찾아 중요한 순서대로 줄 세우는 기술입니다.

🏗️ 실무에서 쓰는 이유: 넷플릭스 추천, 구글 이미지 검색, 노이즈 제거, 데이터 시각화, 그리고 요즘 LLM의 LoRA 파인튜닝까지 — "거대한 데이터를 핵심 몇 개로 요약하는" 모든 작업의 기반입니다. 🚀

📋 선행지식 — 이 장은 앞의 다섯 장을 전부 사용합니다

이전 장배운 것이 장의 어디서 쓰이나
00-01 벡터내적·노름·정규화고유벡터의 길이 1 정규화, 압축 오차를 재는 프로베니우스 노름
00-02 행렬행렬 곱·전치·역행렬1~5단계 전부 — AvA\mathbf{v}, UΣV⊤U\Sigma V^{\top}, X⊤XX^{\top}X
00-03 최소제곱A⊤AA^{\top}A, 정규방정식3단계 공분산 행렬이 사실상 같은 형태
00-04 미분연쇄법칙·경사하강법5단계 역전파와 가중치 갱신
00-05 확률분산·소프트맥스·y^−y\hat{y}-y3단계 분산 최대화, 5단계 손실과 기울기

💬 ※ 잊어버린 부분이 있어도 괜찮습니다. 필요할 때마다 "🔗 0-0X와 연결" 표시로 다시 짚어드립니다.

🎯 학습 목표

☐ 🏹 Av=λvA\mathbf{v}=\lambda\mathbf{v}의 의미를 설명하고 고유값을 손으로 직접 계산할 수 있다 🟢 초급 ⭐

☐ 📐 특성방정식 det⁡(A−λI)=0\det(A-\lambda I)=0으로 고유값을 유도할 수 있다 🔵 중급

☐ ✂️ SVD A=UΣV⊤A=U\Sigma V^{\top}의 세 행렬이 각각 무엇인지 설명할 수 있다 🔵 중급 ⭐

☐ 📊 에너지 비율로 "kk를 몇으로 잡아야 하는지" 정량적으로 판단할 수 있다 🔵 중급

☐ 🎯 PCA가 "분산이 최대인 방향 찾기"임을 설명하고 직접 구현할 수 있다 🔵 중급 ⭐

☐ 🔬 PCA의 고유값 = SVD 특잇값²/(n−1) 관계를 코드로 검증할 수 있다 🔴 심화

☐ 🎬 SVD로 추천 시스템의 빈 평점을 예측하고, 0으로 채우는 방식의 한계를 설명할 수 있다 🔴 심화 🚀

☐ 🤖 00-01~00-05를 모두 사용해 미니 신경망을 처음부터 구현하고 XOR을 학습시킬 수 있다 🔴 심화 ⭐🚀

☐ 🔍 직접 유도한 역전파 공식을 수치미분으로 검증할 수 있다 🔴 심화

📊 핵심 학습 내용 — 수식과 코드의 대응

단계학습 내용핵심 수학공식대응하는 코드
1️⃣고유벡터·고유값Av=λvA\mathbf{v}=\lambda\mathbf{v}np.linalg.eig(A)
2️⃣SVD·저차원 근사A=UΣV⊤A=U\Sigma V^{\top}U[:,:k]@diag(S[:k])@Vt[:k,:]
3️⃣PCA·차원 축소Cv=λvC\mathbf{v}=\lambda\mathbf{v}, C=Xc⊤Xcn−1C=\frac{X_c^{\top}X_c}{n-1}np.linalg.eigh(np.cov(Xc.T))
4️⃣추천 시스템r^ui=pu⋅qi\hat{r}_{ui}=\mathbf{p}_u\cdot\mathbf{q}_iP[u] @ Q[i]
5️⃣종합 — 미니 신경망z=xW+bz=xW+b, ∂L∂z=y^−y\frac{\partial L}{\partial z}=\hat{y}-yW -= lr * np.outer(h, gz)

🗺️ 시각화 ① — 이번 장의 흐름과 앞 장들의 합류

🗺️ 시각화 ① — 이번 장의 흐름과 앞 장들의 합류


📍 00-06-A · 1단계 — 고유벡터와 고유값: 방향이 변하지 않는 특별한 화살표

🔎 ① 핵심 정의 — 무엇인가?\
00-02에서 배웠듯 행렬 AA를 벡터 v\mathbf{v}에 곱하면, 보통 방향도 길이도 모두 달라진 새 벡터가 나옵니다. 그런데 어떤 특별한 벡터는 행렬을 곱해도 방향은 그대로이고 길이만 배수로 변합니다. 이런 벡터를 고유벡터(eigenvector), 그 배수를 고유값(eigenvalue)이라 합니다.

❓ 왜 필요한가?\
고유벡터는 "그 행렬이 가진 가장 본질적인 방향"입니다. 복잡해 보이는 행렬 변환도, 고유벡터 방향에서 보면 단순히 늘이고 줄이는 것뿐입니다. 즉 복잡한 것을 단순한 몇 개로 분해하는 첫걸음입니다.

⏰ 언제 사용하는가?\
PCA의 주성분 방향 찾기(3단계), 구글 페이지랭크, 진동 해석, 그리고 SVD의 이론적 토대가 됩니다.

💜 더 쉽게 이해하기 — 🎡 회전목마 위의 방향표

놀이공원 회전목마(행렬 AA)가 돌아가면 대부분의 물건은 방향이 계속 바뀝니다. 🎠 그런데 회전축에 꽂아둔 깃발만은 아무리 돌아도 같은 방향을 가리키죠 — 다만 바람에 따라 길이만 늘었다 줄었다 할 뿐입니다.

🚩 그 깃발이 고유벡터, 길이 배율이 고유값입니다. 회전목마의 "성격"을 알고 싶다면? 축(고유벡터)이 어디인지만 알면 됩니다. 나머지 복잡한 움직임은 전부 그 축을 기준으로 설명할 수 있으니까요. 🎯

🔢 진행 과정 — 고유값을 손으로 구하는 4단계

  1. Av=λvA\mathbf{v}=\lambda\mathbf{v}를 (A−λI)v=0(A-\lambda I)\mathbf{v}=\mathbf{0}으로 정리한다
  2. v≠0\mathbf{v}\neq\mathbf{0}인 해가 있으려면 행렬식이 0이어야 한다 → det⁡(A−λI)=0\det(A-\lambda I)=0
  3. 이 특성방정식을 풀어 고유값 λ\lambda들을 구한다
  4. 각 λ\lambda를 (A−λI)v=0(A-\lambda I)\mathbf{v}=\mathbf{0}에 넣어 고유벡터 v\mathbf{v}를 구한다

🔬 수학공식 ① — 고유값 방정식과 특성방정식

정의

Av=λv,v≠0A\mathbf{v} = \lambda\mathbf{v}, \qquad \mathbf{v} \neq \mathbf{0}

변수 정의\
• AA : n×nn\times n 정사각 행렬 (⚠️ 직사각형은 안 됩니다 — 2단계에서 해결!)\
• v\mathbf{v} : 고유벡터 — 방향이 유지되는 벡터, 영벡터는 제외\
• λ\lambda (람다) : 고유값 — 길이가 변한 배율\
\
특성방정식 유도\
① 오른쪽을 왼쪽으로 옮깁니다. 단, λ\lambda는 숫자이므로 단위행렬 II를 곱해 행렬로 맞춰줍니다(00-02).

Av−λIv=0  ⟹  (A−λI)v=0A\mathbf{v} - \lambda I\mathbf{v} = \mathbf{0} \;\Longrightarrow\; (A - \lambda I)\mathbf{v} = \mathbf{0}

② 만약 (A−λI)(A-\lambda I)에 역행렬이 존재하면 양변에 곱해서 v=0\mathbf{v}=\mathbf{0}만 나옵니다 — 우리가 원하는 답이 아니죠.\
③ 따라서 역행렬이 없어야 하고, 00-02에서 배웠듯 그 조건은 행렬식이 0입니다.

  det⁡(A−λI)=0  \boxed{\;\det(A - \lambda I) = 0\;}

📌 00-02와 연결 🔗: "역행렬이 없다 = 행렬식이 0 = 특이행렬"이라는 사실이 여기서 고유값을 찾는 열쇠가 됩니다. 그때 배운 개념이 지금 쓰이는 것입니다!

📖 수학적 의미 — A=[4123]A=\begin{bmatrix}4&1\\2&3\end{bmatrix}로 끝까지 계산해보기

1단계 — A−λIA-\lambda I 만들기

A−λI=[4−λ123−λ]A - \lambda I = \begin{bmatrix}4-\lambda & 1\\ 2 & 3-\lambda\end{bmatrix}

2단계 — 행렬식 계산 (2×2 행렬식 =ad−bc=ad-bc, 00-02)

det⁡=(4−λ)(3−λ)−1×2=λ2−7λ+12−2=λ2−7λ+10\det = (4-\lambda)(3-\lambda) - 1\times2 = \lambda^2 - 7\lambda + 12 - 2 = \lambda^2 - 7\lambda + 10

3단계 — 특성방정식 풀기

λ2−7λ+10=0  ⟹  (λ−5)(λ−2)=0  ⟹  λ=5,  2\lambda^2 - 7\lambda + 10 = 0 \;\Longrightarrow\; (\lambda-5)(\lambda-2)=0 \;\Longrightarrow\; \boxed{\lambda = 5,\; 2}

4단계 — λ=5\lambda=5의 고유벡터

[4−5123−5][v1v2]=[−112−2][v1v2]=0\begin{bmatrix}4-5 & 1\\ 2 & 3-5\end{bmatrix}\begin{bmatrix}v_1\\v_2\end{bmatrix} = \begin{bmatrix}-1 & 1\\ 2 & -2\end{bmatrix}\begin{bmatrix}v_1\\v_2\end{bmatrix}=\mathbf{0}

첫 줄이 −v1+v2=0-v_1+v_2=0 즉 v1=v2v_1=v_2이므로, 고유벡터는 v=[11]\mathbf{v}=\begin{bmatrix}1\\1\end{bmatrix} (또는 그 상수배)

✅ 검산해봅시다

Av=[4123][11]=[55]=5[11]=λv✓A\mathbf{v}=\begin{bmatrix}4&1\\2&3\end{bmatrix}\begin{bmatrix}1\\1\end{bmatrix}=\begin{bmatrix}5\\5\end{bmatrix}=5\begin{bmatrix}1\\1\end{bmatrix}=\lambda\mathbf{v} \quad \checkmark

❌ 반례 — 고유벡터가 아닌 경우: u=(1,0)\mathbf{u}=(1,0)을 넣으면 Au=(4,2)A\mathbf{u}=(4,2)입니다. (4,2)(4,2)는 (1,0)(1,0)의 배수가 아니므로(두 번째 성분이 0이 아님) 고유벡터가 아닙니다.

🔗 00-01과 연결 — 정규화된 고유벡터

(1,1)(1,1)도 (2,2)(2,2)도 (−1,−1)(-1,-1)도 모두 같은 고유벡터입니다 — 방향만 같으면 되니까요. 그래서 라이브러리는 00-01에서 배운 정규화를 적용해 길이 1로 통일해 돌려줍니다.

v^=v∥v∥=(1,1)2≈(0.7071,  0.7071)\hat{\mathbf{v}} = \frac{\mathbf{v}}{\|\mathbf{v}\|} = \frac{(1,1)}{\sqrt{2}} \approx (0.7071,\; 0.7071)

아래 코드 실행 결과에 나오는 0.70710678이 바로 1/21/\sqrt{2}입니다! 🔗

💡 직관적 의미 — 고유값의 크기가 말해주는 것\
λ=5\lambda=5인 방향은 5배로 늘어나고, λ=2\lambda=2인 방향은 2배로 늘어납니다. 즉 고유값이 클수록 그 방향이 이 행렬에서 "더 지배적인 방향"입니다. 🎯 이 아이디어가 바로 3단계 PCA에서 "고유값이 큰 순서로 주성분을 고르는" 근거가 됩니다.

📐 시각화 ② — 고유벡터(방향 유지) vs 일반 벡터(방향 변화)

📐 시각화 ② — 고유벡터(방향 유지) vs 일반 벡터(방향 변화)

⚙️ 구현 원리\
손으로 특성방정식을 푸는 것은 3×3만 되어도 3차 방정식이라 매우 번거롭습니다. 다행히 np.linalg.eig()가 반복 알고리즘(QR 분해)으로 안전하게 계산해줍니다. 우리는 결과를 검증할 줄만 알면 됩니다.

🔗 프레임워크 관점

개념NumPySciPyPyTorch
일반 행렬np.linalg.eig(A)scipy.linalg.eigtorch.linalg.eig
대칭 행렬 ⭐np.linalg.eigh(A)scipy.linalg.eightorch.linalg.eigh
행렬식np.linalg.det(A)scipy.linalg.dettorch.linalg.det

📌 공분산 행렬처럼 대칭인 행렬에는 반드시 eigh를 쓰세요(3단계에서 사용). 더 빠르고, 항상 실수 고유값을 보장합니다. 🛡️

🐍 Python — 손계산 검증 + 특성방정식 직접 확인

import numpy as np

A = np.array([[4., 1.],
              [2., 3.]])

# ══════ ① 라이브러리로 고유값·고유벡터 구하기 ══════
# eig()는 (고유값 배열, 고유벡터 행렬)을 반환합니다.
# ⚠️ 고유벡터 행렬은 '각 열'이 하나의 고유벡터입니다 (행이 아님!)
eigenvalues, eigenvectors = np.linalg.eig(A)

print("고유값:", eigenvalues)
print("고유벡터(열 단위 저장):\n", eigenvectors.round(6))

# ══════ ② 정의 Av = λv 가 성립하는지 검증 ══════
for i in range(len(eigenvalues)):
    v = eigenvectors[:, i]        # i번째 '열' = i번째 고유벡터
    lam = eigenvalues[i]
    left = A @ v                  # Av  (00-02 행렬×벡터)
    right = lam * v                # λv  (00-01 스칼라배)
    ok = np.allclose(left, right) # 부동소수점이라 '거의 같은지'로 비교
    print(f"  λ={lam:.1f}: Av={left.round(4)}, λv={right.round(4)} → {ok}")

# ══════ ③ 특성방정식 det(A - λI) = 0 을 직접 확인 ══════
# 손으로 푼 λ=5, 2 를 넣으면 행렬식이 0이 되어야 합니다
I = np.eye(2)                      # 2×2 단위행렬 (00-02)
for lam in [5.0, 2.0, 3.0]:  # 3은 고유값이 아님 → 0이 안 나와야 정상
    det = np.linalg.det(A - lam * I)
    tag = "고유값 ✅" if abs(det) < 1e-9 else "고유값 아님 ❌"
    print(f"  det(A - {lam}I) = {det:+.6f}  → {tag}")

# ══════ ④ 고유벡터는 '방향'만 같으면 된다 ══════
v_hand = np.array([1., 1.])           # 손으로 구한 것
v_lib = eigenvectors[:, 0]           # 라이브러리 결과 (길이 1로 정규화됨)
print(f"\n손계산 (1,1)을 00-01의 정규화 적용: {(v_hand/np.linalg.norm(v_hand)).round(6)}")
print(f"라이브러리 결과              : {v_lib.round(6)}")

🔗 GitHub에서 코드 보기 — 00_06_svd_pca.py L12–L46

📘 코드 이해 — 수식과 코드 대응

수학공식코드설명
AvA\mathbf{v}A @ v00-02 행렬×벡터 (*가 아님! ⚠️)
λv\lambda\mathbf{v}lam * v00-01 스칼라배 (모든 성분에 곱)
vi\mathbf{v}_i (i번째)eigenvectors[:, i]⚠️ 열을 뽑아야 함 — 가장 흔한 실수
IInp.eye(2)단위행렬 — λ\lambda를 행렬로 만들기 위해
det⁡(A−λI)=0\det(A-\lambda I)=0np.linalg.det(A - lam*I)특성방정식을 그대로 코드로
v/∥v∥\mathbf{v}/\Vert \mathbf{v}\Vertv/np.linalg.norm(v)00-01 정규화 🔗

🧩 코드 설명 — Python/NumPy 기초

• eigenvectors[:, i] ⚠️ — 콤마 앞 :는 "모든 행", 뒤 i는 "ii번째 열"입니다. NumPy는 고유벡터를 열 단위로 저장하므로 eigenvectors[i](행)로 꺼내면 완전히 틀린 벡터가 나옵니다. 에러도 안 나서 발견하기 어렵습니다!\
\
• np.allclose(a, b) — 부동소수점은 미세한 오차가 있어 ==로 비교하면 거의 항상 False입니다. "거의 같은가"를 판정하는 함수이며, 00-04·00-05에서 계속 써온 검산 습관의 연장입니다. 🔗\
\
• np.eye(n) — n×nn\times n 단위행렬을 만듭니다. 이름이 "eye"인 이유는 II(대문자 아이)와 발음이 같기 때문입니다. 😄\
\
• abs(det) < 1e-9 — 행렬식이 "정확히 0"이 되는 일은 부동소수점에서 드물어, 허용 오차로 판정합니다. 00-04의 상대오차 검증과 같은 철학입니다.\
\
• .round(6) — 출력용 반올림입니다. 계산에는 원본을 쓰고 출력만 다듬는 것이 좋은 습관입니다.

✅🟩 실행 결과

고유값: [5. 2.]
고유벡터(열 단위 저장):
 [[ 0.707107 -0.447214]
 [ 0.707107  0.894427]]
  λ=5.0: Av=[3.5355 3.5355], λv=[3.5355 3.5355] → True
  λ=2.0: Av=[ 0.8944 -1.7889], λv=[ 0.8944 -1.7889] → True
  det(A - 5.0I) = +0.000000  → 고유값 ✅
  det(A - 2.0I) = -0.000000  → 고유값 ✅
  det(A - 3.0I) = -1.000000  → 고유값 아님 ❌

손계산 (1,1)을 00-01의 정규화 적용: [0.707107 0.707107]
라이브러리 결과              : [0.707107 0.707107]

🎯 세 가지가 모두 확인되었습니다. ① 손으로 푼 고유값 5와 2가 라이브러리와 일치, ② 특성방정식 det⁡(A−λI)\det(A-\lambda I)가 고유값에서만 0이 되고 λ=3\lambda=3에서는 −1-1, ③ 손계산 (1,1)(1,1)을 00-01의 정규화로 나누면 라이브러리 결과와 소수점 6자리까지 동일합니다. 🔗

🛠️ 실습 1 🟢🔵

① A=[2003]A=\begin{bmatrix}2&0\\0&3\end{bmatrix}의 고유값·고유벡터를 손으로 구하고 코드와 비교하세요. (정답: λ=2,3\lambda=2,3, 고유벡터 (1,0),(0,1)(1,0),(0,1)) — 대각행렬은 대각선 값이 곧 고유값입니다!\
② A=[0−110]A=\begin{bmatrix}0&-1\\1&0\end{bmatrix}(90° 회전)의 고유값을 구해보세요. ⚠️ 실수 고유값이 없습니다! 왜 그럴까요? (힌트: 90° 돌리면 방향이 유지되는 실수 벡터가 있을까요? 🎡)\
③ 대각합(trace) = 고유값의 합임을 확인하세요. 4+3=7=5+24+3=7=5+2 ✅ 또 행렬식 = 고유값의 곱도 확인하세요. det⁡A=10=5×2\det A=10=5\times2 ✅

🐞 실수하기 쉬운 부분 & 디버깅 포인트

❌ 실수 ① 고유벡터를 행으로 착각 ⭐ 가장 흔함\
왜 틀리나? eigenvectors[0]으로 첫 고유벡터를 꺼내려 하지만, NumPy는 열에 저장합니다. 수정: eigenvectors[:, 0]. 확인법: np.allclose(A@v, lam*v)가 False면 십중팔구 이것입니다.

❌ 실수 ② 부호·크기가 다르다고 "틀렸다"고 오해\
왜 틀리나? +v+\mathbf{v}든 −v-\mathbf{v}든, 길이가 1이든 5이든 모두 같은 고유벡터입니다. 라이브러리·버전마다 부호가 다르게 나올 수 있습니다. 수정: 벡터끼리 비교하지 말고 Av=λvA\mathbf{v}=\lambda\mathbf{v}가 성립하는지로 판정하세요.

❌ 실수 ③ 복소수 고유값에 당황\
회전 행렬처럼 실수 고유벡터가 없는 경우 eig가 복소수를 돌려줍니다(예: 0.+1.j). 버그가 아닙니다. 수정: 대칭 행렬임을 안다면 eigh를 쓰세요 — 항상 실수를 보장합니다.

❌ 실수 ④ 직사각 행렬에 eig 사용\
LinAlgError: Last 2 dimensions must be square가 납니다. 고유값은 정사각 행렬에만 정의됩니다. 바로 이 한계 때문에 2단계의 SVD가 필요합니다! 🔗

📍 00-06-B · 2단계 — SVD: 모든 행렬을 분해하는 만능 도구

🔎 ① 핵심 정의 — 무엇인가?\
1단계 끝에서 본 것처럼, 고유값 분해는 정사각 행렬에만 쓸 수 있습니다. 그런데 실제 데이터는 "사용자 1000명 × 영화 10000편"처럼 대부분 직사각형입니다. 😩\
SVD(특잇값 분해)는 어떤 행렬이든 — 정사각형이 아니어도, 심지어 랭크가 부족해도 — 세 행렬의 곱으로 분해할 수 있다는 강력한 정리입니다.

❓ 왜 필요한가?\
거대한 데이터 행렬에서 "중요한 패턴 몇 개"만 남기고 나머지를 버려 압축할 수 있기 때문입니다. 그리고 놀랍게도, 이 방식이 수학적으로 "가능한 모든 압축 중 최선"임이 증명되어 있습니다(에카르트–영 정리).

⏰ 언제 사용하는가?\
이미지 압축, 노이즈 제거, 추천 시스템(4단계), 잠재의미분석(LSA), 그리고 최근 LLM의 LoRA 파인튜닝도 "가중치 변화량을 저랭크로 근사"하는 SVD 아이디어입니다. 🚀

💜 더 쉽게 이해하기 — 📖 300페이지 소설 요약하기

두꺼운 소설 한 권에는 여러 "주제"가 뒤섞여 있습니다: 주인공의 성장, 로맨스, 음모, 그리고 사소한 배경 묘사들. 📚

SVD가 하는 일은 정확히 세 가지입니다.\
🎭 UU — "각 인물(행)이 어떤 주제에 얼마나 관여하나"\
📏 Σ\Sigma — "각 주제의 비중이 얼마나 큰가" (큰 순서로 정렬! ⭐)\
📑 V⊤V^{\top} — "각 장면(열)이 어떤 주제에 얼마나 속하나"

✂️ 이제 비중이 큰 주제 2~3개만 남기고 나머지를 버리면? 3페이지 요약본이 완성됩니다. 줄거리는 거의 그대로인데 분량은 100분의 1이죠!

🔢 진행 과정 — 압축 4단계

  1. 행렬 AA를 UU, Σ\Sigma, V⊤V^{\top}로 분해한다
  2. Σ\Sigma의 대각선 값(특잇값)을 확인한다 — 이미 큰 순서로 정렬되어 있다
  3. 에너지 비율을 계산해 "kk를 몇으로 할지" 정한다
  4. 상위 kk개만 잘라 다시 곱한다 → 압축된 근사 행렬 AkA_k

🔬 수학공식 ② — SVD 분해와 세 행렬의 정체

A=U Σ V⊤A = U\,\Sigma\,V^{\top}

변수 정의\
• AA : 원본 행렬, 크기 m×nm\times n — 정사각형이 아니어도 됩니다! ✅\
• UU : m×mm\times m 직교 행렬 — 각 열이 "행 방향 패턴"\
• Σ\Sigma : m×nm\times n 대각 행렬 — 대각선에만 특잇값 σ1≥σ2≥⋯≥0\sigma_1\ge\sigma_2\ge\cdots\ge0\
• V⊤V^{\top} : n×nn\times n 직교 행렬의 전치 — 각 행이 "열 방향 패턴"\
• σi\sigma_i : 특잇값 — 항상 0 이상이고 내림차순으로 정렬됨 ⭐\
\
"직교 행렬"이란? 🔗 00-01과 연결\
열벡터들이 서로 내적이 0(직각)이고 각각 길이가 1인 행렬입니다. 00-01에서 배운 내적과 정규화가 여기서 쓰입니다! 이런 행렬은 회전만 시키고 크기는 바꾸지 않습니다. 🔄\
\
💎 세 행렬의 역할을 한 문장으로

Ax=U⏟(3) 다시 회전  Σ⏟(2) 축마다 늘이기  V⊤⏟(1) 회전xA\mathbf{x} = \underbrace{U}_{\text{(3) 다시 회전}}\;\underbrace{\Sigma}_{\text{(2) 축마다 늘이기}}\;\underbrace{V^{\top}}_{\text{(1) 회전}}\mathbf{x}

📌 어떤 행렬이든 "회전 → 늘이기 → 회전" 세 동작으로 분해된다는 뜻입니다. 아무리 복잡해 보이는 변환도 결국 이 셋의 조합입니다! 🎯\
\
고유값 분해와의 관계 (1단계와 연결 🔗)

A⊤A=VΣ⊤ΣV⊤  ⟹  σi=λi(A⊤A)A^{\top}A = V\Sigma^{\top}\Sigma V^{\top} \;\Longrightarrow\; \sigma_i = \sqrt{\lambda_i(A^{\top}A)}

즉 특잇값은 A⊤AA^{\top}A 고유값의 제곱근입니다. A⊤AA^{\top}A는 항상 정사각 대칭 행렬이므로(00-03의 A⊤AA^{\top}A와 같은 형태! 🔗) 고유값 분해가 가능하고, 그래서 직사각형 AA에도 SVD가 항상 존재합니다. ✨

🔬 수학공식 ③ — 저차원 근사와 에너지 비율 ⭐

랭크-kk 근사

A≈Ak=U:,:k  Σk  V:k,:⊤=∑i=1kσi uivi⊤A \approx A_k = U_{:,:k}\;\Sigma_k\;V^{\top}_{:k,:} = \sum_{i=1}^{k}\sigma_i\,\mathbf{u}_i\mathbf{v}_i^{\top}

오른쪽 형태가 핵심 통찰입니다: 행렬 AA는 "σi\sigma_i만큼의 비중을 가진 단순한 층들의 합"입니다. 마치 사진을 여러 장의 투명 필름으로 겹쳐 만드는 것과 같죠. 🎞️\
\
얼마나 정확한가? (에카르트–영 정리)\
버린 부분의 오차는 정확히 버린 특잇값들로 결정됩니다. 🔗 00-01의 노름을 행렬로 확장한 프로베니우스 노름을 씁니다.

∥A−Ak∥F=σk+12+σk+22+⋯\|A - A_k\|_F = \sqrt{\sigma_{k+1}^2 + \sigma_{k+2}^2 + \cdots}

그리고 이보다 더 잘 근사하는 랭크-kk 행렬은 존재하지 않습니다. 즉 SVD 압축은 수학적으로 최적입니다! 🏆\
\
에너지 비율 — "kk를 몇으로 할까?"에 대한 답

energy(k)=∑i=1kσi2∑i=1rσi2\text{energy}(k) = \frac{\sum_{i=1}^{k}\sigma_i^2}{\sum_{i=1}^{r}\sigma_i^2}

📌 실무 기준: 이 값이 0.90~0.99가 되는 최소 kk를 고릅니다. "정보의 95%를 유지하면서 크기는 10분의 1로" 같은 판단을 숫자로 할 수 있게 됩니다. 📊

📖 수학적 의미 — 저장 공간이 얼마나 줄어드나?\
m×nm\times n 행렬을 랭크-kk로 압축하면, 저장해야 할 숫자의 개수가 이렇게 바뀝니다.

구분저장할 숫자 개수1000×1000, k=50 예시
원본 AAm×nm \times n1,000,000개
압축 AkA_kk(m+n+1)k(m + n + 1)100,050개
절감률—약 90% 절감 🎉

💡 직관적 의미: 특잇값은 보통 처음 몇 개가 압도적으로 크고 뒤로 갈수록 급격히 작아집니다. 📉 작은 특잇값은 "거의 기여하지 않는 세부사항"이므로, 버려도 원본과 거의 같은 행렬을 훨씬 적은 숫자로 표현할 수 있습니다.

📐 시각화 ③ — SVD 분해와 잘라내기(truncation)

📐 시각화 ③ — SVD 분해와 잘라내기(truncation)

🗺️ 시각화 ④ — 분해에서 압축까지의 흐름

🗺️ 시각화 ④ — 분해에서 압축까지의 흐름

⚙️ 구현 원리\
np.linalg.svd(A)가 UU, SS, V⊤V^{\top}를 한 번에 돌려줍니다. ⚠️ 여기서 SS는 대각행렬이 아니라 대각선 값만 담은 1차원 배열입니다 — 메모리를 아끼기 위해서죠. 곱셈에 쓰려면 np.diag(S)로 되살려야 합니다. 압축은 슬라이싱만으로 끝납니다.

🔗 프레임워크 관점

개념NumPySciPyPyTorchscikit-learn
전체 SVDnp.linalg.svdscipy.linalg.svdtorch.linalg.svd—
부분 SVD ⭐슬라이싱svds(A, k=50)svd_lowrankTruncatedSVD(50)
랭크 확인matrix_rank—matrix_rank—

📌 실무 주의: 100만×10만 같은 거대한 행렬에 np.linalg.svd를 쓰면 메모리가 터집니다. 💥 이럴 땐 상위 kk개만 근사로 구하는 svds나 TruncatedSVD를 쓰세요.

🐍 Python — SVD 분해 · 에너지 비율 · 오차 검증

import numpy as np
np.set_printoptions(suppress=True)   # 1e-16 같은 지수 표기 끄기

# 각 행이 '앞 행 + 1'인 규칙적인 행렬 → 사실상 패턴이 2개뿐
A = np.array([[1., 2., 3., 4.],
              [2., 3., 4., 5.],
              [3., 4., 5., 6.],
              [4., 5., 6., 7.]])

# ══════ ① SVD 분해 ══════
# ⚠️ S는 '1차원 배열'로 반환됩니다 (대각행렬 아님!)
U, S, Vt = np.linalg.svd(A)

print("특잇값 S :", S.round(4))
print("행렬 랭크 :", np.linalg.matrix_rank(A), "← 0이 아닌 특잇값의 개수")

# ══════ ② 에너지 비율로 k 정하기 ══════
# energy(k) = (상위 k개 σ²의 합) / (전체 σ²의 합)
energy = np.cumsum(S**2) / np.sum(S**2)   # cumsum: 누적 합
print("\n--- k별 에너지 비율과 오차 ---")
for k in [1, 2, 3]:
    # ★핵심 한 줄★ 수식 A_k = U[:,:k] Σ_k Vt[:k,:] 그대로
    A_k = U[:, :k] @ np.diag(S[:k]) @ Vt[:k, :]

    # 00-01의 노름을 행렬로 확장한 '프로베니우스 노름'으로 오차 측정
    err = np.linalg.norm(A - A_k)            # 기본값이 'fro'

    # 이론값 √(σ_{k+1}² + ...) 과 비교 (에카르트-영 정리)
    theory = np.sqrt(np.sum(S[k:]**2))
    print(f"  k={k}: 에너지 {energy[k-1]:.4f} | 실제오차 {err:.6f}"
          f" | 이론오차 {theory:.6f}")

# ══════ ③ k=1과 k=2 근사 행렬 비교 ══════
A_1 = U[:, :1] @ np.diag(S[:1]) @ Vt[:1, :]
A_2 = U[:, :2] @ np.diag(S[:2]) @ Vt[:2, :]
print("\nk=1 근사 (패턴 1개만):\n", A_1.round(2))
print("\nk=2 근사 (패턴 2개):\n", A_2.round(2))
print("\n원본과 k=2가 같은가?", np.allclose(A, A_2))

# ══════ ④ '층의 합' 형태로도 같은지 확인 ══════
# A_k = Σ σ_i · u_i v_iᵀ  (외적의 가중합)
A_layers = sum(S[i] * np.outer(U[:, i], Vt[i, :]) for i in range(2))
print("층의 합 방식과 슬라이싱 방식이 같은가?", np.allclose(A_2, A_layers))

🔗 GitHub에서 코드 보기 — 00_06_svd_pca.py L50–L92

📘 코드 이해 — 수식이 코드의 어느 줄인가

수학공식코드설명
A=UΣV⊤A=U\Sigma V^{\top}U, S, Vt = np.linalg.svd(A)한 줄로 세 행렬 반환
Σk\Sigma_knp.diag(S[:k])⚠️ 1차원 → 대각행렬 복원 필수
U:,:kU_{:,:k}U[:, :k]열을 자름 (앞쪽 k개 패턴)
V:k,:⊤V^{\top}_{:k,:}Vt[:k, :]행을 자름 (전치되어 있으므로!)
∑i≤kσi2∑σi2\frac{\sum_{i\le k}\sigma_i^2}{\sum\sigma_i^2}np.cumsum(S**2)/np.sum(S**2)에너지 비율을 한 번에 전부
∥A−Ak∥F\Vert A-A_k\Vert _Fnp.linalg.norm(A - A_k)00-01 노름의 행렬 버전 🔗
σiuivi⊤\sigma_i\mathbf{u}_i\mathbf{v}_i^{\top}S[i]*np.outer(U[:,i], Vt[i,:])'층' 하나 = 외적 × 비중

🧩 코드 설명 — Python/NumPy 기초

• np.diag(S) ⚠️ — svd가 돌려주는 S는 1차원 배열입니다. U @ S @ Vt로 바로 곱하면 shape 오류가 나거나 조용히 틀린 값이 나옵니다. 반드시 np.diag()로 2차원 대각행렬로 되살리세요.\
\
• np.cumsum(x) — 누적 합입니다. [1,2,3] → [1,3,6]. 에너지 비율을 모든 k에 대해 한 번에 구할 수 있어 매우 편리합니다.\
\
• np.linalg.norm(행렬) — 행렬에 쓰면 기본이 프로베니우스 노름입니다. 모든 원소를 제곱해 더하고 루트 — 00-01의 벡터 노름을 그대로 확장한 것입니다. 🔗\
\
• np.outer(u, v) — 외적입니다. 벡터 두 개로 행렬을 만듭니다(00-01의 내적은 숫자를 만들었죠). 5단계 역전파에서 다시 등장합니다!\
\
• sum(... for i in range(2)) — 제너레이터 표현식으로 여러 행렬을 더합니다. 수식의 ∑i=1k\sum_{i=1}^{k}를 코드로 그대로 옮긴 형태입니다.\
\
• np.set_printoptions(suppress=True) — 1.93e-15 같은 지수 표기를 0.로 깔끔하게 보여줍니다. 사실 이 값들은 이론상 0인데 부동소수점 오차로 남은 찌꺼기입니다.

✅🟩 실행 결과

특잇값 S : [17.1652  1.1652  0.      0.    ]
행렬 랭크 : 2 ← 0이 아닌 특잇값의 개수

--- k별 에너지 비율과 오차 ---
  k=1: 에너지 0.9954 | 실제오차 1.165151 | 이론오차 1.165151
  k=2: 에너지 1.0000 | 실제오차 0.000000 | 이론오차 0.000000
  k=3: 에너지 1.0000 | 실제오차 0.000000 | 이론오차 0.000000

k=1 근사 (패턴 1개만):
 [[1.7  2.31 2.92 3.53]
 [2.31 3.14 3.96 4.79]
 [2.92 3.96 5.01 6.05]
 [3.53 4.79 6.05 7.32]]

k=2 근사 (패턴 2개):
 [[1. 2. 3. 4.]
 [2. 3. 4. 5.]
 [3. 4. 5. 6.]
 [4. 5. 6. 7.]]

원본과 k=2가 같은가? True
층의 합 방식과 슬라이싱 방식이 같은가? True

🔍 결과 해석 — 네 가지 발견

① 특잇값이 2개뿐 — 17.17, 1.17, 0, 017.17,\ 1.17,\ 0,\ 0. 뒤의 두 개가 0이라는 것은 이 4×4 행렬이 사실 패턴 2개로 완전히 만들어진다는 뜻입니다. 실제로 각 행이 "앞 행 + 1"이라는 규칙이 있었죠. 이를 랭크 2라고 합니다.

② k=2k=2에서 오차가 정확히 0 — 압축했는데 정보 손실이 전혀 없습니다! 16개 숫자를 저장하던 것을 2×(4+4+1)=182\times(4+4+1)=18개... 어라, 오히려 늘었네요? 😅 작은 행렬에서는 압축 효과가 없습니다. 큰 행렬일수록 이득이 커집니다(앞의 1000×1000 표 참고).

③ 에카르트–영 정리가 실제로 성립 ⭐ — k=1k=1에서 실제 오차 1.165151과 이론값 σ22=σ2=\sqrt{\sigma_2^2}=\sigma_2= 1.165151이 소수점 6자리까지 완벽히 일치합니다. 수학이 예측한 대로 정확히 나온 것입니다! 🎯

④ k=1k=1인데도 에너지가 99.54% — 첫 번째 패턴 하나만으로 정보의 99.5%를 담습니다. 근사 행렬을 보면 [1.7, 2.31, ...]로 원본 [1, 2, ...]와 대략적인 모양은 비슷하죠. 이것이 압축의 원리입니다. ✂️

🛠️ 실습 2 🔵🔴

① 무작위 행렬 np.random.default_rng(0).standard_normal((20,20))의 특잇값을 출력해보세요. 규칙 없는 데이터는 특잇값이 고르게 퍼져 압축이 잘 안 된다는 것을 확인하세요.\
② 에너지 90%를 넘기는 최소 kk를 자동으로 찾는 함수를 작성하세요. (힌트: np.searchsorted(energy, 0.9) + 1)\
③ 🚀 이미지 압축 체험: 흑백 이미지를 행렬로 읽어 k=5,20,50k=5, 20, 50으로 압축하고 matplotlib으로 나란히 그려보세요. kk가 커질수록 선명해지는 과정이 눈에 보입니다! 🖼️\
④ 원본 AA에 노이즈를 조금 섞은 뒤 k=2k=2로 압축해보세요. 노이즈가 제거되는 것을 확인할 수 있습니다. (노이즈는 작은 특잇값에 실리기 때문)

🐞 실수하기 쉬운 부분 & 디버깅 포인트

❌ 실수 ① np.diag() 누락 ⭐ 가장 흔함\
왜 틀리나? U @ S @ Vt로 쓰면 S가 1차원이라 행렬 곱이 아닌 이상한 연산이 되거나 shape 오류가 납니다. 수정: U @ np.diag(S) @ Vt. 확인법: print(S.shape)가 (4,)면 1차원입니다.

❌ 실수 ② UU와 V⊤V^{\top}의 자르는 방향 혼동\
UU는 [:, :k](열), V⊤V^{\top}는 [:k, :](행)입니다. 왜? V⊤V^{\top}는 이미 전치된 상태라 패턴이 행에 있습니다. 확인법: print(U[:,:k].shape, Vt[:k,:].shape)로 (m,k)(m,k)와 (k,n)(k,n)인지 보세요.

❌ 실수 ③ 직사각 행렬에서 shape 불일치\
m≠nm\neq n이면 full_matrices=True(기본값)일 때 UU가 m×mm\times m, V⊤V^{\top}가 n×nn\times n이라 중간 Σ\Sigma 크기가 안 맞습니다. 수정: full_matrices=False를 쓰면 필요한 최소 크기로 맞춰줍니다. 4단계에서 실제로 사용합니다.

❌ 실수 ④ 거대 행렬에 전체 SVD\
100만×10만에 np.linalg.svd를 쓰면 메모리가 폭발합니다 💥. 수정: scipy.sparse.linalg.svds(A, k=50)이나 TruncatedSVD를 쓰세요.

✅ 검산 습관: ① np.allclose(A, U@np.diag(S)@Vt)로 분해가 맞는지 ② 실제 오차와 이론 오차 ∑i>kσi2\sqrt{\sum_{i>k}\sigma_i^2}가 일치하는지 — 이 두 가지면 충분합니다. 🛡️

📍 00-06-C · 3단계 — PCA: 데이터의 가장 중요한 방향 찾기

🔎 ① 핵심 정의 — 무엇인가?\
PCA(주성분 분석)는 1~2단계의 고유벡터·SVD를 활용해 데이터가 가장 넓게 퍼져 있는 방향(주성분)을 찾고, 그 방향을 새 좌표축으로 삼아 차원을 줄이는 방법입니다.

❓ 왜 필요한가?\
여러 변수가 서로 강하게 연관되어 있을 때(예: 키와 몸무게), 실제로는 훨씬 적은 수의 "핵심 축"만으로 데이터를 거의 그대로 설명할 수 있습니다. 변수가 100개여도 실질적인 자유도는 5개뿐일 수 있죠. 이를 찾아내면 계산량 감소·시각화·노이즈 제거가 동시에 해결됩니다.

⏰ 언제 사용하는가?\
고차원 데이터 시각화(1000차원 → 2차원 그래프), 머신러닝 전처리, 이미지 얼굴 인식(고유얼굴, eigenface), 유전체 데이터 분석 등.

💜 더 쉽게 이해하기 — 📷 사진 찍는 각도 고르기

긴 자동차 모형을 사진 한 장(2차원)으로 남겨야 합니다. 🚗📷

😀 옆에서 찍으면 — 길이와 높이가 모두 잘 보입니다. 정보가 많이 살아남죠.\
😵 정면에서 찍으면 — 차가 얼마나 긴지 전혀 알 수 없습니다. 정보가 뭉개집니다.

PCA는 "가장 잘 보이는 각도"를 수학적으로 찾아주는 것입니다. 그리고 "잘 보인다"의 수학적 정의는 놀랍도록 간단합니다 — "그 방향으로 봤을 때 데이터가 가장 넓게 퍼져 있다(분산이 크다)". 📏

🔢 진행 과정 — PCA 5단계

  1. 중심화 — 평균을 빼서 데이터 중심을 원점으로 옮긴다 ⚠️ 절대 빠뜨리면 안 됨
  2. 공분산 행렬을 계산한다 — 변수들이 얼마나 함께 움직이는가
  3. 공분산 행렬의 고유벡터·고유값을 구한다 (1단계!)
  4. 고유값이 큰 순서로 정렬한다 → PC1, PC2, ...
  5. 상위 몇 개 주성분에 데이터를 투영해 차원을 줄인다

🔬 수학공식 ④ — 공분산 행렬과 "왜 고유벡터인가"의 유도 ⭐

① 중심화 — 🔗 00-04의 정규화, 00-05의 z-점수와 같은 발상

Xc=X−XˉX_c = X - \bar{X}

② 공분산 행렬 — 🔗 00-03의 A⊤AA^{\top}A와 똑같은 형태!

C=1n−1Xc⊤XcC = \frac{1}{n-1} X_c^{\top} X_c

CC의 (i,j)(i,j) 성분은 "ii번째 변수와 jj번째 변수가 얼마나 함께 변하는가"이고, 대각선은 각 변수의 분산(00-05 🔗)입니다.\
\
③ 핵심 질문: 어떤 방향 v\mathbf{v}로 봐야 가장 넓게 퍼질까?\
길이 1인 방향 v\mathbf{v}에 데이터를 투영하면(00-01의 내적 🔗) 그 값들의 분산은:

Var(Xcv)=v⊤C v\text{Var}(X_c\mathbf{v}) = \mathbf{v}^{\top} C\, \mathbf{v}

④ 최적화 문제로 바꾸면

max⁡v  v⊤Cv단, ∥v∥=1\max_{\mathbf{v}} \; \mathbf{v}^{\top}C\mathbf{v} \quad \text{단, } \|\mathbf{v}\|=1

⑤ 답이 곧 고유벡터 — 이 문제의 해는 CC의 가장 큰 고유값에 대응하는 고유벡터이고, 그때 분산의 최댓값은 그 고유값 자체입니다.

  Cv=λv,최대 분산=λ1  \boxed{\;C\mathbf{v} = \lambda\mathbf{v},\qquad \text{최대 분산} = \lambda_1\;}

📌 1단계에서 배운 Av=λvA\mathbf{v}=\lambda\mathbf{v}가 여기서 "분산 최대화 문제의 답"으로 재등장합니다! 🔗 고유값이 클수록 그 방향의 정보량이 많다는 1단계의 직관이 정확히 증명된 셈입니다. ✨\
\
⑥ 설명 분산 비율 — 2단계의 에너지 비율과 같은 개념

ratioi=λi∑jλj\text{ratio}_i = \frac{\lambda_i}{\sum_j \lambda_j}

🔬 수학공식 ⑤ — PCA와 SVD는 같은 것이다 🔴 심화

중심화된 데이터 XcX_c에 직접 SVD를 적용하면:

Xc=UΣV⊤X_c = U\Sigma V^{\top}

이를 공분산 식에 넣어봅시다.

C=Xc⊤Xcn−1=(UΣV⊤)⊤(UΣV⊤)n−1=VΣ⊤U⊤UΣV⊤n−1C = \frac{X_c^{\top}X_c}{n-1} = \frac{(U\Sigma V^{\top})^{\top}(U\Sigma V^{\top})}{n-1} = \frac{V\Sigma^{\top}U^{\top}U\Sigma V^{\top}}{n-1}

UU는 직교행렬이라 U⊤U=IU^{\top}U=I이므로:

C=V(Σ2n−1)V⊤C = V\left(\frac{\Sigma^2}{n-1}\right)V^{\top}

이것은 정확히 고유값 분해의 형태입니다. 따라서:

  주성분 방향=V의 열,λi=σi2n−1  \boxed{\;\text{주성분 방향} = V\text{의 열},\qquad \lambda_i = \frac{\sigma_i^2}{n-1}\;}

📌 즉 PCA는 "중심화한 데이터에 SVD를 돌린 것"과 완전히 같습니다. 실무 라이브러리(scikit-learn 포함)는 공분산 행렬을 만들지 않고 바로 SVD를 씁니다 — 더 빠르고 수치적으로 안정적이기 때문입니다. 아래 코드에서 두 방법이 같은 값을 내는지 직접 검증합니다! 🔬

💡 직관적 의미 — 왜 "분산이 크다 = 정보가 많다"인가?\
어떤 방향으로 투영했더니 모든 점이 한곳에 뭉쳐 있다면(분산 0), 그 방향으로는 데이터를 전혀 구별할 수 없습니다. 😵 반대로 넓게 퍼져 있다면 점들을 잘 구별할 수 있죠. 그래서 "분산 = 구별 능력 = 정보량"입니다. 🎯

📐 시각화 ⑤ — 주성분 방향과 투영

📐 시각화 ⑤ — 주성분 방향과 투영

⚙️ 구현 원리 — 두 가지 경로\
① 공분산 경로: np.cov() → np.linalg.eigh() — 개념을 이해하기 좋음\
② SVD 경로: 중심화 후 np.linalg.svd() — 실무에서 쓰는 방법, 더 안정적\
아래 코드에서 두 경로가 같은 답을 내는지 검증합니다. 이것이 수식 ⑤를 코드로 확인하는 과정입니다. 🔬

🔗 프레임워크 관점

개념NumPy 직접 구현scikit-learnPyTorch
중심화X - X.mean(axis=0)자동 처리X - X.mean(0)
주성분eigh(np.cov(Xc.T))PCA(n_components=k)torch.pca_lowrank
설명 분산 비율ev / ev.sum().explained_variance_ratio_직접 계산
투영·복원Xc @ V[:, :k].transform() / .inverse_transform()Xc @ V[:, :k]

🐍 Python — PCA 직접 구현 + SVD 경로와 교차 검증

import numpy as np
np.random.seed(42)

# ══════ 데이터 생성: 키와 상관된 몸무게 (00-05 정규분포 활용) ══════
n = 100
height = np.random.normal(170, 8, n)                    # N(170, 8²)
weight = 0.8 * height - 60 + np.random.normal(0, 3, n)  # 키에 노이즈 추가
X = np.column_stack([height, weight])                    # (100, 2)

# ══════ 경로 A) 공분산 행렬 → 고유값 분해 ══════
# ① 중심화 — ⚠️ 절대 빠뜨리면 안 되는 단계!
Xc = X - X.mean(axis=0)

# ② 공분산 행렬 C = XcᵀXc/(n-1)  ← 00-03의 AᵀA와 같은 형태
C = np.cov(Xc.T)                     # ⚠️ 전치 필수! (변수가 행이 되어야 함)
print("공분산 행렬:\n", C.round(4))

# ③ 고유값 분해 — 대칭행렬이므로 eigh (1단계에서 배운 규칙!)
eigenvalues, eigenvectors = np.linalg.eigh(C)

# ④ eigh는 '오름차순'으로 주므로 내림차순으로 뒤집기
order = np.argsort(eigenvalues)[::-1]
eigenvalues = eigenvalues[order]
eigenvectors = eigenvectors[:, order]      # 열 단위로 재정렬

ratio = eigenvalues / eigenvalues.sum()      # 설명 분산 비율
print(f"\n고유값 : {eigenvalues.round(4)}")
print(f"설명비율: PC1 {ratio[0]:.1%} / PC2 {ratio[1]:.1%}")
print(f"PC1 방향: {eigenvectors[:, 0].round(4)}")

# ══════ 경로 B) SVD로 바로 (수식 ⑤ 검증!) ══════
U, S, Vt = np.linalg.svd(Xc, full_matrices=False)
lambda_from_svd = S**2 / (n - 1)          # λ = σ²/(n-1)

print(f"\n[검증] 공분산 경로 고유값: {eigenvalues.round(4)}")
print(f"[검증] SVD 경로  σ²/(n-1): {lambda_from_svd.round(4)}")
print(f"[검증] 두 방법이 일치하는가? {np.allclose(eigenvalues, lambda_from_svd)}")

# ══════ 차원 축소: 2D → 1D 투영 후 복원 ══════
k = 1
V_k = eigenvectors[:, :k]            # PC1만 사용 (2×1)
Z = Xc @ V_k                         # 투영: (100,2)@(2,1) → (100,1)
X_restored = Z @ V_k.T + X.mean(axis=0)   # 복원 (평균 다시 더하기!)

recon_error = np.linalg.norm(X - X_restored)
theory_error = np.sqrt((n - 1) * eigenvalues[1])   # 버린 고유값으로 예측
print(f"\n2D→1D 압축 후 복원 오차: {recon_error:.4f}")
print(f"이론 예측 √((n-1)·λ₂)  : {theory_error:.4f}")

🔗 GitHub에서 코드 보기 — 00_06_svd_pca.py L96–L143

📘 코드 이해 — 수식이 코드의 어느 줄인가

수학공식코드설명
Xc=X−XˉX_c = X-\bar{X}X - X.mean(axis=0)⚠️ axis=0 = 열별 평균 (변수별)
C=Xc⊤Xcn−1C=\frac{X_c^{\top}X_c}{n-1}np.cov(Xc.T)⚠️ .T 필수 — 00-03의 A⊤AA^{\top}A 🔗
Cv=λvC\mathbf{v}=\lambda\mathbf{v}np.linalg.eigh(C)1단계 고유값 방정식 🔗
λi/∑λj\lambda_i/\sum\lambda_jev / ev.sum()설명 분산 비율 (2단계 에너지와 동일)
λi=σi2/(n−1)\lambda_i=\sigma_i^2/(n-1)S**2/(n-1)⭐ 수식 ⑤ — PCA=SVD 증명
Z=XcVkZ = X_c V_kXc @ V_k투영 = 00-01의 내적을 한꺼번에 🔗
X^=ZVk⊤+Xˉ\hat{X}=ZV_k^{\top}+\bar{X}Z @ V_k.T + X.mean(0)⚠️ 평균을 다시 더해야 원래 위치로

🧩 코드 설명 — NumPy 기초

• np.cov(Xc.T) ⚠️ — NumPy의 cov는 "각 행이 하나의 변수"라고 가정합니다. 우리 데이터는 각 열이 변수(키, 몸무게)이므로 .T로 뒤집어야 합니다. 안 하면 100×100 행렬이 나와 바로 알아챌 수 있습니다.\
\
• np.linalg.eigh vs eig — 공분산 행렬은 항상 대칭이므로 eigh가 정답입니다. 더 빠르고, 복소수가 절대 안 나오며, 고유벡터가 서로 직교함을 보장합니다.\
\
• np.argsort(ev)[::-1] — argsort는 오름차순 인덱스를 주므로, [::-1]로 뒤집어 내림차순(큰 고유값 먼저)으로 만듭니다. eigh가 오름차순으로 주기 때문에 필수입니다.\
\
• eigenvectors[:, order] — 고유벡터는 열에 저장되므로, 열 순서를 바꿔야 합니다(1단계의 그 규칙 🔗).\
\
• full_matrices=False — XcX_c가 100×2인 직사각형이라, 기본값이면 UU가 100×100이 되어 메모리를 낭비합니다. False로 100×2만 만듭니다.

✅🟩 실행 결과

공분산 행렬:
 [[52.7853 39.3925]
 [39.3925 37.4308]]

고유값 : [85.2417  4.9744]
설명비율: PC1 94.5% / PC2 5.5%
PC1 방향: [-0.7718 -0.6359]

[검증] 공분산 경로 고유값: [85.2417  4.9744]
[검증] SVD 경로  σ²/(n-1): [85.2417  4.9744]
[검증] 두 방법이 일치하는가? True

2D→1D 압축 후 복원 오차: 22.1915
이론 예측 √((n-1)·λ₂)  : 22.1915

🔍 결과 해석 — 네 가지

① PC1이 94.5%를 설명 — 2차원 데이터를 1차원으로 줄여도 정보의 94.5%가 살아남습니다. 키와 몸무게가 강하게 연관되어 있어, 사실상 "체격"이라는 하나의 축으로 대부분 설명되기 때문입니다. 📏

② PC1 방향 (−0.77,−0.64)(-0.77, -0.64) — 두 성분의 부호가 같으므로 "키가 크면 몸무게도 크다"는 방향입니다. 부호가 둘 다 음수인 것은 방향만 반대일 뿐 같은 축입니다(1단계에서 배운 그 성질 🔗).

③ 수식 ⑤가 코드로 증명됨 ⭐ — 공분산 경로의 고유값 [85.2417,4.9744][85.2417, 4.9744]와 SVD 경로의 σ2/(n−1)\sigma^2/(n-1)이 소수점 4자리까지 완전히 일치합니다. PCA와 SVD가 같은 것임을 눈으로 확인했습니다! 🎯

④ 복원 오차까지 이론과 일치 — 실제 오차 22.1915와 버린 고유값으로 예측한 (n−1)λ2=\sqrt{(n-1)\lambda_2}= 22.1915가 정확히 같습니다. 2단계 에카르트–영 정리가 PCA에서도 그대로 성립하는 것입니다. 🔗

⚠️ 매우 중요 — PCA는 "단위"에 민감합니다

위 결과는 몸무게를 kg으로 쟀을 때입니다. 만약 g으로 바꾸면(1000배) 어떻게 될까요?

👉 설명 비율이 PC1 100.0% / PC2 0.0%가 됩니다. 몸무게의 분산이 100만 배로 커져 PC1이 그냥 "몸무게 축"이 되어버리기 때문입니다. 😱

✅ 해결책: 변수들의 단위가 다르면 표준화(00-05의 z-점수 🔗)를 먼저 하세요.
Xs = (X - X.mean(0)) / X.std(0, ddof=1)\
표준화 후에는 공분산 행렬이 상관 행렬이 되고, 설명 비율은 94.3% / 5.7%로 단위와 무관해집니다. 🛡️

🛠️ 실습 3 🔵🔴

① scikit-learn의 PCA(n_components=2).fit(X)로 같은 데이터를 분석하고 .explained_variance_ratio_가 [0.9449, 0.0551]과 일치하는지 확인하세요.\
② 몸무게 단위를 g으로 바꿔(weight * 1000) 다시 돌려보고, 설명 비율이 [1.0, 0.0]이 되는 것을 확인하세요. 😱\
③ 표준화 후 다시 계산해 [0.9431, 0.0569]가 나오는지, 그리고 단위를 g으로 바꿔도 결과가 변하지 않는지 확인하세요.\
④ 🚀 sklearn.datasets.load_digits()(64차원 손글씨)를 PCA로 2차원으로 압축해 산점도를 그려보세요. 숫자들이 군집을 이루는 모습이 보입니다! ✨

🐞 실수하기 쉬운 부분 & 디버깅 포인트

❌ 실수 ① 중심화 누락 ⭐ 가장 치명적\
왜 틀리나? 평균이 0이 아니면, 원점에서 데이터 중심까지의 방향이 "분산이 큰 방향"으로 잘못 섞여 들어갑니다. 수정: 반드시 X - X.mean(axis=0). 확인법: print(Xc.mean(axis=0))이 거의 0인지 보세요.

❌ 실수 ② np.cov()에 전치 안 함\
np.cov(Xc)로 쓰면 100×100 행렬이 나옵니다. 수정: np.cov(Xc.T) 또는 np.cov(Xc, rowvar=False). 확인법: print(C.shape)가 (변수 개수, 변수 개수)인지 보세요.

❌ 실수 ③ eigh 결과를 정렬 안 함\
eigh는 오름차순이라, 정렬 없이 eigenvectors[:, 0]을 쓰면 가장 덜 중요한 방향을 고르게 됩니다! 😱 확인법: 설명 비율 첫 값이 작으면(예: 5.5%) 뒤집힌 것입니다.

❌ 실수 ④ 복원할 때 평균을 안 더함\
Z @ V_k.T만 하면 원점 근처에 뭉친 데이터가 나옵니다. 수정: + X.mean(axis=0)을 잊지 마세요.

❌ 실수 ⑤ 단위가 다른 변수를 표준화 없이 사용\
연봉(천만 단위)과 나이(십 단위)를 함께 넣으면 연봉이 PC1을 독점합니다. 수정: 표준화하거나, 단위가 같은 변수끼리만 쓰세요.


💻 GitHub 실습 — 코드로 직접 확인하기

📂 이 장의 코드 폴더: book/00-math-overview-for-beginners/00-06-svd-pca

책에 실린 코드를 장(절)별로 모아 둔 폴더입니다. 아래 파일을 열어 보거나, Colab에서 바로 실행해 보세요.

파일용도링크
00_06_svd_pca.py책의 코드 블록 5개를 순서대로 모은 실행 스크립트 (# %% 셀 구분)코드 보기
00_06_svd_pca.ipynb같은 코드를 블록당 셀 1개로 담은 주피터 노트북보기 · Colab
README.md코드 블록 목록 · 실행 방법 · 검증 결과보기
🎬 medium-series/08-1-eigen-svd-pca/Medium 시리즈 예제 — 같은 주제를 블로그용으로 다듬은 실습폴더 · Colab

🧪 저장소 검증 상태: ✅ 실행 OK

🔑 핵심 코드 1 — SVD 랭크-k 근사와 에카르트–영 정리 검증

📄 00_06_svd_pca.py L53–L80 · Block 2 (📍 00-06-B · 2단계 — SVD: 모든 행렬을 분해하는 만능 도구)

# 각 행이 '앞 행 + 1'인 규칙적인 행렬 → 사실상 패턴이 2개뿐
A = np.array([[1., 2., 3., 4.],
              [2., 3., 4., 5.],
              [3., 4., 5., 6.],
              [4., 5., 6., 7.]])

# ══════ ① SVD 분해 ══════
# ⚠️ S는 '1차원 배열'로 반환됩니다 (대각행렬 아님!)
U, S, Vt = np.linalg.svd(A)

print("특잇값 S :", S.round(4))
print("행렬 랭크 :", np.linalg.matrix_rank(A), "← 0이 아닌 특잇값의 개수")

# ══════ ② 에너지 비율로 k 정하기 ══════
# energy(k) = (상위 k개 σ²의 합) / (전체 σ²의 합)
energy = np.cumsum(S**2) / np.sum(S**2)   # cumsum: 누적 합
print("\n--- k별 에너지 비율과 오차 ---")
for k in [1, 2, 3]:
    # ★핵심 한 줄★ 수식 A_k = U[:,:k] Σ_k Vt[:k,:] 그대로
    A_k = U[:, :k] @ np.diag(S[:k]) @ Vt[:k, :]

    # 00-01의 노름을 행렬로 확장한 '프로베니우스 노름'으로 오차 측정
    err = np.linalg.norm(A - A_k)            # 기본값이 'fro'

    # 이론값 √(σ_{k+1}² + ...) 과 비교 (에카르트-영 정리)
    theory = np.sqrt(np.sum(S[k:]**2))
    print(f"  k={k}: 에너지 {energy[k-1]:.4f} | 실제오차 {err:.6f}"
          f" | 이론오차 {theory:.6f}")

▶ 실행 결과 — 위 코드를 실행한 출력 (직접 실행: Windows · Python 3.11.2 · numpy 2.4.6, 앞 블록부터 순서대로 실행)

특잇값 S : [17.1652  1.1652  0.      0.    ]
행렬 랭크 : 2 ← 0이 아닌 특잇값의 개수

--- k별 에너지 비율과 오차 ---
  k=1: 에너지 0.9954 | 실제오차 1.165151 | 이론오차 1.165151
  k=2: 에너지 1.0000 | 실제오차 0.000000 | 이론오차 0.000000
  k=3: 에너지 1.0000 | 실제오차 0.000000 | 이론오차 0.000000

🔑 핵심 코드 2 — PCA 두 경로 — 공분산 고유분해 vs SVD

📄 00_06_svd_pca.py L100–L132 · Block 3 (📍 00-06-C · 3단계 — PCA: 데이터의 가장 중요한 방향 찾기)

n = 100
height = np.random.normal(170, 8, n)                    # N(170, 8²)
weight = 0.8 * height - 60 + np.random.normal(0, 3, n)  # 키에 노이즈 추가
X = np.column_stack([height, weight])                    # (100, 2)

# ══════ 경로 A) 공분산 행렬 → 고유값 분해 ══════
# ① 중심화 — ⚠️ 절대 빠뜨리면 안 되는 단계!
Xc = X - X.mean(axis=0)

# ② 공분산 행렬 C = XcᵀXc/(n-1)  ← 00-03의 AᵀA와 같은 형태
C = np.cov(Xc.T)                     # ⚠️ 전치 필수! (변수가 행이 되어야 함)
print("공분산 행렬:\n", C.round(4))

# ③ 고유값 분해 — 대칭행렬이므로 eigh (1단계에서 배운 규칙!)
eigenvalues, eigenvectors = np.linalg.eigh(C)

# ④ eigh는 '오름차순'으로 주므로 내림차순으로 뒤집기
order = np.argsort(eigenvalues)[::-1]
eigenvalues = eigenvalues[order]
eigenvectors = eigenvectors[:, order]      # 열 단위로 재정렬

ratio = eigenvalues / eigenvalues.sum()      # 설명 분산 비율
print(f"\n고유값 : {eigenvalues.round(4)}")
print(f"설명비율: PC1 {ratio[0]:.1%} / PC2 {ratio[1]:.1%}")
print(f"PC1 방향: {eigenvectors[:, 0].round(4)}")

# ══════ 경로 B) SVD로 바로 (수식 ⑤ 검증!) ══════
U, S, Vt = np.linalg.svd(Xc, full_matrices=False)
lambda_from_svd = S**2 / (n - 1)          # λ = σ²/(n-1)

print(f"\n[검증] 공분산 경로 고유값: {eigenvalues.round(4)}")
print(f"[검증] SVD 경로  σ²/(n-1): {lambda_from_svd.round(4)}")
print(f"[검증] 두 방법이 일치하는가? {np.allclose(eigenvalues, lambda_from_svd)}")

▶ 실행 결과 — 위 코드를 실행한 출력 (직접 실행: Windows · Python 3.11.2 · numpy 2.4.6, 앞 블록부터 순서대로 실행)

공분산 행렬:
 [[52.7853 39.3925]
 [39.3925 37.4308]]

고유값 : [85.2417  4.9744]
설명비율: PC1 94.5% / PC2 5.5%
PC1 방향: [-0.7718 -0.6359]

[검증] 공분산 경로 고유값: [85.2417  4.9744]
[검증] SVD 경로  σ²/(n-1): [85.2417  4.9744]
[검증] 두 방법이 일치하는가? True

☁️ Colab에서 바로 실행

👉 Open in Colab — 00_06_svd_pca.ipynb · 설치 없이 브라우저에서 셀을 위에서부터 순서대로 실행하세요.

🖥️ 내 컴퓨터에서 실행하기

git clone https://github.com/otoloism/Zero_to_ML_Master.git
cd Zero_to_ML_Master
python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install -r book/requirements.txt   # 책 전체용 (PyTorch 등 포함, 용량 큼)
# 이 장만 실행한다면 이것만으로 충분합니다: pip install numpy
cd book/00-math-overview-for-beginners/00-06-svd-pca
python 00_06_svd_pca.py

⭐ GitHub Star / Fork

⭐ 코드가 도움이 되셨다면 GitHub 저장소에 Star를 눌러 주세요. 새 장의 코드가 올라올 때 가장 먼저 알 수 있습니다.
🍴 직접 고쳐 보며 실험하고 싶다면 Fork 해서 내 저장소에서 마음껏 바꿔 보세요. 오류 제보·개선 제안은 Issues로 환영합니다!


🔮 다음 글 예고

다음 글은 00-06 SVD·PCA (2부) — SVD 추천 시스템부터 미니 신경망까지 🎬 입니다. 99% 이상이 비어 있는 넷플릭스식 평점 행렬을 SVD로 채워 추천을 만들고, 00-01부터 00-06까지 배운 여섯 가지 재료를 총동원해 NumPy만으로 XOR을 푸는 미니 신경망을 학습시킵니다.

⬅️ 이전 글: #08 정규분포·소프트맥스·교차 엔트로피 — AI는 확률로 대답한다 (2부) \
➡️ 다음 글: #10 SVD 추천 시스템부터 미니 신경망(XOR)까지 — 수포자 수학 6개 장 총정리 (2부)


📚 더 깊이 공부하고 싶다면

이 시리즈는 수학을 놓았던 비전공자가 AI를 직접 구현하기까지의 공부 기록을 그대로 옮긴 연재입니다. 원문 책, 코드, 개념 사전을 함께 보면 훨씬 빨리 늡니다. 🚀

바로가기이런 분께
📘 책 (위키독스, 무료)Zero to 머신러닝 딥러닝 Master5권 · 20개 장 · 163개 절 전체를 순서대로 읽고 싶은 분
📄 이 글의 원문00-06 SVD·PCA — 300페이지 책을 3페이지로 (1부)연습문제·해설까지 전부 보고 싶은 분
💻 실습 코드 (GitHub)otoloism/Zero_to_ML_Master.py·.ipynb를 Colab에서 바로 돌려보고 싶은 분
🏠 머신러닝 백과사전mldict.net본문 속 핵심 개념을 카드로 다시 확인하고 싶은 분
✍️ 기술 블로그wikidocs.net/blog/@mldict새 글 알림을 가장 먼저 받고 싶은 분

⭐ 도움이 되셨다면 velog 팔로우와 GitHub Star를 눌러 주세요. 다음 글을 쓰는 가장 큰 힘이 됩니다. 궁금한 점은 댓글로 편하게 남겨 주세요! 🙌

profile
자유주의자

0개의 댓글