
300페이지짜리 소설을 3페이지로 요약해야 한다면, 무엇을 남기고 무엇을 버릴까요? 📚 가장 중요한 줄거리만 남기고 나머지는 과감히 버리겠죠.
행렬에도 똑같은 일을 할 수 있습니다. 어떤 행렬이든 세 조각으로 쪼개고(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부)
✏️ 꼭 알아야 할 TIP
🎯 핵심 개념: 어떤 행렬이든 로 분해할 수 있고(SVD), 작은 특잇값을 버리면 정보를 거의 잃지 않고 데이터를 압축할 수 있습니다.
📖 한 줄 비유: SVD는 "300페이지 소설을 핵심 줄거리만 남겨 3페이지로 요약하는 것"입니다.
🔑 이 장을 관통하는 한 줄 공식:
코드에서는
U[:, :k] @ np.diag(S[:k]) @ Vt[:k, :]딱 한 줄입니다. 이 장 전체가 이 한 줄의 의미를 파헤치는 여정입니다.⭐ 가장 중요한 포인트: 고유벡터는 "행렬을 곱해도 방향이 바뀌지 않는 화살표"이며, 이 방향들이 곧 데이터의 가장 본질적인 패턴입니다. SVD·PCA는 이 패턴을 찾아 중요한 순서대로 줄 세우는 기술입니다.
🏗️ 실무에서 쓰는 이유: 넷플릭스 추천, 구글 이미지 검색, 노이즈 제거, 데이터 시각화, 그리고 요즘 LLM의 LoRA 파인튜닝까지 — "거대한 데이터를 핵심 몇 개로 요약하는" 모든 작업의 기반입니다. 🚀
📋 선행지식 — 이 장은 앞의 다섯 장을 전부 사용합니다
| 이전 장 | 배운 것 | 이 장의 어디서 쓰이나 |
|---|---|---|
| 00-01 벡터 | 내적·노름·정규화 | 고유벡터의 길이 1 정규화, 압축 오차를 재는 프로베니우스 노름 |
| 00-02 행렬 | 행렬 곱·전치·역행렬 | 1~5단계 전부 — , , |
| 00-03 최소제곱 | , 정규방정식 | 3단계 공분산 행렬이 사실상 같은 형태 |
| 00-04 미분 | 연쇄법칙·경사하강법 | 5단계 역전파와 가중치 갱신 |
| 00-05 확률 | 분산·소프트맥스· | 3단계 분산 최대화, 5단계 손실과 기울기 |
💬 ※ 잊어버린 부분이 있어도 괜찮습니다. 필요할 때마다 "🔗 0-0X와 연결" 표시로 다시 짚어드립니다.
🎯 학습 목표
☐ 🏹 의 의미를 설명하고 고유값을 손으로 직접 계산할 수 있다 🟢 초급 ⭐
☐ 📐 특성방정식 으로 고유값을 유도할 수 있다 🔵 중급
☐ ✂️ SVD 의 세 행렬이 각각 무엇인지 설명할 수 있다 🔵 중급 ⭐
☐ 📊 에너지 비율로 "를 몇으로 잡아야 하는지" 정량적으로 판단할 수 있다 🔵 중급
☐ 🎯 PCA가 "분산이 최대인 방향 찾기"임을 설명하고 직접 구현할 수 있다 🔵 중급 ⭐
☐ 🔬 PCA의 고유값 = SVD 특잇값²/(n−1) 관계를 코드로 검증할 수 있다 🔴 심화
☐ 🎬 SVD로 추천 시스템의 빈 평점을 예측하고, 0으로 채우는 방식의 한계를 설명할 수 있다 🔴 심화 🚀
☐ 🤖 00-01~00-05를 모두 사용해 미니 신경망을 처음부터 구현하고 XOR을 학습시킬 수 있다 🔴 심화 ⭐🚀
☐ 🔍 직접 유도한 역전파 공식을 수치미분으로 검증할 수 있다 🔴 심화
📊 핵심 학습 내용 — 수식과 코드의 대응
| 단계 | 학습 내용 | 핵심 수학공식 | 대응하는 코드 |
|---|---|---|---|
| 1️⃣ | 고유벡터·고유값 | np.linalg.eig(A) | |
| 2️⃣ | SVD·저차원 근사 | U[:,:k]@diag(S[:k])@Vt[:k,:] | |
| 3️⃣ | PCA·차원 축소 | , | np.linalg.eigh(np.cov(Xc.T)) |
| 4️⃣ | 추천 시스템 | P[u] @ Q[i] | |
| 5️⃣ | 종합 — 미니 신경망 | , | W -= lr * np.outer(h, gz) |
🗺️ 시각화 ① — 이번 장의 흐름과 앞 장들의 합류

