17 손으로 푸는 선형대수 · 제5부 머신러닝에서 다시 만나기
PCA는 결국 SVD다
주성분분석을 배울 때는 "공분산행렬의 고유벡터"라고 배우고, 정작 sklearn을 열어 보면 공분산행렬이 없다. 대신 svd 한 줄이 있다. 두 계산은 수학적으로 같고 수치적으로 다르다. 이 장에서 같음을 손으로 확인하고, 다름이 어디서 생기는지 숫자로 본다.
- 직관 — 가장 넓게 퍼진 방향 하나 고르기
- 수식 읽는 법 — 분산 최대화 = 재구성 오차 최소화
- 손으로 풀기 — A를 중심화해 AcTAc까지
- 코드 — 공분산 경로와 SVD 경로, 그리고 갈라지는 지점
- 시각화 — 축이 돌아가는 그림과 κ가 제곱되는 자리
약어 및 기호 정의
- X 자료행렬 data matrix
- (n, d) 모양. 행이 표본, 열이 특징(1장). 이 장에서는 X = A, n=4, d=3.
- μ 열평균 column mean
- 각 열의 평균을 모은 d차원 벡터. 이 장에서 μ = (0.5, 1.5, 1).
- Xc 중심화 자료 centered data
- X − 1μT. 모든 열의 평균이 0이 되게 민 것. PCA의 정의의 일부다.
- Σ 표본공분산 sample covariance
- XcTXc / (n−1). 대칭 양반정치(15장). 「통계학 기본」 3장의 그 공분산과 같은 것이다.
- 주성분 principal component
- Σ의 고유벡터. 데이터가 퍼진 방향을 큰 것부터 나열한 직교 기저.
- 레일리 몫 Rayleigh quotient
- R(w) = wTΣwwTw. 최댓값이 λmax, 그때의 w가 최대 고유벡터다.
- 설명분산 비율 explained variance ratio
- λk / Σλj. k번째 축이 전체 퍼짐의 몇 %를 담는가.
- 스코어 score
- XcW. 원 데이터를 주성분 축에서 다시 읽은 좌표. sklearn의 transform 결과다.
- U Σ VT SVD
- 16장의 특이값분해. 이 장에서는 Xc에 적용하고, 혼동을 피하려고 특이값 행렬은 S가 아닌 diag(σ)로 쓴다.
- 조건수 κ₂ condition number
- 8장. σmax/σmin. 이 장의 후반부가 전부 κ₂(Σ) = κ₂(Xc)²이라는 한 줄에서 나온다.
- 부호 규약 sign convention
- 고유벡터는 ±가 자유롭다. 라이브러리마다 고르는 방식이 달라 부호가 뒤집혀 보인다. 틀린 것이 아니다.
직관 — 가장 넓게 퍼진 방향 하나를 고른다
특징이 세 개인 데이터가 있는데 그림은 두 축밖에 못 그린다고 하자. 어느 두 방향을 고를 것인가. 가장 흔한 대답은 "원래 축 중 둘"이지만, 그건 데이터가 아니라 측정 장비가 정한 축이다. 데이터가 실제로 퍼진 방향이 원래 축과 나란하리라는 보장은 전혀 없다.
PCA가 하는 일은 한 문장으로 끝난다. 데이터가 가장 넓게 퍼진 방향을 찾고, 거기에 직각이면서 그다음으로 넓게 퍼진 방향을 찾고, 그렇게 d개를 다 찾는다. 그러고 나서 뒤쪽 몇 개를 버린다.

