혼자 공부하는 머신러닝 + 딥러닝 06-3 주성분 분석

손지호·2023년 8월 13일
0

차원과 차원 축소

과일 사진의 경우 10,000개의 픽셀이 있어 10,000개의 특성이 있다라고 했다. 이런 특성은 차원(dimension) 이라고도 부른다. 10,000개의 특성은 결국 10,000개의 차원이라는 건데 이 차원을 줄일 수 있다면 저장 공간을 크게 절약할 수 있다.
_+ 2차원 배열과 1차원 배열의 차원은 다른 건가요??
2차원 배열과 1차원 배열(벡터)에서 차원이랑 용어는 조금 다르게 사용함. 다차원 배열에서 차원은 배열의 축 개수가 된다. 가령 2차원 배열일 때는 행과 열이 차원이 되는 것. 하지만 1차원 배열, 즉 벡터일 경우에는 원소의 개수를 말한다.

_

차원 축소(dimensionality reduction) 알고리즘 다루어 보자. 3장에서 특성이 많으면 선형 모델의 성능이 높아지고 훈련 데이터에 쉽게 과대적합 된다는 것을 배웠다. 차원 축소는 데이터를 가장 잘 나타내는 일부 특성을 선택하여 데ㅣ터 크기를 줄이고 지도 학습 모데르이 성능을 향상시킬 수 있는 방법이다.
또한 줄어든 차원에서 다시 원본 차원으로 손실을 최대한 줄이면서 복원할 수도 있다. 이 절에서는 대표적인 차원 축소 알고리즘인 주성분 분석(prinicpal component analysis)을 배운다.


주성분 분석 소개

주성분 분석(PCA)은 데이터에 있는 분산이 큰 방향을 찾는 것으로 이해할 수 있다. 분산은 데이터가 널리 퍼져있는 정도를 말한다. 분산이 큰 방향이란 데이터를 잘 표현하는 어떤 벡터라고 생각할 수 있다.
<그림 1>과 같은 데이터는 x_1, x_2 2개의 특성이 있따. 대각선 방향으로 길게 늘어진 형태를 가지고 있다. 이 데이터에서 가장 분산이 큰 방향, 즉 가장 데이터의 분포를 가장 잘 표현하는 방항을 찾아보면 <그림 2>와 같다.
직관적을오 길게 늘어진 대각선 방향이 분산이 가장 크다고 할 수 있다. 위의 그림에서 화살표 위치는 큰 의미 없다. 오른쪽 위로 향하거나 왼쪽 아래로 향할 수도 있다. 중요한 것은 분산이 큰 방향을 찾는 것!

앞에서 찾은 직선이 원점에서 출발한다면 두 원소로 이루어진 벡터로 쓸 수 있다. (<그림 3> 참고) 이 벡터를 주성분(principal component)이라고 부른다. 이 주성분 벡터는 원본 데이터에 있는 어떤 방향이다. 따라서 주성분 벡터의 원소 개수는 원본 데이터셋에 있는 특성 개수와 같다. 하지만 원본 데이터는 주성분을 사용해 차원 줄일 수 있다. 예를 들면 <그림 4>와 같이 샘플 데이터 s(4,2)를 주성분에 직각으로 투영하면 1차원 데이터 p(4,5)를 만들 수 있다.

주성분은 원본 차원과 같고 주성분으로 바꾼 데이터는 차원이 줄어든다는 점 꼭 기억! 주성분이 가장 분산이 큰 방향이기 때문에 주성분에 투영하여 바꾼 데이터는 원본이 가지고 있는 특성을 가장 잘 나타내고 있을 것이다.
첫 번째 주성분을 찾은 다음 이 벡터에 수직이고 분산이 가장 큰 방향을 찾는다. 이 벡터가 두 번째 주성분이다. 여기서는 2차원이기 때문에 두 번째 주성분의 방향은 <그림 5>처럼 하나 뿐이다.


PCA 클래스

# 이전 절과 마찬가지로 과일 사진 데이터 다운로드 하여 넘파이 배열로 적재
!wget https://bit.ly/fruits_300_data -O fruits_300.npy
import numpy as np
fruits = np.load('fruits_300.npy')
fruits_2d = fruits.reshape(-1, 100*100)

