본문 바로가기
로봇/인공지능, AI

[핸즈온 머신러닝 3판] 8.차원 축소

by 33곰탱 2024. 11. 17.

1부 8장

8.차원 축소

  • 8.1 차원의 저주
  • 8.2 차원 축소를 위한 접근법
  • 8.3 주성분 분석 
  • 8.4 랜덤 투영
  • 8.5 지역 선형 임베딩
  • 8.6 다른 차원 축소 기법

차원 축소..라 시간여행이 이제 가능해지는건가 뭔가 호기심이 생기네요

시간 축소도 있으면 좋겠다.

 

Anyway Let's go!

 

https://colab.research.google.com/github/rickiepark/handson-ml3/blob/main/08_dimensionality_reduction.ipynb#scrollTo=QVtMHLlAZPsZ

 

8.1 차원의 저주

우리는 3차원 세계에 익숙하며 고차원 공간을 직관적으로 상상하기 어렵다. 사실 4차원도 뭔지 모름.... 음...

 

고차원 초입방체에서는 경계에 가까운 점의 비율이 매우 높아지는데, 10,000차원 초입방체에서 점을 무작위로 선택하면 대부분 경계 근처에 위치한다.

 

경계에 위치하기 때문에 차원이 증가할수록 공간 내 두 점 간의 평균 거리가 급격히 멀어지게 된다.

  • 즉 고차원 데이터셋에서 훈련 샘플의 밀도가 낮아져 훈련 데이터가 희박해지고, 새로운 샘플이 기존 데이터에서 멀리 떨어질 가능성이 증가하며, 모델이 과대적합되거나 예측 성능이 저하될 위험 증가한다.
  • 해결 방안으로는 훈련 샘플의 밀도를 높이기 위해 훈련 데이터의 크기를 기하급수적으로 늘리는 방법이 있다. 하지만 실제로는 데이터 수집과 계산 자원이 한정되어 있으므로 특정 범위에 균일한 밀도를 유지하는 샘플링 전략이 필요하다.

 

8.2 차원 축소를 위한 접근법

차원 축소 그래서 어캐 함,,? 밑에서 알아 보자!

8.2.1 투영

일단 투영은 선형 대수에서 배웠던 개념인데 잘 기억이 안난다. 

정의는 다음과 같다. "고차원 벡터 공간에서 벡터를 특정 부분 공간으로 사영하는 연산"

 

음...

 

GPT : 고차원 공간에 있는 점(또는 벡터)을 낮은 차원의 평면이나 에 "그림자처럼 옮기는 과정"입니다.

           마치 3차원 물체를 빛에 비춰서 2차원 벽에 그림자를 만드는 것과 비슷합니다.

 

이제 좀 알 것 같다.



 

대부분의 훈련 샘플은 이러한 부분 공간이나 경계 근처에 위치하며, 고차원 데이터의 공통적인 특성이다.

 

위의 사진을 보면 3차원 데이터셋이 있는 것을 확인할 수 있는데, 3차원 데이터셋을 2차원 평면에 투영한 사례이다. (3D->2D)

 

 

데이터가 특정 저차원 평면에 가까이 분포할 때, 이를 수직으로 투영하면 저차원 데이터로 변환할 수 있다는 것이다!

 

그렇다면 투영이 만능일까? 유감스럽게도 당연히 아니다..(ㅠ)

 

아래의 그림(스위스 롤)을 보자

 

이 그림을 평면에 투영시키면 아래 왼쪽 사진처럼 빵을 위에서 본 것처럼 보이게 된다. 우리가 원하는 것은 오른쪽 사진처럼 롤케이크를 펼쳤을 때와 같은 데이터셋이다.

8.2.2 매니 폴드 학습

고차원 공간에서 데이터가 위치한 저차원 구조(subspace)를 매니폴드(Manifold)라고 한다.

2D 매니폴드는 고차원 공간에서 휘어지거나 뒤틀린 2D 모양을 말한다.

 

일반적으로 d차원 매니폴드는 국부적으로 d차원 초평면으로 보일 수 있는 n차원 공간의 일부라고 한다! (d<n)

 

이때 스위스 롤은 2D 매니폴드로, 3D 공간에서 뒤틀린 2D 평면 형태를 가진다.

 