여기서 중요한 것은 평균을 먼저 뺀다는 점이다. 도해 1에서 두 축이 모두 평균 μ를 지난다. 평균을 빼지 않으면 "가장 넓게 퍼진 방향"이 아니라 "원점에서 가장 멀리 뻗은 방향"을 찾게 되고, 그 방향은 거의 언제나 평균 방향이다. 데이터가 어떻게 퍼져 있는지와는 아무 상관이 없는 답이다. STEP 03 (다)에서 우리 A로 이것을 숫자로 확인한다.
그리고 이 장의 제목. Σ의 고유벡터를 구하는 일과 Xc의 SVD를 구하는 일은 같은 계산이다. 15장에서 Σ가 ATA 꼴이라는 것을 봤고, 16장에서 ATA의 고유벡터가 곧 A의 우특이벡터라는 것을 봤다. 두 사실을 이어 붙이면 끝이다. 남는 질문은 하나다 — 같은 답이면 어느 쪽으로 계산해야 하나.
수식 읽는 법 — 두 정의가 같은 답에 도달한다
정의 ① 분산을 최대로 하는 방향
단위벡터 w 하나를 고르고, 중심화한 데이터를 그 방향으로 투영한다. 표본 i의 투영 좌표는 xc,i·w이고, 이것들의 제곱합이 곧 그 방향의 퍼짐이다.
그러니 PCA의 첫 축은 이 최적화 문제의 답이다.
제약을 없애는 표준적인 방법이 레일리 몫Rayleigh quotient이다. 길이로 나눠 버리면 w의 크기는 무관해진다.
왜 이것의 최댓값이 λmax인가. 14장의 스펙트럼 분해를 쓰면 한 줄이다. Σ는 대칭이므로 직교 고유기저 w₁,…,wd가 있고, 아무 w나 그 기저로 w = Σ cjwj로 쓸 수 있다.
두 번째 축은 w₁에 직교한다는 조건을 더해 같은 논증을 반복하면 λ₂의 고유벡터가 나온다. 이 과정이 끝까지 가면 주성분 전체가 Σ의 고유기저다.
정의 ② 재구성 오차를 최소로 하는 부분공간
완전히 다른 데서 출발해 보자. 데이터를 k차원 부분공간으로 눌러 담았다가 다시 펼칠 때, 원본과 가장 덜 어긋나는 부분공간은 어디인가. 직교기저 Wk(열이 k개)로 만든 투영행렬은 10장에서 본 대로 P = WkWkT다.
두 정의를 잇는 것은 피타고라스 정리 하나다. 투영과 잔차는 직교하므로(10장), 표본마다
전체 표본에 대해 더하면 ‖Xc‖F²가 고정된 채로 투영 에너지와 잔차 에너지가 서로 상보가 된다. 그러니 잔차를 최소화하는 것과 투영을 최대화하는 것은 정확히 같은 문제다.
그러니 두 정의는 같은 답을 낸다. 18장에서 이 사실이 에카르트–영 정리Eckart–Young theorem라는 이름으로 일반화된다. 미리 한 줄만 말하면, 랭크 k로 자를 때 최적 근사는 특이값 큰 쪽 k개만 남기는 것이고 버린 오차는 정확히 남은 특이값이다.
그래서 Σ의 고유분해와 Xc의 SVD가 만난다
이제 둘을 나란히 놓는다. Xc = U·diag(σ)·VT로 SVD하면(16장),
식 (17.6)의 오른쪽은 "직교행렬 × 대각 × 전치"다. 14장에서 본 대칭행렬의 스펙트럼 분해와 모양이 정확히 같다. 대칭행렬의 스펙트럼 분해는 (고유값이 서로 다르면) 유일하므로, 양변을 비교해 두 줄을 읽어 낸다.
여기서 U는 어디로 갔는가. 사라지지 않았다. 스코어가 바로 U다.