사이킷런은 sklearn.decomposition 모듈 아래 PCA 클래스로 주성분 분석 알고리즘 제공한다. PCA 클래스의 객체를 만들 때 n_components 매개변수에 주서분의 개수를 지정해야 한다. k-평균과 마찬가지로 비지도 학습이기 때문에 fit() 메서드에 타깃값을 제공하지 않는다.

from sklearn.decomposition import PCA
pca = PCA(n_components=50)
pca.fit(fruits_2d)
>>> 
PCA
PCA(n_components=50)

# PCA 클래스가 찾은 주성분은 components_ 속성에 저장되어 있음
print(pca.components_.shape)
>>> (50, 10000)

ncomponents=50으로 지정했기 때문에 pca.components 배열의 첫 번째 차원이 50. 즉 50개의 주성분을 찾은 것! 두 번째 차원은 항상 원본 데이터의 특성 개수와 같은 10,000.
원본 데이터와 차원이 같으므로 주성분을 100 x 100 크기의 이미지처럼 출력해 볼 수 있다. 2절에서 사용했던 draw_fruits() 함수를 사용해서 이 주성분을 그림으로 그려보자.

draw_fruits(pca.components_.reshape(-1, 100, 100))


이 주성분은 원본 데이터에서 가장 분산이 큰 방향을 순서대로 나타낸 것. 한편으로는 데이터셋에 있는 어떤 특징을 잡아낸 것처럼 생각될 수도 있다.
주성분 찾았으므로 원본 데이터를 주성분에 투영하여 특성의 개수를 10,000개에서 50개로 줄일 수 있다. PCA의 transform() 메서드 사용해 원본 데이터의 차원을 50으로 줄여보자.

print(fruits_2d.shape)
>>> (300, 10000)

fruits_pca = pca.transform(fruits_2d)
print(fruits_pca.shape)
>>> (300, 50)

fruits_2d는 (300, 10000) 크기의 배열. 10,000개의 픽셀(특성)을 가진 300개의 이미지이다. 50개의 주성분을 찾은 PCA 모델을 사용해 이를 (300, 50) 크기의 배열로 변환했다. 이제 fruits_pca 배열은 50개의 특성을 가진 데이터이다.
데이터를 성공적으로 줄였다! 무려 1/200으로. fruits_2d 대신 fruits_pca를 저장한다면 훨씬 많은 공간을 줄일 수 있다. 데이터의 차원 줄였다면 다시 원상 복구도 가능할까??


원본 데이터 재구성

앞에서 10,000개의 특성을 50개로 줄였다. 이로 인해 어느 정도 손실이 발생할 수 밖에 없다. 하지만 최대한 분산이 큰 방향으로 데이터를 투영했기 때문에 원본 데이터를 상당 부분 재구성할 수 있다.
PCA 클래스는 이를 위해 inverse_transform() 메서드를 제공한다. 앞서 50개의 차원으로 축소한 fruits_pca 데이터를 전달해 10,000개의 특성을 복원해보자.

fruits_inverse = pca.inverse_transform(fruits_pca)
print(fruits_inverse.shape)
>>> (300, 10000)

예상대로 10,000개의 특성이 복원되었다. 이 데이터를 100 x 100 크기로 바꾸어 100개씩 나누어 출력해보자. 이 데이터는 순서대로 사과, 파인애플, 바나나를 100개씩 담고 있다.

fruits_reconstruct = fruits_inverse.reshape(-1, 100, 100)
for start in [0, 100, 200]:
    draw_fruits(fruits_reconstruct[start:start+100])
    print("\n")


거의 모든 과일이 잘 복원됨!! 일부 흐리고 번진 부분 있지만 50개의 특성을 10,000개로 늘린 것 감안하면 놀라운 일! 이 50개의 특성이 분산을 가장 잘 보존하도록 변환된 것이기 때문이다.
만약 주성분을 최대로 사용햇다면 완벽하게 원본 데이터 재구성할 수 있을 것. 그럼 50개의 특서은 얼마나 분산을 보존하고 있는걸까??


설명된 분산

주성분이 원본 데이터의 분산을 얼마나 잘 나타내는지 기록한 값을 설명된 분산(explained variance) 이라고 한다. PCA 클래스의 explainedvariance_ratio에 각 주성분의 설명된 분산 비율이 기록되어 있다. 당연히 첫 번째 주성분의 설명된 분산이 가장 크다. 이 분산 비율을 모두 더하면 50개의 주성분으로 표현하고 있는 총 분산 비율을 얻을 수 있다.

print(np.sum(pca.explained_variance_ratio_))
>>> 0.9215651897863715