차원 축소 알고리즘은 매니폴드를 모델링하는 방식으로 작동하는데 이를 매니폴드 학습이라고 한다. 이 때,  많은 차원 축소 알고리즘은 데이터가 저차원 매니폴드에 가깝게 분포해 있다는 가정을 바탕으로 작동한다. 이를 매니폴드 가정 또는 매니폴드 가설이라고 한다.

 

매니폴드 가정은 종종 암묵적으로 다른 가정과 병행되곤 한다. 처리해야 할 작업이 저차원의 매니폴드 공간에 표현되면 더 간단해질 것이란 가정인데, 과연 그럴까?

 

3D(왼쪽)에서는 결정 경계가 매우 복잡하지만 펼쳐진 매나폴드 공간인 2D(오른쪽) 에서는 결정 경계가 단순한 직선이다.

왼쪽 사진은 결정 경계가 x1 = 5 에 놓여 있는 모습이다. 이 결정 경계는 3D 공간에서는 매우 단순한 반면(수직 평면) 오른쪽 사진에서 펼쳐진 매니폴드에서는 결정 경계가 더 복잡해진 모습을 알 수 있다.

 

이를 통해서 알 수 있는 것은 모델을 훈련시키기 전에 훈련 세트의 차원을 감소시키면 훈련 속도는 빨라지겠지만,

항상 더 낫거나 간단한 솔루션이 되는 것은 아니라고 한다. 데이터셋마다 다르다!!! 

8.3 주성분 분석 

주성분 분석은 가장 인기 잇는 차원 축소 알고리즘이다. 먼저 데이터에 가장 가까운 초평면을 정의한 다음, 데이터를 이 평면에 투영시킨다고 한다.

초평면 (wikipedia)

뭔지 한번 알아보자

8.3.1 분산 보존

데이터를 저차원으로 투영할 때, 올바른 초평면(투영 축)을 선택하는 것이 중요하다. 

 

 

왼쪽 그래프: 2D 데이터셋이 3개의 축(1차원 초평면)에 투영된 것이고 오른쪽 그래프는 데이터가 선택된 각 축으로 투영된 결과를 나타낸다. c1축 기준으로 분산을 최대로 보존하는 반면, c2의 경우 분산을 매우 적게 유지하는 것을 알 수 있다.

 

이 때 분산이 최대로 보존되는 축을 선택하는 것이 정보가 가장 적게 손실되기 때문에 잘 선택해야 한다.

 

즉, 원본 데이터셋과 투영된 평면 사이의 평균 제곱 거리를 최소화하는 축을 골라야 한다!

 

 

8.3.2 주성분

주성분 분석(PCA)은 훈련 세트에서 분산이 최대인 축을 찾는 알고리즘이다.

 

 

  • 첫 번째 축(주성분 1, PC1)은 데이터의 분산을 가장 많이 설명하는 방향이며,
  • 두 번째 축(주성분 2, PC2)은 첫 번째 축에 직교하며, 남은 분산을 최대한 설명한다.
  • 이러한 과정은 차원 수만큼 반복되어 세 번째, 네 번째... n 번째 축이 결정 된다.

이 때 i 번째 축을 데이터의 i 번째 주성분(principal component, (PC)) 이라고 부른다. 이 때 각 주성분은 데이터의 분산 방향을 나타낸다.

 

위의 그래프 에서는 c1이 첫 번째 주성분, c2가 두 번째 주성분이다.

 

그렇다면 주성분은 어떻게 찾을까? 특잇값 분해(SVD)라는 표준 행렬 분해 기술을 사용한다. 

 

\begin{equation}
V = 
\begin{pmatrix}
\mathbf{c}_1 & \mathbf{c}_2 & \cdots & \mathbf{c}_n
\end{pmatrix}
\end{equation}

 

SVD 인수 분해 알고리즘은 X = UΣV인 세 개의 행렬 U, Σ, V을 반환한다. U m × m 행렬, Σ m × n 행렬, V n × n 행렬이다.

 

이를 통해 모든 주성분의 단위 벡터가 V에 담기게 된다.

 

 

아래는 넘파이의 svd() 함수를 사용해서 단위 벡터를 추출하는 코드이다. 

import numpy as np