손으로 풀기 — A를 데이터로 보고 PCA를 끝까지
1장에서 A를 "관측 4건, 특징 3개"로 읽었다. 그 읽기를 이제 실제로 쓴다.
(가) 열평균과 중심화
열마다 평균을 낸다
1열: (1 + 0 + 1 + 0)/4 = 24 = 0.5
2열: (1 + 2 + 0 + 3)/4 = 64 = 1.5
3열: (0 + 0 + 3 + 1)/4 = 44 = 1
그러니 μ = (0.5, 1.5, 1). 각 행에서 이 벡터를 빼면 Ac다.
⎡ 1 1 0 ⎤ ⎡ 0.5 −0.5 −1 ⎤
A = ⎢ 0 2 0 ⎥ → A_c = ⎢ −0.5 0.5 −1 ⎥
⎢ 1 0 3 ⎥ ⎢ 0.5 −1.5 2 ⎥
⎣ 0 3 1 ⎦ ⎣ −0.5 1.5 0 ⎦
(17.8) 열마다 더해 보면 0, 0, 0. 중심화가 제대로 됐다는 확인은 항상 이 한 줄이다.여기서 미리 짚어 둘 것 하나. Ac의 네 행은 모두 더하면 0이 된다. 즉 행들 사이에 선형관계가 하나 생겼다. 4개의 행이 있지만 자유도는 3이다. 그래서 일반적으로 rank(Xc) ≤ min(n−1, d)다. 우리 경우 min(3, 3) = 3이고, A의 랭크가 이미 3이었으므로 중심화 후에도 랭크는 3으로 유지된다. (표본이 특징 수보다 적었다면 여기서 랭크가 떨어졌을 것이다 — 그것이 n < d일 때 PCA가 최대 n−1개 성분만 내놓는 이유다.)
(나) AcTAc와 그 고유값
Ac에 소수점이 들어갔으니 AcTAc도 지저분할 것 같지만, 해 보면 정수로 떨어진다. 1장에서 G를 채운 것과 똑같이 열끼리 내적하면 된다. 열을 d1, d2, d3라 쓰자.
아홉 칸 채우기
d₁ = (0.5, −0.5, 0.5, −0.5), d₂ = (−0.5, 0.5, −1.5, 1.5), d₃ = (−1, −1, 2, 0)
d₁·d₁ = 4 × 0.25 = 1
d₂·d₂ = 0.25 + 0.25 + 2.25 + 2.25 = 5
d₃·d₃ = 1 + 1 + 4 + 0 = 6
d₁·d₂ = −0.25 − 0.25 − 0.75 − 0.75 = −2
d₁·d₃ = −0.5 + 0.5 + 1 + 0 = 1
d₂·d₃ = 0.5 − 0.5 − 3 + 0 = −3
대각이 1, 5, 6 — 합이 12다. 이 숫자를 기억해 두자.
⎡ 1 −2 1 ⎤ ⎡ 1/3 −2/3 1/3 ⎤
S = A_cᵀA_c = ⎢ −2 5 −3 ⎥ Σ = S/3 = ⎢−2/3 5/3 −1 ⎥
⎣ 1 −3 6 ⎦ ⎣ 1/3 −1 2 ⎦
(17.9) n−1 = 3으로 나눈 것이 표본공분산. 대각 1/3, 5/3, 2가 각 특징의 분산이고, 합은 4다.중요한 함정 하나. S = AcTAc는 G = ATA = [[2,1,3],[1,14,3],[3,3,10]]과 완전히 다른 행렬이다. 그러니 고유값도 16, 9, 1이 아니다. 중심화는 행렬을 바꾸는 연산이다.
이제 S의 고유값을 구한다. 특성다항식부터.
det(S − λI) = 0
3×3 대칭행렬의 특성다항식은 세 계수가 각각 이름을 갖는다.
λ³ − (tr S)λ² + (주소행렬식 합)λ − det S = 0
tr S = 1 + 5 + 6 = 12
2×2 주소행렬식 세 개:
|[[1,−2],[−2,5]]| = 5 − 4 = 1
|[[1,1],[1,6]]| = 6 − 1 = 5
|[[5,−3],[−3,6]]| = 30 − 9 = 21
합 = 1 + 5 + 21 = 27
det S — 1행으로 전개:
1·(30 − 9) − (−2)·(−12 + 3) + 1·(6 − 5)
= 21 − 18 + 1 = 4
따라서 λ³ − 12λ² + 27λ − 4 = 0
계수가 전부 정수다. 여기까지는 종이 위에서 깨끗하게 왔다. 그런데 λ = 1, 2, 4를 넣어 보면 각각 12, −10, −24. 유리근이 없다. 유리근 정리에 따라 가능한 후보는 ±1, ±2, ±4뿐인데 전부 탈락이므로, 세 근은 모두 무리수다.
이 시리즈에서 정수가 처음 깨지는 자리다. A는 특이값이 4, 3, 1이라는 이례적으로 좋은 행렬이었지만, 그것은 중심화하지 않은 A의 성질이다. 열평균을 빼는 순간 다른 행렬이 되고, 좋은 성질은 따라오지 않는다. 지어내지 않고 수치로 간다.
λ³ − 12λ² + 27λ − 4 = 0 → λ = 9.072603, 2.768124, 0.159273
검산: 9.072603 + 2.768124 + 0.159273 = 12.000000 = tr S ✓
9.072603 × 2.768124 × 0.159273 = 4.000000 = det S ✓
두 검산이 맞으므로 근이 옳다. 이제 식 (17.7)로 세 가지를 동시에 읽는다.
| 성분 | S의 고유값 λ | Ac의 특이값 σ = √λ | 공분산 고유값 λ/3 |
|---|---|---|---|
| PC1 | 9.072603 | 3.012076 | 3.024201 |
| PC2 | 2.768124 | 1.663768 | 0.922708 |
| PC3 | 0.159273 | 0.399090 | 0.053091 |
| 합 | 12 | — | 4 |
세 번째 열의 합이 4이고, 이것은 Σ의 대각합 1/3 + 5/3 + 2 = 4와 같다. 전체 분산은 축을 어떻게 돌려도 보존된다 — 대각합이 고유값의 합이라는 사실(14장)이 PCA에서는 이 뜻이다.
고유벡터는 무리수라 종이에서 예쁘게 나오지 않는다. 수치로 적으면 이렇다.
w₁ = ( 0.250049, −0.650848, 0.716849) λ₁/3 = 3.024201
w₂ = (−0.329606, 0.638930, 0.695075) λ₂/3 = 0.922708
w₃ = (−0.910404, −0.410080, −0.054760) λ₃/3 = 0.053091
직교 확인: w₁·w₂ = 0, w₁·w₃ = 0, w₂·w₃ = 0 (14장 — 대칭이면 항상)
(다) 중심화하지 않으면 무슨 일이 일어나는가
이번엔 A를 그대로 SVD한다. 16장의 결과를 그대로 쓸 수 있다.
A 의 특이값 : σ = 4, 3, 1
첫 우특이벡터: v₁ = (1, 5, 3)/√35 = (0.169031, 0.845154, 0.507093)
이제 이 v₁을 평균 방향과 비교해 보자. μ = (0.5, 1.5, 1)이고 ‖μ‖² = 0.25 + 2.25 + 1 = 3.5이므로 μ/‖μ‖ = (1, 3, 2)/√14 = (0.267261, 0.801784, 0.534522).
v₁은 사실 평균을 가리키고 있다
cos θ = v₁ · μ‖μ‖
= 0.169031×0.267261 + 0.845154×0.801784 + 0.507093×0.534522
= 0.045172 + 0.677624 + 0.271063 = 0.993859
각도로는 6.35°. 거의 같은 방향이다.
반면 진짜 첫 주성분 w₁과 비교하면
|v₁ · w₁| = 0.144292 → 81.7°, 거의 직각이다.
즉 중심화를 빼먹으면 첫 성분이 "데이터가 어느 쪽으로 퍼져 있는가"가 아니라 "데이터 뭉치가 원점에서 어느 쪽에 있는가"를 답한다. 그리고 그 답은 진짜 주성분과 거의 직교한다. 설명분산이라 부르던 숫자도 같이 망가진다.
| PC1 | PC2 | PC3 | 무엇을 재고 있나 | |
|---|---|---|---|---|
| 중심화 X (A의 SVD) | 61.54% | 34.62% | 3.85% | 원점에서의 거리 — 대부분 평균 |
| 중심화 O (Ac의 SVD) | 75.61% | 23.07% | 1.33% | 평균 둘레의 퍼짐 — 진짜 분산 |
가장 깔끔한 증거는 에너지 분해다. 프로베니우스 노름의 제곱은 이렇게 정확히 두 조각으로 갈린다.
그러니까 중심화하지 않은 A의 에너지 26 중 14(53.8%)는 순전히 평균 때문이고, 분산이라 부를 수 있는 것은 12뿐이다. 중심화하지 않은 SVD의 첫 성분이 61.54%를 먹은 것은 그 14를 주워 담았기 때문이다. PCA에서 중심화는 전처리 옵션이 아니라 정의의 일부다. sklearn.decomposition.PCA가 center 인자를 아예 제공하지 않는 이유가 이것이다. 중심화 없이 SVD만 하고 싶으면 그것은 PCA가 아니라 TruncatedSVD이고, 실제로 그 이름으로 따로 있다(「자연어처리 입문」 6~7장의 LSA가 그 경우다 — 희소행렬을 중심화하면 밀집행렬이 돼 버려서 일부러 안 한다).
(라) 설명분산 비율
세 축이 나눠 가진 분산
전체 분산 = tr Σ = 4 (또는 tr S = 12로 계산해도 비율은 같다)
PC1: 9.07260312 = 0.756050
PC2: 2.76812412 = 0.230677
PC3: 0.15927312 = 0.013273
누적: 0.756050 → 0.986727 → 1.000000
읽는 법. 3차원을 2차원으로 줄이면 분산의 98.67%가 남고 1.33%를 버린다. 18장에서 이 "버린 양"이 σ₃² = 0.159273, 즉 ‖Ac − (Ac)₂‖F = σ₃ = 0.399090으로 정확히 나타난다.
코드 — 두 경로, 같은 답, 다른 정확도
① 공분산 고유분해 경로 vs 직접 SVD 경로
import numpy as np
np.set_printoptions(precision=6, suppress=True)
A = np.array([[1,1,0],[0,2,0],[1,0,3],[0,3,1]], float)
n = A.shape[0]
mu = A.mean(axis=0) # 열평균 (0.5, 1.5, 1)
Ac = A - mu # 중심화
print("열평균 mu :", mu)
print("Ac =\n", Ac)
# --- 경로 ① 공분산을 만들고 고유분해 ---
S = Ac.T @ Ac # 정수로 떨어진다
Sigma = S / (n - 1)
lam, W = np.linalg.eigh(Sigma) # 오름차순
lam, W = lam[::-1], W[:, ::-1] # 내림차순으로 뒤집기
# --- 경로 ② 중심화한 데이터를 바로 SVD ---
U, sv, Vt = np.linalg.svd(Ac, full_matrices=False)
print("\nS = AcT Ac =\n", S, " det =", round(np.linalg.det(S), 12))
print("특성다항식 계수 :", np.round(np.poly(S), 12))
print("\n고유분해 경로 lambda :", lam)
print("SVD 경로 sigma^2/(n-1):", sv**2 / (n - 1))
print("두 경로 고유값 일치 :", np.allclose(lam, sv**2 / (n - 1)))
sign = np.sign(np.sum(W * Vt.T, axis=0)) # 부호 규약을 맞춘다
print("\n고유벡터 W (열) =\n", W * sign)
print("우특이벡터 V (열) =\n", Vt.T)
print("두 경로 방향 일치 :", np.allclose(W * sign, Vt.T, atol=1e-12))
evr = lam / lam.sum()
print("\n설명분산 비율 :", np.round(evr, 6), " 누적 :", np.round(np.cumsum(evr), 6))
print("에너지 분해 : ||A||F^2 =", (A**2).sum(),
"= ||Ac||F^2", (Ac**2).sum(), "+ n*||mu||^2", n * mu @ mu)
Ac =
[[ 0.5 -0.5 -1. ]
[-0.5 0.5 -1. ]
[ 0.5 -1.5 2. ]
[-0.5 1.5 0. ]]
S = AcT Ac =
[[ 1. -2. 1.]
[-2. 5. -3.]
[ 1. -3. 6.]] det = 4.0
특성다항식 계수 : [ 1. -12. 27. -4.]
고유분해 경로 lambda : [3.024201 0.922708 0.053091]
SVD 경로 sigma^2/(n-1): [3.024201 0.922708 0.053091]
두 경로 고유값 일치 : True
고유벡터 W (열) =
[[ 0.250049 -0.329606 -0.910404]
[-0.650848 0.63893 -0.41008 ]
[ 0.716849 0.695075 -0.05476 ]]
우특이벡터 V (열) =
[[ 0.250049 -0.329606 -0.910404]
[-0.650848 0.63893 -0.41008 ]
[ 0.716849 0.695075 -0.05476 ]]
두 경로 방향 일치 : True
설명분산 비율 : [0.75605 0.230677 0.013273] 누적 : [0.75605 0.986727 1. ]
에너지 분해 : ||A||F^2 = 26.0 = ||Ac||F^2 12.0 + n*||mu||^2 14.0
손으로 구한 특성다항식 λ³ − 12λ² + 27λ − 4가 np.poly의 [1, −12, 27, −4]와 그대로 맞는다. 고유값·고유벡터·설명분산까지 두 경로가 소수점 열두 자리에서 일치한다. 식 (17.7)은 참이다.
② 그런데 조건수가 나쁘면 두 경로가 갈라진다
8장에서 κ₂(A) = 4, κ₂(ATA) = 16을 봤다. 일반적으로 κ₂(XTX) = κ₂(X)²다 — 그람 행렬을 만드는 순간 조건수가 제곱된다. 12장에서 이것이 정규방정식을 쓰지 말아야 할 이유였다. PCA에서도 정확히 같은 일이 벌어진다. Σ = XcTXc/(n−1)을 만드는 행위 자체가 조건수를 제곱하기 때문이다.
그러니 실험은 이렇게 설계한다. 특이값을 1부터 σmin까지 로그로 깔아 둔 인공 데이터를 만들고, 가장 작은 주성분의 방향을 두 경로로 각각 추정해 참값과의 각도를 잰다. 가장 작은 성분이 먼저 무너진다 — 그것이 조건수가 하는 일이다.
import numpy as np
rng = np.random.default_rng(20260924)
n, d = 200, 6
Q, _ = np.linalg.qr(rng.standard_normal((d, d))) # 참 주성분 방향
P, _ = np.linalg.qr(rng.standard_normal((n, d))) # 좌특이벡터
t = Q[:, -1] # 가장 작은 주성분의 참값
def angle(v): # 참 방향과의 각(도)
return np.degrees(np.arccos(min(1.0, abs(v @ t))))
print(f"{'sigma_min':>10} {'k(Xc)':>10} {'k(Sigma)':>10} "
f"{'공분산경로 오차':>14} {'SVD경로 오차':>12} {'sigma_min 복원':>16}")
for smin in [1e-3, 1e-5, 1e-7, 1e-8, 1e-9, 1e-10]:
s = np.logspace(0, np.log10(smin), d) # 특이값을 1 -> smin 으로
Xc = (P * s) @ Q.T # 이미 중심화된 데이터
Xc = Xc - Xc.mean(axis=0)
Sigma = Xc.T @ Xc / (n - 1) # ① 공분산을 만든다 <- kappa 가 제곱되는 지점
lam, W = np.linalg.eigh(Sigma)
v_cov = W[:, 0] # 가장 작은 고유값의 고유벡터
sig_cov = np.sqrt(max(lam[0], 0.0) * (n - 1)) # 거기서 되돌린 sigma_min
U, sv, Vt = np.linalg.svd(Xc, full_matrices=False) # ② 바로 SVD
v_svd = Vt[-1]
print(f"{smin:10.0e} {sv[0]/sv[-1]:10.2e} {lam[-1]/max(lam[0],1e-300):10.2e} "
f"{angle(v_cov):13.5f}° {angle(v_svd):11.5f}° "
f"{sig_cov:9.3e} / {sv[-1]:9.3e}")
1e-03 9.99e+02 9.99e+05 0.01967° 0.01967° 9.997e-04 / 9.997e-04
1e-05 9.99e+04 9.99e+09 0.00726° 0.00726° 9.997e-06 / 9.997e-06
1e-07 9.99e+06 9.99e+13 0.00269° 0.00285° 9.996e-08 / 9.997e-08
1e-08 9.99e+07 5.19e+15 0.01751° 0.00180° 1.386e-08 / 9.997e-09
1e-09 9.99e+08 2.45e+17 0.16760° 0.00113° 2.017e-09 / 9.997e-10
1e-10 9.99e+09 4.03e+16 4.16755° 0.00072° 4.978e-09 / 9.997e-11
읽는 법은 이렇다.
핵심은 "제곱하면 정보가 사라진다"는 것이다. σ가 배정밀도의 상대오차 ε ≈ 2.2×10−16 근처보다 작아지면 σ²는 ε 아래로 떨어져 큰 성분에 흡수된다. SVD는 Xc를 직접 다루므로 σ 자체의 자릿수만 필요하다. 같은 답을 구하는 두 길인데, 한쪽은 도중에 자릿수를 절반 버린다.
③④ 그래서 sklearn은 SVD를 쓴다 — 결과 대조
import numpy as np
from sklearn.decomposition import PCA
np.set_printoptions(precision=6, suppress=True)
A = np.array([[1,1,0],[0,2,0],[1,0,3],[0,3,1]], float)
n = 4
mu = A.mean(axis=0)
Ac = A - mu
# --- 중심화 안 한 A 의 SVD (16장) ---
U0, s0, V0t = np.linalg.svd(A, full_matrices=False)
print("중심화 X sigma :", np.round(s0, 12), " 비율 :", np.round(s0**2/(s0**2).sum(), 6))
print(" v1 =", V0t[0], " (1,5,3)/sqrt35 =", np.array([1,5,3])/np.sqrt(35))
print(" 평균방향 mu/|mu| =", mu/np.linalg.norm(mu),
" cos(v1, mu) =", round(abs(V0t[0] @ mu/np.linalg.norm(mu)), 6))
# --- 중심화 한 뒤 ---
U, sv, Vt = np.linalg.svd(Ac, full_matrices=False)
print("\n중심화 O sigma :", np.round(sv, 6), " 비율 :", np.round(sv**2/(sv**2).sum(), 6))
print(" w1 =", Vt[0], " cos(w1, v1) =", round(abs(Vt[0] @ V0t[0]), 6))
# --- sklearn 과 대조 ---
p = PCA(n_components=3, svd_solver="full").fit(A)
print("\nsklearn components_ =\n", p.components_)
print("직접 구현 Vt =\n", Vt)
sign = np.sign(np.sum(p.components_ * Vt, axis=1)) # 부호 규약만 다르다
print("부호 맞춘 뒤 일치 :", np.allclose(p.components_, Vt * sign[:, None], atol=1e-12))
print("sklearn explained_variance_ :", p.explained_variance_)
print("직접 sigma^2/(n-1) :", sv**2/(n-1))
print("sklearn explained_variance_ratio_ :", p.explained_variance_ratio_)
print("sklearn 내부 mean_ :", p.mean_, " == 열평균 :", np.allclose(p.mean_, mu))
print("점수 일치 :", np.allclose(np.abs(p.transform(A)), np.abs(Ac @ Vt.T)))
v1 = [0.169031 0.845154 0.507093] (1,5,3)/sqrt35 = [0.169031 0.845154 0.507093]
평균방향 mu/|mu| = [0.267261 0.801784 0.534522] cos(v1, mu) = 0.993859
중심화 O sigma : [3.012076 1.663768 0.39909 ] 비율 : [0.75605 0.230677 0.013273]
w1 = [ 0.250049 -0.650848 0.716849] cos(w1, v1) = 0.144292
sklearn components_ =
[[ 0.250049 -0.650848 0.716849]
[-0.329606 0.63893 0.695075]
[ 0.910404 0.41008 0.05476 ]]
직접 구현 Vt =
[[ 0.250049 -0.650848 0.716849]
[-0.329606 0.63893 0.695075]
[-0.910404 -0.41008 -0.05476 ]]
부호 맞춘 뒤 일치 : True
sklearn explained_variance_ : [3.024201 0.922708 0.053091]
직접 sigma^2/(n-1) : [3.024201 0.922708 0.053091]
sklearn explained_variance_ratio_ : [0.75605 0.230677 0.013273]
sklearn 내부 mean_ : [0.5 1.5 1. ] == 열평균 : True
점수 일치 : True
세 줄만 짚자.
시각화 — 축이 돌아가는 자리와 자릿수가 사라지는 자리
3차원은 그리기 어려우니 A의 2·3열만 떼어 2차원으로 본다. 네 점은 (1,0), (2,0), (0,3), (3,1), 평균은 (1.5, 1). 이 부분문제는 손으로 끝까지 간다.
2열·3열만 떼어 낸 2×2 PCA
중심화: (−0.5,−1), (0.5,−1), (−1.5,2), (1.5,0)
S₂ = [[5, −3], [−3, 6]] ← 식 (17.9)의 오른쪽 아래 2×2 그대로다.
특성방정식: λ² − 11λ + (30 − 9) = λ² − 11λ + 21 = 0
λ = 11 ± √(121 − 84)2 = 11 ± √372 = 8.541381, 2.458619
첫 축 방향: (5 − λ)x = 3y → (x, y) ∝ (6, −1 − √37)
= (6, −7.082763) → (0.646375, −0.763019)
설명분산 8.541381/11 = 77.65%, 나머지 22.35%.
3차원 전체(75.61%)와 다른 것이 당연하다. 특징 하나를 버린 다른 문제다.