92%가 넘는 분산을 유지하고 있다. 앞에서 50개의 특성에서 원본 데이터를 복원했을 때 원본 이미지의 품질이 높았던 이유를 여기서 찾을 수 있다! 설명된 분산의 비율을 그래프로 그려 보면 적절한 주성분의 개수를 찾는 데 도움이 된다. 맷플롯립의 plot() 함수로 설명된 분산을 그래프로 출력해보자.

plt.plot(pca.explained_variance_ratio_)


그래프 보면 처음 10개의 주성분이 대부분의 분산을 표현하고 있다. 그 다음부터는 각 주성분이 설명하고 있는 분산은 비교적 작다. 이번에는 PCA로 차원 축소된 데이터를 사용해 지도 학습 모델을 훈련해보자. 원본 데이터를 사용했을 때와 어떤 차이가 있는지 확인!


다른 알고리즘과 함꼐 사용하기

과일 사진 원본 데이터와 PCA로 축소한 데이터를 지도 학습에 적용해 보고 어떤 차이가 있는지 알아보자. 3개의 과일 사진을 분류해야 하므로 간단히 로지스틱 회귀 모델을 사용해보자. 먼저 사이킷런의 LogisticRegression 모델을 만들자.

from sklearn.linear_model import LogisticRegression
lr = LogisticRegression()

지도 학습 모델을 사용하려면 타깃값이 있어야 한다. 여기에서는 사과를 0, 파인애플을 1, 바나나를 2로 지정. 파이선 리스트와 정수를 곱하면 리스트 안의 원소를 정수만큼 반복한다. 이를 이용하면 100개의 0, 100개의 1, 100개 2로 이루어진 타깃 데이터를 손쉽게 만들 수 있다.

target = np.array([0] * 100 + [1] * 100 + [2] * 100)

# 원본 데이터 fruits_2d 사용.
# 로지스틱 회귀 모델에서 성능 가늠해 보기 위해 cross_validate()로 교차 검증 수행
from sklearn.model_selection import cross_validate
scores = cross_validate(lr, fruits_2d, target)
print(np.mean(scores['test_score']))
print(np.mean(scores['fit_time']))
>>> 0.9966666666666667
	0.973611307144165

교차 검증의 점수는 0.997 정도로 매우 높음. 특성이 10,000개나 되기 때문에 300개의 샘플에서는 금방 과대적합된 모델 만들기 쉬움. cross_validate() 함수가 반환하는 딕셔너리에는 fit_time 항목에 각 교차 검증 폴드의 훈련 시간이 기록되어 있다. 0.97초 정도 걸렷음! 이 값을 PCA로 축소한 fruits_pca를 사용했을 때와 비교.

scores = cross_validate(lr, fruits_pca, target)
print(np.mean(scores['test_score']))
print(np.mean(scores['fit_time']))
>>> 1.0
	0.032833099365234375

50개의 특성만 사용했는데도 정확도가 100%이고 훈련 시간은 0.03초로 20배 이상 감소! PCA로 훈련 데이터의 차원을 축소하면 저장 공간뿐만 아니라 머신러닝 모델의 훈련 속도도 높일 수 있다.
앞서 PCA 클래스를 사용할 때 n_components 매개변수에 주성분의 개수를 지정했다. 이 대신 원하는 설명된 분산의 비율을 입력할 수 도 있다. PCA 클래스는 지정된 비율에 도달할 때까지 자동으로 주성분을 찾는다.

# 설명된 분산의 50%에 달하는 주성분 찾도록 PCA 모델 만들어보자.
pca = PCA(n_components=0.5)
pca.fit(fruits_2d)
>>> PCA
	PCA(n_components=0.5)

# 몇 개의 주성분 찾았는지 확인
print(pca.n_components_)
>>> 2

단 2개! 2개의 특성만으로 원본 데이터에 있는 분산의 50%를 표현할 수 있다!

이 모델로 원본 데이터 변환. 주성분이 2개이므로 변환된 데이터의 크기는 (300, 2)가 될 것.

fruits_pca = pca.transform(fruits_2d)
print(fruits_pca.shape)
>>> (300, 2)

# 2개의 특성만 사용하고도 교차 검증의 결과가 좋을까??
scores = cross_validate(lr, fruits_pca, target)
print(np.mean(scores['test_score']))
print(np.mean(scores['fit_time']))
>>> 0.9933333333333334
	0.02255110740661621