# X = [...]  # 이 노트북의 앞부분에서 생성한 작은 3D 데이터셋
X_centered = X - X.mean(axis=0)
U, s, Vt = np.linalg.svd(X_centered)
c1 = Vt[0]
c2 = Vt[1]

 

 

코랩 참고 : svd() 함수는 대신 U, s  V를 반환합니다. s Σ의 상위 n 행의 주 대각선에 있는 모든 값을 포함하는 벡터입니다. 다른 곳에서는 Σ가 0으로 가득 차 있기 때문에 s로부터 다음과 같이 쉽게 재구성할 수 있습니다:

# 추가 코드 - s에서 Σ를 구성하는 방법을 보여줍니다.
m, n = X.shape
Σ = np.zeros_like(X_centered)
Σ[:n, :n] = np.diag(s)
assert np.allclose(X_centered, U @ Σ @ Vt)

 

8.3.3 d 차원으로 투영하기

주성분을 모두 추출해냈다면 처음 d개의 주성분으로 정의한 초평면에 투영하여 데이터셋의 차원을 d치원으로 축소시킬 수 있게 된다!

 

축소된 데이터 계산을 위해서는 주성분으로 구성된 행렬 \( W_d \)를 사용하여 원본 데이터 \( X \)를 투영한 후, 축소된 데이터 \( X_{d\text{-proj}} \)는 다음과 같이 계산하면 된다: \[X_{d\text{-proj}} = X W_d\]

 

아래는 위에서 했던 코드를 이어서 축소된 데이터 계산하는 과정이다.

W2 = Vt[:2].T  # 첫 2개의 주성분으로 구성된 행렬
X2D = X_centered @ W2  # 데이터셋을 투영하여 2D로 축소

8.3.4 사이킷런 사용하기

from sklearn.decomposition import PCA

pca = PCA(n_components=2)
X2D = pca.fit_transform(X)

pca.components_

8.3.5 설명된 분산의 비율

설명된 분산의 비율은 각 주성분(Principal Component, PC)이 데이터의 전체 분산에서 차지하는 비율을 나타낸다.

 

아래의 코드를 실행하면

pca.explained_variance_ratio_

첫 번째 차원은 분산의 약 76%를 설명하는 반면, 두 번째 차원은 약 15%를 설명하는 모습이다.

 

1 - pca.explained_variance_ratio_.sum()  # 추가 코드

2D로 투영함으로써 약 9%의 분산이 감소한 모습을 볼 수 있다.

8.3.6 적절한 차원 수 선택

축소할 차원의 수를 임의로 정하는 것보다는 데이터 축소 시 전체 분산의 일정 비율(예: 95%)을 유지할 수 있는 차원 수를 선택하는 것이 일반적이라고 한다.

 

아래의 코드는 훈련 집합의 분산 95%를 보존하는 데 필요한 최소 차원 수를 계산 한다.

from sklearn.datasets import fetch_openml

# 사이킷런 1.4버전에서 parser 매개변수 기본값이 'liac-arff'에서 'auto'로 바뀌었습니다.
# 이전 버전에서도 동일한 결과를 내도록 명시적으로 'auto'로 지정합니다.
mnist = fetch_openml('mnist_784', as_frame=False, parser="auto")
X_train, y_train = mnist.data[:60_000], mnist.target[:60_000]
X_test, y_test = mnist.data[60_000:], mnist.target[60_000:]

pca = PCA()
pca.fit(X_train)
cumsum = np.cumsum(pca.explained_variance_ratio_)
d = np.argmax(cumsum >= 0.95) + 1  # d = 154

d

 

d = 154개가 되는 것을 알 수 있다.

 

pca = PCA(n_components=0.95)
X_reduced = pca.fit_transform(X_train)
  • n_components=0.95로 설정하면, PCA가 자동으로 95% 분산을 유지하는 데 필요한 차원 수를 계산해준다.
pca.n_components_


차원 수를 찾는 또 다른 방법은 분산을 차원 수에 대한 함수로 그리는 것이라고 한다.

 

아래 그림은 차원 수에 대한 설명된 분산 그래프이다. 

 

 지도 학습 작업의 전처리 단계로 차원 축소를 사용하는 경우, 차원 수를 튜닝할 수 있다고 한다. 

 

