SVD와 PCA: 차원을 줄여 본질만 남기기

모든 행렬을 회전, 늘이기, 회전으로 쪼개는 특이값 분해(SVD)를 이해하고, 중요한 성분만 남기는 저차원 근사로 데이터를 압축해요. 추천 시스템의 원리, 주성분 분석(PCA)으로 데이터의 주축을 찾고 차원을 줄이는 과정까지 넘파이로 직접 해 봐요.

고급강의 6 / 6약 8분

데이터에는 군더더기가 많아요. 키와 몸무게처럼 함께 움직이는 특징, 거의 변하지 않는 특징, 그리고 잡음. 본질만 남기고 나머지를 버리는 기술이 이번 강의의 주제예요. 그 중심에 선형대수의 꽃이라고 불리는 특이값 분해(SVD)가 있어요. 이미지 압축, 추천 시스템, 검색, 차원 축소, 그리고 LLM의 효율적인 미세 조정까지 SVD의 생각이 곳곳에 쓰여요.

고유값 분해의 한계

지난 강의의 대각화 A=PDP−1A = PDP^{-1}는 강력하지만 조건이 까다로워요.

  • 정사각 행렬에만 쓸 수 있어요. 데이터 행렬은 보통 "데이터 수 × 특징 수"라 정사각이 아니에요.
  • 정사각이어도 고유벡터가 모자라 대각화가 안 되는 행렬이 있어요.

SVD는 모든 행렬에 대해 항상 가능해요.

특이값 분해 (SVD)

m×nm \times n 행렬 AA는 언제나 이렇게 쪼갤 수 있어요.

A=UΣVTA = U \Sigma V^T
  • VTV^T: 입력 공간의 회전(직교 행렬)
  • Σ\Sigma: 축을 따라 늘이기. 대각선에 특이값 σ1≥σ2≥⋯≥0\sigma_1 \ge \sigma_2 \ge \cdots \ge 0이 큰 순서로 놓여요.
  • UU: 출력 공간의 회전(직교 행렬)

그림으로 보기

어떤 선형 변환이든 "돌리고, 축마다 늘이고, 다시 돌리는" 세 단계로 설명된다는 뜻이에요. 원(구)에 행렬을 곱하면 항상 타원(타원체)이 되는데,

  • 특이값 σi\sigma_i는 그 타원의 반지름들이고,
  • UU의 열은 타원의 축 방향,
  • VV의 열은 그 축으로 가게 되는 원래 방향이에요.

고유값과의 관계

ATAA^T A는 항상 대칭 행렬이에요. 그 고유벡터가 VV의 열이고, 고유값이 특이값의 제곱이에요.

ATA=VΣ2VTA^T A = V \Sigma^2 V^T

지난 강의의 "대칭 행렬은 착하다"가 여기서 쓰여요. 아무 행렬이라도 ATAA^T A라는 착한 대칭 행렬을 거치면 분해할 수 있는 거예요.

성분의 합으로 보기

SVD를 펼쳐 쓰면 행렬이 간단한 행렬들의 합이 돼요.

A=σ1u1v1T+σ2u2v2T+⋯+σrurvrTA = \sigma_1 \mathbf{u}_1 \mathbf{v}_1^T + \sigma_2 \mathbf{u}_2 \mathbf{v}_2^T + \cdots + \sigma_r \mathbf{u}_r \mathbf{v}_r^T

uiviT\mathbf{u}_i \mathbf{v}_i^T는 열 벡터와 행 벡터를 곱한 계수 1짜리 행렬, 일종의 "패턴 한 장"이에요. 행렬은 패턴들을 중요도(σi\sigma_i) 순서로 겹쳐 놓은 것이고, 특이값이 빠르게 작아진다면 앞의 몇 장만으로 거의 원래 모습이 나와요.

저차원 근사: 데이터 압축

앞의 kk개 성분만 남긴 것을 AkA_k라고 하면, 이것이 계수 kk짜리 행렬 중 원래 행렬에 가장 가까운 행렬이에요(에카르트-영 정리). 버리는 것이 가장 덜 중요한 성분이니까요.

이미지 압축 실험

import numpy as np

rng = np.random.default_rng(0)
x = np.linspace(0, 1, 200)
# 두 가지 무늬를 겹치고 약간의 잡음을 섞은 200 x 200 "이미지"
img = (np.outer(np.sin(6 * x), np.cos(4 * x))
       + 0.5 * np.outer(x, x ** 2)
       + 0.05 * rng.normal(size=(200, 200)))

U, S, Vt = np.linalg.svd(img)
print(np.round(S[:6], 2))   # [117.59  14.84   1.4    1.38   1.34   1.31]

for k in (1, 2, 5, 20):
    approx = (U[:, :k] * S[:k]) @ Vt[:k]
    err = np.linalg.norm(img - approx) / np.linalg.norm(img)
    store = k * (200 + 200 + 1) / (200 * 200)
    print(f"k={k:2d}  오차 {err:.3f}  저장 공간 {store:.1%}")