+ 위 코드 입력하면 로지스틱 회귀 모델이 완전히 수렴하지 못했으니 반복 횟수 증가하라는 경고(Convergence Warning: lbfgs failed to converge)가 출력. 하지만 교차 검증의 결과가 추분히 좋기 때문에 무시해도 좋다!

2개의 특성을 사용했지만 99%의 정확도를 달성할 수 있다!
이번에는 차원 축소된 데이터를 사용해 k-평균 알고리즘으로 클러스터 찾아보기

from sklearn.cluster import KMeans
km = KMeans(n_clusters=3, random_state=42)
km.fit(fruits_pca)
print(np.unique(km.labels_, return_counts=True))
>>> (array([0, 1, 2], dtype=int32), array([110,  99,  91]))

fruits_pca로 찾은 클러스터는 각각 91개, 99개, 110개의 샘플을 포함하고 있다. 이는 2절에서 원본 데이터를 사용했을 때와 거의 비슷한 결과이다! KMeans가 찾은 레이블을 사용해 과일 이미지를 출력해보자.

for label in range(0, 3):
    draw_fruits(fruits[km.labels_ == label])
    print("\n")


2절에서 찾은 클러스터와 비슷하게 파인애플은 사과와 조금 혼돈되는 면이 있다. 몇 개의 사과가 파인애플 클러스터에 섞여 있다.

훈련 데이터의 차원을 줄이면 또 하나 얻을 수 있는 장점은 시각화이다. 3개 이하로 차원을 중리면 화면에 출력하기 비교적 쉽다. fruitspca 데이터는 2개의 특성이 있기 때문에 2차원으로 표현할 수 있다. 앞에서 찾은 km.labels를 사용해 클러스터벼로 나누어 산점도 그려보자.

for label in range(0, 3):
    data = fruits_pca[km.labels_ == label]
    plt.scatter(data[:,0], data[:,1])
plt.legend(['apple', 'banana', 'pineapple'])
plt.show()


각 클러스터의 산점도가 아주 잘 구분된다! 2개의 특성만을 사용했는데 로지스틱 회귀 모델의 교차 검증 점수가 99%에 달하는 이유를 알 수 있다!

이 그림을 보면 사과와 파인애플 클러스터의 경계가 가깝게 붙어 있다. 이 두 클러스터의 샘플은 몇 개가 혼동을 일으키기 쉬울 것이다. 데이터를 시각화하면 예상치 못한 통찰을 얻을 수 있다. 그런 면에서 차원 축소는 매우 유용한 도구 중 하나이다.


주성분 분석으로 차원 축소

대표적인 비지도 학습 문제 중 하나인 차원 축소에 대해 알아보았다. 차원 축소를 사용하면 데이터셋의 크기를 줄일 수 있고 비교적 시각화하기 쉽다. 또 차원 축소된 데이터를 지도 학습 알고리즘이나 다른 비지도 학습 알골즘에 재사용하여 성능 높이거나 훈련 속도 빠르게 만들 수 있다.
사이킷런의 PCA 클래스를 사용해 과일 사진 데이터의 특성을 50개로 크게 줄였다. 특성 개수는 작지만 변환된 데이터는 원본 데이터에 있는 분산의 90% 이상을 표현한다. 이를 설명된 분산이라고 부른다.
PCA 클래스는 자동으로 설명된 분산을 계산하여 제공해준다. 또한 주성분의 개수를 명시적으로 지정하는 대신 설명된 분산의 비율을 설정하여 원하는 비율만큼 주성분을 찾을 수 있다.
PCA 클래스는 벼놘된 데이터에서 원본 데이터를 복원하는 메서드도 제공한다. 변환된 데이터가 원본 데이터의 분산을 모두 유지하고 있지 않다면 완벽하게 복원되지 않는다. 하지만 적은 특성으로도 상당 부분의 디테일을 복원할 수 있다.