다음 코드는 PCA를 사용하여 차원을 줄인 다음 랜덤 포레스트를 사용하여 분류를 수행한 후 RandomizedSearchCV를 사용하여 PCA 와 랜덤 포레스트 분류기에 잘 맞는 하이퍼파라미터 조합을 찾는다.

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import RandomizedSearchCV
from sklearn.pipeline import make_pipeline

clf = make_pipeline(PCA(random_state=42),
                    RandomForestClassifier(random_state=42))
param_distrib = {
    "pca__n_components": np.arange(10, 80),
    "randomforestclassifier__n_estimators": np.arange(50, 500)
}
rnd_search = RandomizedSearchCV(clf, param_distrib, n_iter=10, cv=3,
                                random_state=42)
rnd_search.fit(X_train[:1000], y_train[:1000])

print(rnd_search.best_params_)

784차원23차원으로 줄였다!!!!!

8.3.7 압축을 위한 PCA

 

  • PCA를 사용하면 데이터의 차원을 줄임으로써 데이터 크기를 줄일 수 있다. 예를 들어, MNIST 데이터셋(784개의 특성)을 154개의 특성으로 줄이면서도 데이터의 95% 분산을 유지할 수 있다. (대박) 이를 통해 데이터 크기를 약 20%로 줄이고, 분류 알고리즘의 속도를 높일 수 있게 된다. (좋다)

 

 

  • PCA는 차원을 줄인 데이터에서 다시 원본 형태로 데이터를 복원하는 기능도 제공하는데, 이를 inverse_transform() 메서드를 사용해 구현하며, 복원된 데이터와 원본 데이터의 차이를 재구성 오차라고 한다.

역변환 공식은 아래와 같다.

 

\[X_{\text{recovered}} = X_{d\text{-proj}} W_d^T\] 아까 \[X_{d\text{-proj}} = X W_d\]에서 위치가 좀 바뀌었네

 

또한 inverse_transform() 메서드를 사용하면 축소된 MNIST 데이터 집합을 다시 784개의 차원으로 복원할 수 있다.

X_recovered = pca.inverse_transform(X_reduced)

 

화질 차이 말고는 거의 비슷하게 보인다!

8.3.8 랜덤 PCA

 

  •  

랜덤 PCA는 svd_solver 매개변수를 "randomized"로 설정하여 사용하는 방법이다.

 

완전 SVD의 계산 복잡도
\[
O(m \cdot n^2) + O(n \cdot m^2)
\]

랜덤 PCA의 계산 복잡도
\[
O(m \cdot d^2) + O(d^3)
\]

 

 

알 수 있듯이 d가 n보다 많이 작으면 완전 SVD보다 훨씬 빠르다.

 

rnd_pca = PCA(n_components=154, svd_solver="randomized", random_state=42)
X_reduced = rnd_pca.fit_transform(X_train)

 

 

svd_solver의 기본값은 "auto"로 설정되어 있으며,

보통 max⁡(m,n)>500 이거나 n_components가 의 80%보다 작을 경우 자동으로 랜덤 PCA를 사용한다고 한다. (양이 많아서?)

 

랜덤 PCA를 만약 명시적으로 사용하려면 svd_solver="randomized"를 지정해주면 되며,

더 정확한 결과가 필요한 경우 svd_solver="full"을 설정하여 완전한 SVD 방식을 사용할 수 있다고 한다.

 

8.3.9 점진적 PCA

 

  • 일반적인 PCA의 문제점 중 하나는 데이터를 모두 메모리에 로드한 후 행렬 분해를 통해 주성분을 계산한다는 것이다.
  • 이에 대응하여 점진적 PCA(IPCA)는 데이터를 배치(batch)로 나누어 각 배치에 대해 부분적으로 학습하며 전체 데이터를 점진적으로 처리한다.

아래의 코드는 데이터를 100개의 배치로 나누고 partial_fit 메서드를 통해 한 번에 한 배치씩 주성분을 학습하고, 그 후에 학습이 완료된 후, transform 메서드를 사용하여 데이터의 차원을 축소하는 코드이다.

from sklearn.decomposition import IncrementalPCA
import numpy as np

n_batches = 100  # 배치 개수
inc_pca = IncrementalPCA(n_components=154)  # IPCA 객체 생성