k= 1  오차 0.150  저장 공간 1.0%
k= 2  오차 0.083  저장 공간 2.0%
k= 5  오차 0.081  저장 공간 5.0%
k=20  오차 0.070  저장 공간 20.1%

특이값을 보세요. 117과 15 다음에 갑자기 1.4 근처로 떨어져요. 이 이미지는 사실 무늬 두 개와 잡음이었고, SVD가 그걸 정확히 찾아냈어요. 성분 2개만 남기면 저장 공간 2%로 거의 원래 모습이 되고, 그 뒤의 성분들은 잡음이라 더 넣어도 별로 나아지지 않아요. 저차원 근사는 압축이면서 동시에 잡음 제거이기도 해요.

내 사진으로 해 보고 싶다면 이렇게 불러오면 돼요.

from PIL import Image   # pip install pillow
img = np.array(Image.open("photo.jpg").convert("L"), dtype=float)

실제 사진은 특이값이 이렇게 급격히 떨어지지는 않지만, 상위 몇십 개만으로 알아볼 수 있는 모습이 나와요.

추천 시스템: 숨은 취향 찾기

사용자 × 영화 평점 행렬을 생각해 볼게요. 대부분의 칸이 비어 있어요(아무도 모든 영화를 보지 않으니까요).

액션1액션2로맨스1로맨스2
민지541?
준혁4?12
서연1254

이 행렬을 SVD처럼 분해하면, 각 사용자와 영화가 몇 개의 잠재 요인(액션 선호, 로맨스 선호 같은 숨은 축) 위의 벡터로 표현돼요. 빈칸의 예상 평점은 사용자 벡터와 영화 벡터의 내적이에요. 넷플릭스가 2006년에 상금 100만 달러를 걸었던 추천 대회에서 이 행렬 분해 방법들이 큰 활약을 했어요. 빈칸이 있는 행렬에는 표준 SVD 대신, 채워진 칸의 오차만 줄이도록 학습하는 변형을 써요.

주성분 분석 (PCA)

아이디어

키와 몸무게 데이터는 비스듬한 타원 모양으로 퍼져 있어요. 축을 그 타원의 긴 방향과 짧은 방향으로 새로 잡으면, 긴 방향 하나만으로도 데이터의 대부분을 설명할 수 있어요. 이 새 축들이 주성분이에요.

  • 첫째 주성분: 데이터가 가장 넓게 퍼진(분산이 가장 큰) 방향
  • 둘째 주성분: 첫째와 직교하면서 그다음으로 넓게 퍼진 방향
  • ...

계산 순서

  1. 각 특징의 평균을 빼서 데이터를 원점 중심으로 옮겨요(중심화).
  2. 특징마다 단위가 다르면 표준편차로 나눠 맞춰요(표준화).
  3. 중심화한 데이터 행렬에 SVD를 해요. VV의 열이 주성분 방향이고, 특이값의 제곱이 각 방향의 분산에 비례해요.
  4. 원하는 개수 kk만큼의 주성분 위로 데이터를 투영해요.

공분산 행렬의 고유값 분해로도 같은 결과가 나와요. 지난 강의의 "공분산 행렬의 고유벡터 = 데이터의 주축"이 바로 이거예요. 실무에서는 수치적으로 더 안정한 SVD를 써요.

직접 해 보기

rng = np.random.default_rng(0)
height = rng.normal(170, 8, 500)
weight = 0.9 * (height - 170) + 65 + rng.normal(0, 4, 500)
X = np.column_stack([height, weight])

Xc = X - X.mean(axis=0)                     # 1. 중심화
U, S, Vt = np.linalg.svd(Xc, full_matrices=False)
var = S ** 2 / (len(X) - 1)                 # 각 주성분의 분산

print(np.round(Vt, 3))                      # 주성분 방향 (행마다 하나)
print(np.round(var / var.sum(), 3))         # 설명된 분산 비율 [0.945 0.055]

Z = Xc @ Vt[0]                              # 첫째 주성분 하나로 줄인 1차원 데이터

첫째 주성분 방향은 대략 (0.70,0.71)(0.70, 0.71)로, "키와 몸무게가 함께 커지는 방향"이에요(부호는 SVD가 임의로 정해서 반대로 나올 수 있어요). 이 한 방향이 전체 변화의 94.5%를 설명해요. 2차원 데이터를 1차원으로 줄여도 정보의 95%가 남는다는 뜻이에요. 이 새 축은 사실상 "덩치"라는 숨은 특징이에요.

몇 개를 남길까

설명된 분산 비율을 큰 순서로 누적해서, 보통 90~95%를 넘는 지점까지 남겨요. 수백 차원 데이터가 수십 차원으로 줄어드는 경우가 흔해요.