전체 코드 (출처 : https://bit.ly/hg-06-1)

!wget https://bit.ly/fruits_300_data -O fruits_300.npy

import numpy as np
fruits = np.load('fruits_300.npy')
fruits_2d = fruits.reshape(-1, 100*100)

from sklearn.decomposition import PCA
pca = PCA(n_components=50)
pca.fit(fruits_2d)

print(pca.components_.shape)

import matplotlib.pyplot as plt
def draw_fruits(arr, ratio=1):
    n = len(arr)    # n은 샘플 개수입니다
    # 한 줄에 10개씩 이미지를 그립니다. 샘플 개수를 10으로 나누어 전체 행 개수를 계산합니다.
    rows = int(np.ceil(n/10))
    # 행이 1개 이면 열 개수는 샘플 개수입니다. 그렇지 않으면 10개입니다.
    cols = n if rows < 2 else 10
    fig, axs = plt.subplots(rows, cols,
                            figsize=(cols*ratio, rows*ratio), squeeze=False)
    for i in range(rows):
        for j in range(cols):
            if i*10 + j < n:    # n 개까지만 그립니다.
                axs[i, j].imshow(arr[i*10 + j], cmap='gray_r')
            axs[i, j].axis('off')
    plt.show()
    
draw_fruits(pca.components_.reshape(-1, 100, 100))
print(fruits_2d.shape)

fruits_pca = pca.transform(fruits_2d)
print(fruits_pca.shape)

fruits_inverse = pca.inverse_transform(fruits_pca)
print(fruits_inverse.shape)

fruits_reconstruct = fruits_inverse.reshape(-1, 100, 100)
for start in [0, 100, 200]:
    draw_fruits(fruits_reconstruct[start:start+100])
    print("\n")
print(np.sum(pca.explained_variance_ratio_))

plt.plot(pca.explained_variance_ratio_)

from sklearn.linear_model import LogisticRegression
lr = LogisticRegression()

target = np.array([0] * 100 + [1] * 100 + [2] * 100)

from sklearn.model_selection import cross_validate
scores = cross_validate(lr, fruits_2d, target)
print(np.mean(scores['test_score']))
print(np.mean(scores['fit_time']))

scores = cross_validate(lr, fruits_pca, target)
print(np.mean(scores['test_score']))
print(np.mean(scores['fit_time']))

pca = PCA(n_components=0.5)
pca.fit(fruits_2d)

print(pca.n_components_)

fruits_pca = pca.transform(fruits_2d)
print(fruits_pca.shape)

scores = cross_validate(lr, fruits_pca, target)
print(np.mean(scores['test_score']))
print(np.mean(scores['fit_time']))

from sklearn.cluster import KMeans
km = KMeans(n_clusters=3, random_state=42)
km.fit(fruits_pca)
print(np.unique(km.labels_, return_counts=True))

for label in range(0, 3):
    draw_fruits(fruits[km.labels_ == label])
    print("\n")
    
for label in range(0, 3):
    data = fruits_pca[km.labels_ == label]
    plt.scatter(data[:,0], data[:,1])
plt.legend(['apple', 'banana', 'pineapple'])
plt.show()

정리

  • 차원 축소는 원본 데이터의 특성을 적은 수의 새로운 특성으로 변환하는 비지도 학습의 한 종류이다. 차원 축소는 저장 공간을 줄이고 시각화하기 쉽다. 또한 다른 알고리즘의 성능을 높일 수도 있다.
  • 주성분 분석은 차원 축소 알고리즘의 하나로 데이터에서 가장 분산이 큰 방향을 찾는 방법이다. 이런 방향을 주성분이라고 부른다. 원본 데이터를 주성분에 투영하여 새로운 특성을 만들 수 있다. 일반적으로 주성분은 원본 데이터에 있는 특성 개수보다 작다.
  • 설명된 분산은 주성분 분석에서 주성분이 얼마나 원본 데이터의 분산을 잘 나타내는지 기록한 것이다. 사이킷런의 PCA 클래스는 주성분 개수나 설명된 분산의 비율을 지정하여 주성분 분석을 수행할 수 있다.

핵심 패키지와 함수

scikit-learn

  • PCA : 주성분 분석을 수행하는 클래스.
    ncomponents는 주성분의 개수를 지정. 기본값은 None으로 샘플 개수와 특성 개수 중에 작은 것의 값을 사용.
    random_state에는 넘파이 난수 시드 값을 지정할 수 있다.
    components
    속성에는 훈련 세트에서 찾은 주성분이 저장된다.
    explainedvariance 속성에는 설명된 분산이 저장되고, explainedvariance_ratio에는 설명된 분산의 비율이 저장된다.
    inverse_trainsform() 메서드는 transform() 메서드로 차원을 축소시킨 데이터를 다시 원본 차원으로 복원한다.
profile
초보 중의 초보. 열심히 하고자 하는 햄스터!

0개의 댓글