🔎 ① 핵심 정의 — 무엇인가?\
00-02에서 배웠듯 행렬 를 벡터 에 곱하면, 보통 방향도 길이도 모두 달라진 새 벡터가 나옵니다. 그런데 어떤 특별한 벡터는 행렬을 곱해도 방향은 그대로이고 길이만 배수로 변합니다. 이런 벡터를 고유벡터(eigenvector), 그 배수를 고유값(eigenvalue)이라 합니다.
❓ 왜 필요한가?\
고유벡터는 "그 행렬이 가진 가장 본질적인 방향"입니다. 복잡해 보이는 행렬 변환도, 고유벡터 방향에서 보면 단순히 늘이고 줄이는 것뿐입니다. 즉 복잡한 것을 단순한 몇 개로 분해하는 첫걸음입니다.
⏰ 언제 사용하는가?\
PCA의 주성분 방향 찾기(3단계), 구글 페이지랭크, 진동 해석, 그리고 SVD의 이론적 토대가 됩니다.
💜 더 쉽게 이해하기 — 🎡 회전목마 위의 방향표
놀이공원 회전목마(행렬 )가 돌아가면 대부분의 물건은 방향이 계속 바뀝니다. 🎠 그런데 회전축에 꽂아둔 깃발만은 아무리 돌아도 같은 방향을 가리키죠 — 다만 바람에 따라 길이만 늘었다 줄었다 할 뿐입니다.
🚩 그 깃발이 고유벡터, 길이 배율이 고유값입니다. 회전목마의 "성격"을 알고 싶다면? 축(고유벡터)이 어디인지만 알면 됩니다. 나머지 복잡한 움직임은 전부 그 축을 기준으로 설명할 수 있으니까요. 🎯
🔢 진행 과정 — 고유값을 손으로 구하는 4단계
🔬 수학공식 ① — 고유값 방정식과 특성방정식
정의
변수 정의\
• : 정사각 행렬 (⚠️ 직사각형은 안 됩니다 — 2단계에서 해결!)\
• : 고유벡터 — 방향이 유지되는 벡터, 영벡터는 제외\
• (람다) : 고유값 — 길이가 변한 배율\
\
특성방정식 유도\
① 오른쪽을 왼쪽으로 옮깁니다. 단, 는 숫자이므로 단위행렬 를 곱해 행렬로 맞춰줍니다(00-02).② 만약 에 역행렬이 존재하면 양변에 곱해서 만 나옵니다 — 우리가 원하는 답이 아니죠.\
③ 따라서 역행렬이 없어야 하고, 00-02에서 배웠듯 그 조건은 행렬식이 0입니다.📌 00-02와 연결 🔗: "역행렬이 없다 = 행렬식이 0 = 특이행렬"이라는 사실이 여기서 고유값을 찾는 열쇠가 됩니다. 그때 배운 개념이 지금 쓰이는 것입니다!
📖 수학적 의미 — 로 끝까지 계산해보기
1단계 — 만들기
2단계 — 행렬식 계산 (2×2 행렬식 , 00-02)
3단계 — 특성방정식 풀기
4단계 — 의 고유벡터
첫 줄이 즉 이므로, 고유벡터는 (또는 그 상수배)
✅ 검산해봅시다
❌ 반례 — 고유벡터가 아닌 경우: 을 넣으면 입니다. 는 의 배수가 아니므로(두 번째 성분이 0이 아님) 고유벡터가 아닙니다.
🔗 00-01과 연결 — 정규화된 고유벡터
도 도 도 모두 같은 고유벡터입니다 — 방향만 같으면 되니까요. 그래서 라이브러리는 00-01에서 배운 정규화를 적용해 길이 1로 통일해 돌려줍니다.
아래 코드 실행 결과에 나오는
0.70710678이 바로 입니다! 🔗
💡 직관적 의미 — 고유값의 크기가 말해주는 것\
인 방향은 5배로 늘어나고, 인 방향은 2배로 늘어납니다. 즉 고유값이 클수록 그 방향이 이 행렬에서 "더 지배적인 방향"입니다. 🎯 이 아이디어가 바로 3단계 PCA에서 "고유값이 큰 순서로 주성분을 고르는" 근거가 됩니다.
📐 시각화 ② — 고유벡터(방향 유지) vs 일반 벡터(방향 변화)