for X_batch in np.array_split(X_train, n_batches):  # 데이터를 배치로 분리
    inc_pca.partial_fit(X_batch)  # 각 배치에 대해 partial fit 수행

X_reduced = inc_pca.transform(X_train)

 

또는 디스크의 이진 파일에 저장된 대규모 배열을 메모리에 있는 것처럼 조작할 수 있는 memmap 클래스를 사용할 수 있다.

 

GPT: Memory-mapped 파일은 대규모 데이터셋을 디스크에 저장한 후 필요한 부분만 메모리에 로드하여 처리할 수 있는 방식입니다. 이는 메모리 사용량을 절약하고, IPCA처럼 대규모 데이터를 다룰 때 유용합니다.

 

먼저, 데이터를 디스크에 저장하고 메모리와 연결하는 작업을 수행한 후,

filename = "my_mnist.mmap"
X_mmap = np.memmap(filename, dtype='float32', mode='write', shape=X_train.shape)
X_mmap[:] = X_train  # 대신 루프를 사용하여 데이터를 청크 단위로 저장할 수 있습니다.
X_mmap.flush()

 

메모리에 로드된 memmap 데이터를 사용해 Incremental PCA를 수행하면 된다.

X_mmap = np.memmap(filename, dtype="float32", mode="readonly").reshape(-1, 784)
batch_size = X_mmap.shape[0] // n_batches
inc_pca = IncrementalPCA(n_components=154, batch_size=batch_size)
inc_pca.fit(X_mmap)

8.4 랜덤 투영

완전 SVD의 계산 복잡도
\[O(m \cdot n^2) + O(n \cdot m^2)\]

랜덤 PCA의 계산 복잡도
\[O(m \cdot d^2) + O(d^3)\]

에서 봤던 것처럼 매우 고차원인 경우 n과 d모두 클 것이다. 그러면 PCA가 너무 느려질 수 있는데, 이 때 랜덤 투영 사용을 고려해보아야 한다.

 

랜덤 투영은 데이터를 랜덤한 선형 변환을 통해 고차원에서 저차원 공간으로 투영하는 방법이다. 뭔가 랜덤이면 잘 안될 것 같은데 랜덤 투영 후에도 데이터 간의 거리는 실제와 매우 비슷하게 유지된다고 한다. (신기..)

 

존슨-린덴스트라우스 정리를 통해 이 이론적 근거를 제공하는데, 한번 봐보자

 

존슨-린덴스트라우스 정리에 따르면, m개의 데이터 샘플이 있을 때, 데이터 간 거리가 ϵ만큼 유지되도록 보장하려면, 목표 차원의 수 d는 다음 식으로 계산된다고 한다.

 

\[d \geq \frac{4 \log(m)}{\frac{1}{2}\epsilon^2 - \frac{1}{3}\epsilon^3}\]

 

, ϵ=0.1인 경우, 차원은 약 7,300개로 축소가 가능해진다. 또한 이 식은 n 값은 사용하지 않는다고 한다.

 

이 방정식은 johnson_lindenstrauss_min_dim() 함수에 구현되어 있다.

from sklearn.random_projection import johnson_lindenstrauss_min_dim

m, ε = 5_000, 0.1
d = johnson_lindenstrauss_min_dim(m, eps=ε)
d

이제 각 항목을 평균 0 분산 %의 가우스 분포에서 랜덤 샘플링한 [d n] 크기의 랜덤 행렬 P 를 생성하고 이를 사용하여 데이터셋을 n차원에서 d차원으로 투영할 수 있다.

n = 20_000
np.random.seed(42)
P = np.random.randn(d, n) / np.sqrt(d)  # 표준 편차 = 분산의 제곱근

X = np.random.randn(m, n)  # 가짜 데이터셋 생성
X_reduced = X @ P.T

 

 

 

 

 사이킷런은 방금 한 것과 같은 작업을 수행할 수 있는 GaussianRandomProjection 클래스를 제공한다.

from sklearn.random_projection import GaussianRandomProjection

gaussian_rnd_proj = GaussianRandomProjection(eps=ε, random_state=42)
X_reduced = gaussian_rnd_proj.fit_transform(X)  # 위와 동일한 결과

8.5 지역 선형 임베딩

지역 선형 임베딩(LLE)은 비선형 차원 축소(NLDR) 기술이다.