PCA는 스케일에 민감하다 — 2열에 10을 곱해 보자
2열이 "cm"였는데 "mm"로 바뀌었다고 하자. 숫자만 10배가 된다.
B의 2열 = (10, 20, 0, 30), 평균 = 15, 중심화하면 (−5, 5, −15, 15).
나머지 두 열은 그대로이므로 새 그람 행렬은 이렇게 된다.
d₂·d₂ = 25 + 25 + 225 + 225 = 500 (원래 5의 10²배)
d₁·d₂ = 10 × (−2) = −20, d₂·d₃ = 10 × (−3) = −30
⎡ 1 −20 1 ⎤
S_B = ⎢ −20 500 −30 ⎥ tr = 507, det = 100 × 4 = 400
⎣ 1 −30 6 ⎦
특성다항식은 λ³ − 507λ² + 2205λ − 400 = 0, 근은
λ = 502.614524, 4.195801, 0.189675 (합 507 ✓, 곱 400 ✓)
설명분산 비율: 99.135%, 0.828%, 0.037%
그리고 첫 주성분은
w₁ = (0.039887, −0.997381, 0.060331)
2열 하나로 쏠렸다. 원래의 w₁ = (0.250049, −0.650848, 0.716849)는 세 열이 고루 섞인 방향이었다. 단위를 바꿨을 뿐인데 "가장 중요한 방향"의 답이 통째로 달라진 것이다.
왜 그런가. PCA는 분산을 최대화하는데, 분산에는 단위가 있다. 어떤 열의 단위를 10배 크게 잡으면 그 열의 분산은 100배가 되고, 레일리 몫은 그 열 쪽을 고르는 것이 무조건 이득이 된다. PCA는 "어느 특징이 중요한가"를 묻지 않는다. "어느 방향의 숫자가 큰가"를 물을 뿐이다.
해법 — 상관행렬 PCA. 각 열을 표준편차로 나눈 뒤(표준화) PCA하면 공분산행렬 자리에 상관행렬correlation matrix이 들어온다. 우리 A의 경우 대각이 1/3, 5/3, 2이므로
⎡ 1 −2/√5 1/√6 ⎤ ⎡ 1.000000 −0.894427 0.408248 ⎤
R = ⎢ −2/√5 1 −3/√30 ⎥ = ⎢ −0.894427 1.000000 −0.547723 ⎥
⎣ 1/√6 −3/√30 1 ⎦ ⎣ 0.408248 −0.547723 1.000000 ⎦
고유값 2.258747, 0.650509, 0.090744 (합 3 = 특징 수), 비율 75.29%, 21.68%, 3.02%, 첫 축 (−0.606949, 0.637674, −0.474325).
핵심은 이것이다 — A로 계산하든 B로 계산하든 R은 완전히 같다. 상관계수는 단위에 영향을 받지 않기 때문이다(「통계학 기본」 16장). 그래서 특징들의 단위가 제각각일 때는 거의 언제나 StandardScaler를 먼저 건다. 반대로 단위가 이미 같고 크기 차이 자체가 정보일 때(픽셀 밝기, 같은 단위의 센서 채널)는 표준화가 오히려 약한 채널의 잡음을 증폭한다.
이것만 기억하자
- Σ의 고유벡터 = Xc의 우특이벡터, Σ의 고유값 = σ²/(n−1). PCA와 SVD는 같은 계산의 두 이름이다. 우리 A에서 두 경로가 소수점 열두 자리까지 일치한다.
- 같은 답인데도 SVD로 가야 한다. Σ = XcTXc를 만드는 순간 κ가 제곱되고(8·12장), σmin이 작은 성분부터 잠긴다. 실험에서 σmin=10−10일 때 공분산 경로는 4.17°, SVD 경로는 0.0007° 어긋났다.
- 중심화는 정의의 일부, 표준화는 선택이다. 중심화를 빼면 첫 성분이 평균 방향을 잡고(cos = 0.993859), 스케일을 안 맞추면 단위가 큰 열로 쏠린다(2열 ×10 → 첫 성분 99.14%).
흔한 오해
- "PCA는 공분산행렬의 고유분해다." — 정의로는 맞지만 구현으로는 틀렸다. 실무 구현은 공분산행렬을 만들지 않는다. sklearn.decomposition.PCA의 내부는 svd이고, explained_variance_는 σ²/(n−1)로 역산한 값이다.
- "어차피 중심화는 결과를 평행이동만 시킨다." — 아니다. 방향 자체가 바뀐다. 우리 A에서 중심화 전후 첫 성분의 cos은 0.144292, 즉 81.7°로 거의 직교다. 그리고 에너지 26 중 14는 평균이 들고 있다.
- "설명분산이 높은 성분이 예측에 중요한 성분이다." — 별개다. PCA는 y를 본 적이 없다. 분산이 가장 작은 방향에 신호가 몰려 있는 데이터도 얼마든지 있다. 목표를 쓰려면 PLS나 LDA이고, 능형회귀는 작은 고유값 방향을 일부러 줄이는 쪽이다(20장).
- "주성분 부호가 저번 실행과 다르면 잘못된 것이다." — 아니다. 고유벡터의 ±는 정의상 자유이고 라이브러리마다 규약이 다르다. 우리 실험에서도 3번 성분만 numpy와 sklearn의 부호가 반대였다. 스코어의 부호도 같이 뒤집히므로 재구성 결과는 동일하다.
'선형대수' 카테고리의 다른 글
| 손으로 푸는 선형대수 19장 — 행렬로 하는 미분: 그래디언트·야코비안·헤시안 (0) | 2026.09.24 |
|---|---|
| 손으로 푸는 선형대수 18장 — 저랭크 근사: 무엇을 버리는가 (0) | 2026.09.24 |
| 손으로 푸는 선형대수 16장 — SVD: 모든 행렬의 정규분해 (0) | 2026.09.24 |
| 손으로 푸는 선형대수 15장 — 양정치와 이차형식: 공분산이 왜 대칭 양반정치인가 (0) | 2026.09.24 |
| 손으로 푸는 선형대수 14장 — 대칭행렬의 스펙트럼 분해 (0) | 2026.09.24 |
댓글