from sklearn.decomposition import PCA   # pip install scikit-learn
pca = PCA(n_components=0.95)            # 95%를 설명할 만큼 자동으로 고르기
Z = pca.fit_transform(Xc)
print(pca.n_components_, pca.explained_variance_ratio_)

PCA를 쓰는 곳

  • 시각화: 수백 차원 데이터를 2~3차원으로 줄여 그림으로 보기
  • 잡음 제거: 작은 성분을 버리면 잡음이 줄어요.
  • 속도 향상: 특징이 줄면 학습이 빨라져요.
  • 다중 공선성 해결: 주성분은 서로 직교해서, 서로 얽힌 특징 문제가 사라져요.

주의할 점

  • 표준화를 잊지 마세요. 원 단위 연봉과 년 단위 나이를 그대로 넣으면 숫자가 큰 연봉이 주성분을 독차지해요.
  • PCA는 직선 방향만 찾아요. 데이터가 소용돌이처럼 휘어 있으면 잘 못 잡아요. 시각화에는 t-SNE나 UMAP 같은 비선형 방법을 함께 써요.
  • 주성분은 해석이 어려울 수 있어요. 원래 특징들의 섞음이라, "덩치"처럼 이름 붙이기 쉬운 경우만 있는 건 아니에요.

AI 속의 SVD

  • 임베딩 시각화: 1000차원이 넘는 문장 임베딩을 PCA로 2차원에 찍어 보면, 비슷한 뜻끼리 모여 있는 게 보여요.
  • LoRA: 거대한 언어 모델을 내 일에 맞게 미세 조정할 때, 가중치 변화량을 계수가 낮은 두 행렬의 곱으로 근사해요. "중요한 변화는 몇 개의 방향에 몰려 있다"는 저차원 근사의 생각 그대로예요. LLM 트랙의 파인튜닝 강의에서 다시 만나요.
  • 잠재 의미 분석: 단어 × 문서 행렬에 SVD를 해서 단어와 문서의 숨은 주제를 찾던 방법이 현대 임베딩의 조상이에요.

실습

  1. 위 이미지 실험에서 잡음의 크기(0.05)를 0.3으로 키우면 특이값의 모양과 오차가 어떻게 바뀌는지 보세요.
  2. 키, 몸무게에 "신발 크기" 특징을 하나 더 만들어 3차원 데이터로 PCA를 해 보세요. 첫째 주성분이 몇 %를 설명하나요?
  3. scikit-learn의 load_digits() 손글씨 데이터(64차원)를 PCA로 2차원으로 줄여 숫자별로 색을 달리해 그려 보세요. 같은 숫자끼리 모이는지 확인하세요.

자주 하는 실수

  • 중심화 없이 PCA 하기. 평균을 빼지 않으면 첫째 주성분이 데이터의 퍼짐이 아니라 평균 방향을 가리켜요.
  • 표준화 생략. 단위가 다른 특징이 섞여 있으면 반드시 표준화하세요.
  • 주성분 부호에 의미 두기. 부호는 임의예요. 방향(직선)이 중요해요.
  • 테스트 데이터로 PCA 학습하기. 머신러닝에서는 학습 데이터로 구한 주성분을 테스트 데이터에 그대로 적용해야 해요. 머신러닝 트랙의 평가 강의에서 이유를 다뤄요.

점검 문제

  1. SVD A=UΣVTA = U\Sigma V^T에서 각 행렬이 하는 일은?
  2. 특이값이 [50,30,0.1,0.05][50, 30, 0.1, 0.05]인 행렬은 대략 계수가 몇인 행렬로 볼 수 있을까요?
  3. PCA의 첫째 주성분은 어떤 방향인가요?
  4. PCA 전에 표준화가 필요한 이유는?

정답

  1. VTV^T는 입력 공간을 회전하고, Σ\Sigma는 축마다 늘이고, UU는 출력 공간을 회전해요.
  2. 앞의 두 특이값이 압도적이라 사실상 계수 2인 행렬(나머지는 잡음 수준)이에요.
  3. 데이터의 분산이 가장 큰 방향, 즉 데이터가 가장 넓게 퍼진 방향이에요.
  4. 단위와 크기가 다른 특징이 섞여 있으면 숫자가 큰 특징이 분산을 독차지해서 주성분이 왜곡되기 때문이에요.

선형대수를 마치며

벡터로 데이터를 표현하고, 행렬로 공간을 움직이고, 역행렬로 되돌리고, 고유벡터와 특이값으로 변환의 뼈대를 찾았어요. 이제 신경망 코드에서 W @ x를 보면 "공간을 변환하는구나", 임베딩을 보면 "방향으로 뜻을 담았구나" 하고 읽을 수 있을 거예요.

다음은 미적분과 최적화 트랙이에요. 선형대수가 AI의 "몸"이라면 미적분은 AI가 스스로 나아지는 방법이에요. 오차를 줄이는 방향을 찾는 미분, 그리고 그 방향으로 한 걸음씩 내려가는 경사 하강법을 배워요.