PCA나 랜덤 투영과는 달리 투영에 의존하지 않는 매니폴드 학습이라고 한다.

 

아래의 코드로 스위스 롤을 만든 뒤 사이킷런의 LocallyLinearEmbedding으로 이를 펼칠 수 있다.

여기서 변수 t는 스위스 롤의 회전 축을 따라 각 샘플의 위치를 포함하는 1D 넘파이 배열이라고 한다. (이게 뭔소리?)

from sklearn.datasets import make_swiss_roll
from sklearn.manifold import LocallyLinearEmbedding

X_swiss, t = make_swiss_roll(n_samples=1000, noise=0.2, random_state=42)
lle = LocallyLinearEmbedding(n_components=2, n_neighbors=10, random_state=42)
X_unrolled = lle.fit_transform(X_swiss)

왜 책이랑 x축이 거꾸로 된 것인지는 모르겠지만 스위스 롤이 펼쳐진 모습이다.

이렇게 꼬여있는 스위스 롤도 LLE는 잘 풀어준다고 한다.

 

LLE의 수식은 다음과 같다.


\[W = \arg\min_W \sum_{i} \left\| x_i - \sum_{j} W_{ij} x_j \right\|^2\]

 

 

  • 역할: 각 데이터 포인트 $x_i$ 를 자신의 $k$개 가까운 이웃들의 선형 조합으로 나타냄
  • 목적: 데이터 포인트가 이웃 포인트들로 잘 설명되도록 $W$를 계산
  • 핵심 아이디어: $x_i$와 이웃의 선형 조합 간의 차이를 최소화

 


\[\sum_{j} W_{ij} = 1, \quad W_{ij} = 0 \quad \text{if $x_j$는 $x_i$의 이웃이 아님.}\]

 

  • 역할: 가중치 $W_{ij}$의 합을 1로 맞추고, 이웃이 아닌 데이터 포인트에는 가중치를 0으로 설정
  • 목적:  $W$ 가 선형 관계를 유지하도록 제한

\[Z = \arg\min_Z \sum_{i} \left\| z_i - \sum_{j} W_{ij} z_j \right\|^2\]

 

 

  • 역할: 저차원 데이터 표현  $Z$를 계산
  • 목적: 저차원 공간에서도 $x_i$와 이웃 간의 선형 관계를 최대한 보존

 


\[Z \text{는 평균 0, 분산 1을 가지도록 설정.}\]

 

 

  • 역할: 저차원 데이터  $Z$의 평균과 분산을 표준화
  • 목적: 데이터의 분산이 적절히 유지되도록 보장

 


\[\text{근접 이웃 찾기: } \mathcal{O}(m \log m)\]

\[\text{가중치 계산: } \mathcal{O}(m n k)\]

\[\text{저차원 표현 계산: } \mathcal{O}(d m^2)\]


위의 사진은 Z1이 t와 얼마나 잘 연관되어 있는지 보여준다.

8.6 다른 차원 축소 기법

 

  • 다차원 스케일링 (MDS)
    • 데이터 포인트 간의 거리를 보존하며 차원을 축소하는 방법
    • 고차원 데이터보다는 저차원 데이터에 더 적합
  • Isomap
    • 각 샘플을 연결하여 그래프를 만들고, 샘플 간의 지오데식 거리(최단 경로 거리)를 보존하며 차원을 축소
    • 그래프 기반 접근법으로, 노드 간 최단 경로를 계산해 저차원 공간으로 맵핑
  • t-SNE (t-distributed stochastic neighbor embedding)
    • 비슷한 샘플은 가깝게, 비슷하지 않은 샘플은 멀리 배치하면서 차원을 축소
    • 시각화에 주로 사용되며, 고차원 데이터의 클러스터링 구조를 효과적으로 보여줌.
    • MNIST 데이터셋 같은 복잡한 데이터의 2D 시각화에 특히 유용
  • 선형 판별 분석 (LDA)
    • 클래스 사이를 가장 잘 구분하는 축을 찾는 선형 알고리즘
    • 클래스 라벨이 제공될 때만 사용할 수 있으며, 투영을 통해 클래스 간 거리를 최대로 유지
    • 차원 축소 후 분류 문제에 효과적으로 적용