⚙️ 구현 원리\
손으로 특성방정식을 푸는 것은 3×3만 되어도 3차 방정식이라 매우 번거롭습니다. 다행히 np.linalg.eig()가 반복 알고리즘(QR 분해)으로 안전하게 계산해줍니다. 우리는 결과를 검증할 줄만 알면 됩니다.
🔗 프레임워크 관점
| 개념 | NumPy | SciPy | PyTorch |
|---|---|---|---|
| 일반 행렬 | np.linalg.eig(A) | scipy.linalg.eig | torch.linalg.eig |
| 대칭 행렬 ⭐ | np.linalg.eigh(A) | scipy.linalg.eigh | torch.linalg.eigh |
| 행렬식 | np.linalg.det(A) | scipy.linalg.det | torch.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
📘 코드 이해 — 수식과 코드 대응
| 수학공식 | 코드 | 설명 |
|---|---|---|
A @ v | 00-02 행렬×벡터 (*가 아님! ⚠️) | |
lam * v | 00-01 스칼라배 (모든 성분에 곱) | |
| (i번째) | eigenvectors[:, i] | ⚠️ 열을 뽑아야 함 — 가장 흔한 실수 |
np.eye(2) | 단위행렬 — 를 행렬로 만들기 위해 | |
np.linalg.det(A - lam*I) | 특성방정식을 그대로 코드로 | |
v/np.linalg.norm(v) | 00-01 정규화 🔗 |
🧩 코드 설명 — Python/NumPy 기초
•
eigenvectors[:, i]⚠️ — 콤마 앞:는 "모든 행", 뒤i는 "번째 열"입니다. NumPy는 고유벡터를 열 단위로 저장하므로eigenvectors[i](행)로 꺼내면 완전히 틀린 벡터가 나옵니다. 에러도 안 나서 발견하기 어렵습니다!\
\
•np.allclose(a, b)— 부동소수점은 미세한 오차가 있어==로 비교하면 거의 항상False입니다. "거의 같은가"를 판정하는 함수이며, 00-04·00-05에서 계속 써온 검산 습관의 연장입니다. 🔗\
\
•np.eye(n)— 단위행렬을 만듭니다. 이름이 "eye"인 이유는 (대문자 아이)와 발음이 같기 때문입니다. 😄\
\
•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가 라이브러리와 일치, ② 특성방정식 가 고유값에서만 0이 되고 에서는 , ③ 손계산 을 00-01의 정규화로 나누면 라이브러리 결과와 소수점 6자리까지 동일합니다. 🔗
🛠️ 실습 1 🟢🔵
① 의 고유값·고유벡터를 손으로 구하고 코드와 비교하세요. (정답: , 고유벡터 ) — 대각행렬은 대각선 값이 곧 고유값입니다!\
② (90° 회전)의 고유값을 구해보세요. ⚠️ 실수 고유값이 없습니다! 왜 그럴까요? (힌트: 90° 돌리면 방향이 유지되는 실수 벡터가 있을까요? 🎡)\
③ 대각합(trace) = 고유값의 합임을 확인하세요. ✅ 또 행렬식 = 고유값의 곱도 확인하세요. ✅
🐞 실수하기 쉬운 부분 & 디버깅 포인트
❌ 실수 ① 고유벡터를 행으로 착각 ⭐ 가장 흔함\
왜 틀리나?eigenvectors[0]으로 첫 고유벡터를 꺼내려 하지만, NumPy는 열에 저장합니다. 수정:eigenvectors[:, 0]. 확인법:np.allclose(A@v, lam*v)가False면 십중팔구 이것입니다.❌ 실수 ② 부호·크기가 다르다고 "틀렸다"고 오해\
왜 틀리나? 든 든, 길이가 1이든 5이든 모두 같은 고유벡터입니다. 라이브러리·버전마다 부호가 다르게 나올 수 있습니다. 수정: 벡터끼리 비교하지 말고 가 성립하는지로 판정하세요.❌ 실수 ③ 복소수 고유값에 당황\
회전 행렬처럼 실수 고유벡터가 없는 경우eig가 복소수를 돌려줍니다(예:0.+1.j). 버그가 아닙니다. 수정: 대칭 행렬임을 안다면eigh를 쓰세요 — 항상 실수를 보장합니다.❌ 실수 ④ 직사각 행렬에
eig사용\
LinAlgError: Last 2 dimensions must be square가 납니다. 고유값은 정사각 행렬에만 정의됩니다. 바로 이 한계 때문에 2단계의 SVD가 필요합니다! 🔗
🔎 ① 핵심 정의 — 무엇인가?\
1단계 끝에서 본 것처럼, 고유값 분해는 정사각 행렬에만 쓸 수 있습니다. 그런데 실제 데이터는 "사용자 1000명 × 영화 10000편"처럼 대부분 직사각형입니다. 😩\
SVD(특잇값 분해)는 어떤 행렬이든 — 정사각형이 아니어도, 심지어 랭크가 부족해도 — 세 행렬의 곱으로 분해할 수 있다는 강력한 정리입니다.
❓ 왜 필요한가?\
거대한 데이터 행렬에서 "중요한 패턴 몇 개"만 남기고 나머지를 버려 압축할 수 있기 때문입니다. 그리고 놀랍게도, 이 방식이 수학적으로 "가능한 모든 압축 중 최선"임이 증명되어 있습니다(에카르트–영 정리).
⏰ 언제 사용하는가?\
이미지 압축, 노이즈 제거, 추천 시스템(4단계), 잠재의미분석(LSA), 그리고 최근 LLM의 LoRA 파인튜닝도 "가중치 변화량을 저랭크로 근사"하는 SVD 아이디어입니다. 🚀
💜 더 쉽게 이해하기 — 📖 300페이지 소설 요약하기
두꺼운 소설 한 권에는 여러 "주제"가 뒤섞여 있습니다: 주인공의 성장, 로맨스, 음모, 그리고 사소한 배경 묘사들. 📚
SVD가 하는 일은 정확히 세 가지입니다.\
🎭 — "각 인물(행)이 어떤 주제에 얼마나 관여하나"\
📏 — "각 주제의 비중이 얼마나 큰가" (큰 순서로 정렬! ⭐)\
📑 — "각 장면(열)이 어떤 주제에 얼마나 속하나"✂️ 이제 비중이 큰 주제 2~3개만 남기고 나머지를 버리면? 3페이지 요약본이 완성됩니다. 줄거리는 거의 그대로인데 분량은 100분의 1이죠!
🔢 진행 과정 — 압축 4단계
🔬 수학공식 ② — SVD 분해와 세 행렬의 정체
변수 정의\
• : 원본 행렬, 크기 — 정사각형이 아니어도 됩니다! ✅\
• : 직교 행렬 — 각 열이 "행 방향 패턴"\
• : 대각 행렬 — 대각선에만 특잇값 \
• : 직교 행렬의 전치 — 각 행이 "열 방향 패턴"\
• : 특잇값 — 항상 0 이상이고 내림차순으로 정렬됨 ⭐\
\
"직교 행렬"이란? 🔗 00-01과 연결\
열벡터들이 서로 내적이 0(직각)이고 각각 길이가 1인 행렬입니다. 00-01에서 배운 내적과 정규화가 여기서 쓰입니다! 이런 행렬은 회전만 시키고 크기는 바꾸지 않습니다. 🔄\
\
💎 세 행렬의 역할을 한 문장으로📌 어떤 행렬이든 "회전 → 늘이기 → 회전" 세 동작으로 분해된다는 뜻입니다. 아무리 복잡해 보이는 변환도 결국 이 셋의 조합입니다! 🎯\
\
고유값 분해와의 관계 (1단계와 연결 🔗)즉 특잇값은 고유값의 제곱근입니다. 는 항상 정사각 대칭 행렬이므로(00-03의 와 같은 형태! 🔗) 고유값 분해가 가능하고, 그래서 직사각형 에도 SVD가 항상 존재합니다. ✨
🔬 수학공식 ③ — 저차원 근사와 에너지 비율 ⭐
랭크- 근사
오른쪽 형태가 핵심 통찰입니다: 행렬 는 "만큼의 비중을 가진 단순한 층들의 합"입니다. 마치 사진을 여러 장의 투명 필름으로 겹쳐 만드는 것과 같죠. 🎞️\
\
얼마나 정확한가? (에카르트–영 정리)\
버린 부분의 오차는 정확히 버린 특잇값들로 결정됩니다. 🔗 00-01의 노름을 행렬로 확장한 프로베니우스 노름을 씁니다.그리고 이보다 더 잘 근사하는 랭크- 행렬은 존재하지 않습니다. 즉 SVD 압축은 수학적으로 최적입니다! 🏆\
\
에너지 비율 — "를 몇으로 할까?"에 대한 답📌 실무 기준: 이 값이 0.90~0.99가 되는 최소 를 고릅니다. "정보의 95%를 유지하면서 크기는 10분의 1로" 같은 판단을 숫자로 할 수 있게 됩니다. 📊
📖 수학적 의미 — 저장 공간이 얼마나 줄어드나?\
행렬을 랭크-로 압축하면, 저장해야 할 숫자의 개수가 이렇게 바뀝니다.
| 구분 | 저장할 숫자 개수 | 1000×1000, k=50 예시 |
|---|---|---|
| 원본 | 1,000,000개 | |
| 압축 | 100,050개 | |
| 절감률 | — | 약 90% 절감 🎉 |
💡 직관적 의미: 특잇값은 보통 처음 몇 개가 압도적으로 크고 뒤로 갈수록 급격히 작아집니다. 📉 작은 특잇값은 "거의 기여하지 않는 세부사항"이므로, 버려도 원본과 거의 같은 행렬을 훨씬 적은 숫자로 표현할 수 있습니다.
📐 시각화 ③ — SVD 분해와 잘라내기(truncation)

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

⚙️ 구현 원리\
np.linalg.svd(A)가 , , 를 한 번에 돌려줍니다. ⚠️ 여기서 는 대각행렬이 아니라 대각선 값만 담은 1차원 배열입니다 — 메모리를 아끼기 위해서죠. 곱셈에 쓰려면 np.diag(S)로 되살려야 합니다. 압축은 슬라이싱만으로 끝납니다.
🔗 프레임워크 관점
| 개념 | NumPy | SciPy | PyTorch | scikit-learn |
|---|---|---|---|---|
| 전체 SVD | np.linalg.svd | scipy.linalg.svd | torch.linalg.svd | — |
| 부분 SVD ⭐ | 슬라이싱 | svds(A, k=50) | svd_lowrank | TruncatedSVD(50) |
| 랭크 확인 | matrix_rank | — | matrix_rank | — |
📌 실무 주의: 100만×10만 같은 거대한 행렬에 np.linalg.svd를 쓰면 메모리가 터집니다. 💥 이럴 땐 상위 개만 근사로 구하는 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
📘 코드 이해 — 수식이 코드의 어느 줄인가
| 수학공식 | 코드 | 설명 |
|---|---|---|
U, S, Vt = np.linalg.svd(A) | 한 줄로 세 행렬 반환 | |
np.diag(S[:k]) | ⚠️ 1차원 → 대각행렬 복원 필수 | |
U[:, :k] | 열을 자름 (앞쪽 k개 패턴) | |
Vt[:k, :] | 행을 자름 (전치되어 있으므로!) | |
np.cumsum(S**2)/np.sum(S**2) | 에너지 비율을 한 번에 전부 | |
np.linalg.norm(A - A_k) | 00-01 노름의 행렬 버전 🔗 | |
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))— 제너레이터 표현식으로 여러 행렬을 더합니다. 수식의 를 코드로 그대로 옮긴 형태입니다.\
\
•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개뿐 — . 뒤의 두 개가 0이라는 것은 이 4×4 행렬이 사실 패턴 2개로 완전히 만들어진다는 뜻입니다. 실제로 각 행이 "앞 행 + 1"이라는 규칙이 있었죠. 이를 랭크 2라고 합니다.
② 에서 오차가 정확히 0 — 압축했는데 정보 손실이 전혀 없습니다! 16개 숫자를 저장하던 것을 개... 어라, 오히려 늘었네요? 😅 작은 행렬에서는 압축 효과가 없습니다. 큰 행렬일수록 이득이 커집니다(앞의 1000×1000 표 참고).
③ 에카르트–영 정리가 실제로 성립 ⭐ — 에서 실제 오차 1.165151과 이론값 1.165151이 소수점 6자리까지 완벽히 일치합니다. 수학이 예측한 대로 정확히 나온 것입니다! 🎯
④ 인데도 에너지가 99.54% — 첫 번째 패턴 하나만으로 정보의 99.5%를 담습니다. 근사 행렬을 보면 [1.7, 2.31, ...]로 원본 [1, 2, ...]와 대략적인 모양은 비슷하죠. 이것이 압축의 원리입니다. ✂️
🛠️ 실습 2 🔵🔴
① 무작위 행렬
np.random.default_rng(0).standard_normal((20,20))의 특잇값을 출력해보세요. 규칙 없는 데이터는 특잇값이 고르게 퍼져 압축이 잘 안 된다는 것을 확인하세요.\
② 에너지 90%를 넘기는 최소 를 자동으로 찾는 함수를 작성하세요. (힌트:np.searchsorted(energy, 0.9) + 1)\
③ 🚀 이미지 압축 체험: 흑백 이미지를 행렬로 읽어 으로 압축하고matplotlib으로 나란히 그려보세요. 가 커질수록 선명해지는 과정이 눈에 보입니다! 🖼️\
④ 원본 에 노이즈를 조금 섞은 뒤 로 압축해보세요. 노이즈가 제거되는 것을 확인할 수 있습니다. (노이즈는 작은 특잇값에 실리기 때문)
🐞 실수하기 쉬운 부분 & 디버깅 포인트
❌ 실수 ①
np.diag()누락 ⭐ 가장 흔함\
왜 틀리나?U @ S @ Vt로 쓰면S가 1차원이라 행렬 곱이 아닌 이상한 연산이 되거나 shape 오류가 납니다. 수정:U @ np.diag(S) @ Vt. 확인법:print(S.shape)가(4,)면 1차원입니다.❌ 실수 ② 와 의 자르는 방향 혼동\
는[:, :k](열), 는[:k, :](행)입니다. 왜? 는 이미 전치된 상태라 패턴이 행에 있습니다. 확인법:print(U[:,:k].shape, Vt[:k,:].shape)로 와 인지 보세요.❌ 실수 ③ 직사각 행렬에서 shape 불일치\
이면full_matrices=True(기본값)일 때 가 , 가 이라 중간 크기가 안 맞습니다. 수정: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)로 분해가 맞는지 ② 실제 오차와 이론 오차 가 일치하는지 — 이 두 가지면 충분합니다. 🛡️
🔎 ① 핵심 정의 — 무엇인가?\
PCA(주성분 분석)는 1~2단계의 고유벡터·SVD를 활용해 데이터가 가장 넓게 퍼져 있는 방향(주성분)을 찾고, 그 방향을 새 좌표축으로 삼아 차원을 줄이는 방법입니다.
❓ 왜 필요한가?\
여러 변수가 서로 강하게 연관되어 있을 때(예: 키와 몸무게), 실제로는 훨씬 적은 수의 "핵심 축"만으로 데이터를 거의 그대로 설명할 수 있습니다. 변수가 100개여도 실질적인 자유도는 5개뿐일 수 있죠. 이를 찾아내면 계산량 감소·시각화·노이즈 제거가 동시에 해결됩니다.
⏰ 언제 사용하는가?\
고차원 데이터 시각화(1000차원 → 2차원 그래프), 머신러닝 전처리, 이미지 얼굴 인식(고유얼굴, eigenface), 유전체 데이터 분석 등.
💜 더 쉽게 이해하기 — 📷 사진 찍는 각도 고르기
긴 자동차 모형을 사진 한 장(2차원)으로 남겨야 합니다. 🚗📷
😀 옆에서 찍으면 — 길이와 높이가 모두 잘 보입니다. 정보가 많이 살아남죠.\
😵 정면에서 찍으면 — 차가 얼마나 긴지 전혀 알 수 없습니다. 정보가 뭉개집니다.PCA는 "가장 잘 보이는 각도"를 수학적으로 찾아주는 것입니다. 그리고 "잘 보인다"의 수학적 정의는 놀랍도록 간단합니다 — "그 방향으로 봤을 때 데이터가 가장 넓게 퍼져 있다(분산이 크다)". 📏
🔢 진행 과정 — PCA 5단계
🔬 수학공식 ④ — 공분산 행렬과 "왜 고유벡터인가"의 유도 ⭐
① 중심화 — 🔗 00-04의 정규화, 00-05의 z-점수와 같은 발상
② 공분산 행렬 — 🔗 00-03의 와 똑같은 형태!
의 성분은 "번째 변수와 번째 변수가 얼마나 함께 변하는가"이고, 대각선은 각 변수의 분산(00-05 🔗)입니다.\
\
③ 핵심 질문: 어떤 방향 로 봐야 가장 넓게 퍼질까?\
길이 1인 방향 에 데이터를 투영하면(00-01의 내적 🔗) 그 값들의 분산은:④ 최적화 문제로 바꾸면
⑤ 답이 곧 고유벡터 — 이 문제의 해는 의 가장 큰 고유값에 대응하는 고유벡터이고, 그때 분산의 최댓값은 그 고유값 자체입니다.
📌 1단계에서 배운 가 여기서 "분산 최대화 문제의 답"으로 재등장합니다! 🔗 고유값이 클수록 그 방향의 정보량이 많다는 1단계의 직관이 정확히 증명된 셈입니다. ✨\
\
⑥ 설명 분산 비율 — 2단계의 에너지 비율과 같은 개념
🔬 수학공식 ⑤ — PCA와 SVD는 같은 것이다 🔴 심화
중심화된 데이터 에 직접 SVD를 적용하면:
이를 공분산 식에 넣어봅시다.
는 직교행렬이라 이므로:
이것은 정확히 고유값 분해의 형태입니다. 따라서:
📌 즉 PCA는 "중심화한 데이터에 SVD를 돌린 것"과 완전히 같습니다. 실무 라이브러리(scikit-learn 포함)는 공분산 행렬을 만들지 않고 바로 SVD를 씁니다 — 더 빠르고 수치적으로 안정적이기 때문입니다. 아래 코드에서 두 방법이 같은 값을 내는지 직접 검증합니다! 🔬
💡 직관적 의미 — 왜 "분산이 크다 = 정보가 많다"인가?\
어떤 방향으로 투영했더니 모든 점이 한곳에 뭉쳐 있다면(분산 0), 그 방향으로는 데이터를 전혀 구별할 수 없습니다. 😵 반대로 넓게 퍼져 있다면 점들을 잘 구별할 수 있죠. 그래서 "분산 = 구별 능력 = 정보량"입니다. 🎯
📐 시각화 ⑤ — 주성분 방향과 투영

⚙️ 구현 원리 — 두 가지 경로\
① 공분산 경로: np.cov() → np.linalg.eigh() — 개념을 이해하기 좋음\
② SVD 경로: 중심화 후 np.linalg.svd() — 실무에서 쓰는 방법, 더 안정적\
아래 코드에서 두 경로가 같은 답을 내는지 검증합니다. 이것이 수식 ⑤를 코드로 확인하는 과정입니다. 🔬
🔗 프레임워크 관점
| 개념 | NumPy 직접 구현 | scikit-learn | PyTorch |
|---|---|---|---|
| 중심화 | 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
📘 코드 이해 — 수식이 코드의 어느 줄인가
| 수학공식 | 코드 | 설명 |
|---|---|---|
X - X.mean(axis=0) | ⚠️ axis=0 = 열별 평균 (변수별) | |
np.cov(Xc.T) | ⚠️ .T 필수 — 00-03의 🔗 | |
np.linalg.eigh(C) | 1단계 고유값 방정식 🔗 | |
ev / ev.sum() | 설명 분산 비율 (2단계 에너지와 동일) | |
S**2/(n-1) | ⭐ 수식 ⑤ — PCA=SVD 증명 | |
Xc @ V_k | 투영 = 00-01의 내적을 한꺼번에 🔗 | |
Z @ V_k.T + X.mean(0) | ⚠️ 평균을 다시 더해야 원래 위치로 |
🧩 코드 설명 — NumPy 기초
•
np.cov(Xc.T)⚠️ — NumPy의cov는 "각 행이 하나의 변수"라고 가정합니다. 우리 데이터는 각 열이 변수(키, 몸무게)이므로.T로 뒤집어야 합니다. 안 하면 100×100 행렬이 나와 바로 알아챌 수 있습니다.\
\
•np.linalg.eighvseig— 공분산 행렬은 항상 대칭이므로eigh가 정답입니다. 더 빠르고, 복소수가 절대 안 나오며, 고유벡터가 서로 직교함을 보장합니다.\
\
•np.argsort(ev)[::-1]—argsort는 오름차순 인덱스를 주므로,[::-1]로 뒤집어 내림차순(큰 고유값 먼저)으로 만듭니다.eigh가 오름차순으로 주기 때문에 필수입니다.\
\
•eigenvectors[:, order]— 고유벡터는 열에 저장되므로, 열 순서를 바꿔야 합니다(1단계의 그 규칙 🔗).\
\
•full_matrices=False— 가 100×2인 직사각형이라, 기본값이면 가 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 방향 — 두 성분의 부호가 같으므로 "키가 크면 몸무게도 크다"는 방향입니다. 부호가 둘 다 음수인 것은 방향만 반대일 뿐 같은 축입니다(1단계에서 배운 그 성질 🔗).
③ 수식 ⑤가 코드로 증명됨 ⭐ — 공분산 경로의 고유값 와 SVD 경로의 이 소수점 4자리까지 완전히 일치합니다. PCA와 SVD가 같은 것임을 눈으로 확인했습니다! 🎯
④ 복원 오차까지 이론과 일치 — 실제 오차 22.1915와 버린 고유값으로 예측한 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을 독점합니다. 수정: 표준화하거나, 단위가 같은 변수끼리만 쓰세요.
📂 이 장의 코드 폴더: 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
📄 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
📄 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
👉 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 해서 내 저장소에서 마음껏 바꿔 보세요. 오류 제보·개선 제안은 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 머신러닝 딥러닝 Master | 5권 · 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를 눌러 주세요. 다음 글을 쓰는 가장 큰 힘이 됩니다. 궁금한 점은 댓글로 편하게 남겨 주세요! 🙌