특징 추출을 위한 Auto-encoder와 주성분 분석(PCA)

주어진 데이터 \(X\)가 고차원이어서(변수의 개수가 많음) 변수(또는 특징)들 끼리의 관계를 한눈에 보기 어렵거나 데이터의 특징이 직관적으로 와닫지 않는 경우가 많다. 데이터의 특징을 잘 표현하면서 처리하기 쉬운 저차원의 벡터로 변환(transform)하는 기법이 특징 추출(feature extraction)이다. 즉, 데이터 변환을 통해서 새로운 변수를 만드는 방법이다(새로 만든 변수를 사용해서 모델 제작). 특징 추출의 방법으로 주로 쓰이는 Auto-encoder와 주성분 분석(Principal component analysis, PCA)에 대해 알아보자.

특징 추출 & 특징 선택

특징 추출과 유사한 차원축소(dimensionality reduction) 기법으로는 특징 선택(feature selection)이 있다. 특징 추출이 모든 독립변수(특징)을 사용하여 변수를 변환해 새로운 변수를 만드는 것이라면 특징 선택은 유의미한 변수들을 남기고 불필요한 특징들을 제거하는 기법이다. 즉, 주어진 독립변수에서 문제를 해결하는데 유의미한 변수(특징)를 찾는 방법이다.

예를 들어서 학생들의 기말고사 성적을 예측하는 모델에서 중간고사, 퀴즈1, 퀴즈2, 학생의 키 4개 독립변수를 사용했다고 가정하자. 4개 변수 모두를 사용해서 기말고사 성적을 예측하는 모델을 만들고 변수를 한개씩 제거해서 만든 모델과 비교해서 유의미하지 않은 변수를 제거한다. 아마 위 경우 학생들의 키는 기말고사 성적과 연관성이 없어 보이기 때문에 제거될 확률이 높다.

특징 선택 방법 중에서 Variance Threshold를 소개하면 이 알고리즘은 \(X\)의 변수들 중에서 분산이 작은 변수를 제거하는 방식이다. 변수의 분산이 상대적으로 작다고 유의미하지 않다고는 할 수 없다. 하지만 분산이 0에 가까운 변수들은 모든 데이터들이 유사하거나 동일한 값을 갖기 때문에 Variance Threshold를 통한 특징 선택은 의미가 있다.

데이터 구조

우리은 다루고자 하는 데이터 \(X\)는 \(d\)차원 벡터가 \(N\)개 있는 \(N\times d\) 행렬 (2차원 array)이다(일반적으로 Python으로 데이터를 분석하는 경우 데이터를 이런 모양으로 구성한다). 여기서 데이터의 개수 \(N\)은 차원 \(d\)보다 많다고 가정한다(\(N>d\)).

주성분 분석

PCA는 특징 추출(feature extraction) 기법 중에서 널리 쓰이는 방법으로 변수들의 분산(variance)을 최대로 하는 \(R^d\)의 기저를 찾아서 선형변환(linear transform)을 통해 새로운 변수를 생성한다. 그리고 생성된 새로운 각 변수들의 분산값들을 고려해서 분산이 작은 변수를 제거하는 기법이다.

\(\mathbf x \in R^d\)이고 \(\{ \mathbf v_1, \ldots, \mathbf v_d \}\)이 \(R^d\)의 orthonormal basis라고 하자. 즉, \(\mathbf v_i \in R^d\) 이고

\[\langle \mathbf v_i, \mathbf v_j \rangle = \delta_{ij} =\begin{cases} 1 \quad \text{if } i=j\\ 0 \quad \text{otherwise} \end{cases}\]

을 만족한다. \(\mathbf x\)를 \(\{ \mathbf v_1, \ldots, \mathbf v_d \}\)으로 표현하면 다음과 같다.

\[\underbrace{\langle \mathbf x, \mathbf v_1 \rangle}_{\text{첫 번째 성분}} \mathbf v_1 + \underbrace{\langle \mathbf x, \mathbf v_2 \rangle}_{\text{두 번째 성분}} \mathbf v_2 + \, \cdots\, + \langle \mathbf x, \mathbf v_d \rangle \mathbf v_d\]

데이터 \(X\)는 \(N\times d\)행렬이기 때문에 \(X\)의 orthonormal basis 열벡터로 구성된 \(d\times d\) 행렬 \(V=[\mathbf v_1, \ldots, \mathbf v_d]\)에 대한 선형변환

\[Z:= XV\]

변수(열 벡터)들의 분산이 최대가 되는 \(V\)를 찾아야 한다(\(\{ \mathbf v_1, \ldots, \mathbf v_d \}\)은 \(R^d\)의 orthonormal basis). 여기서 cov\((X)\) 계산 편의를 위해 데이터 \(X\)의 각 열의 평균은 0이라고 가정한다. 평균이 0이 아닌 경우에는 아래 코드와 같이 평균을 0으로 맞출 수 있다.

X = np.array([[1,2,3],
[2,3,4],
[3,4,5],
[1,2,5]])

X = X - X.mean(axis=0)
\[X = \left(\begin{array}{cc} 1& 2 & 3\\ 2 & 3& 4 \\3 &4 & 5 \\ 1 & 2 & 5 \end{array}\right) \Longrightarrow \left(\begin{array}{cc} -0.75 & -0.75 & -1.25\\ 0.25 & 0.25 & -0.25 \\1.25 &1.25 & 0.75 \\ -0.75 & -0.75 & 0.75 \end{array}\right)\]

다시 본론으로 돌아와서 \(Z=XV\)의 각 열들의 분산을 최대로 만드는 \(V\)를 찾는 방법을 살펴보자. Orthonormal basis 중 한 벡터(\(V\)의 열 벡터)인 \(d\times 1\) 행렬을 \(\mathbf v\)라고 하면 \(\mathbf v\)는 다음을 만족한다.

\[\begin{equation} \langle \mathbf v,\mathbf v\rangle = \mathbf v ^T \mathbf v = 1. \end{equation}\]

위 식을 만족하는 \(\mathbf v\) 중에서

\[\begin{equation} \text{Var}(X \mathbf v)= \mathbf v^T \text{Cov}(X) \mathbf v = \mathbf v^T \left( \frac{1}{N-1} X ^ T X \right) \mathbf v \end{equation}\]

값이 최대가 되는 \(\mathbf v\)는 라그랑즈 승수법(method of Lagrange multipliers)을 이용하여 찾을 수 있다. \(\mathbf v\neq \mathbf 0\)이므로 식(1)과 (2)의 \(\mathbf v\)에 대한 gradient들이 평행(parallel)이 되는 즉, 다음 등식이 성립하는 \(\lambda \in R\)과 \(\mathbf v\)를 찾는 것이다.

\[\begin{equation} \lambda \mathbf v = \text{Cov}(X) \mathbf v. \end{equation}\]

또한, (1), (2), (3)을 결합하면

\[\text{Var}(X\mathbf v) = \mathbf v^T \text{Cov}(X) \mathbf v = \mathbf v^T (\lambda \mathbf v) = \lambda\]

을 만족한다. 결론적으로 데이터 \(X\)의 선형변환으로 부터 얻은 새로운 데이터 \(Z=XV\) 변수들의 분산을 최대로 하는 \(V =[ \mathbf v_1, \ldots, \mathbf v_d ]\)의 열벡터들은 Cov(\(X\))의 eigen vevtor들이고 \(N\)차원 벡터 \(X \mathbf v\)의 분산은 eigen vector \(\mathbf v\)에 대응하는 eigen value \(\lambda\)이다.

결론적으로 선형변환으로 얻은 \(Z=XV\)의 \(d\)개 변수 중에서 분산값들이 큰 변수들 \(p\)개만 특징 선택해서 새로운 변수로 사용하는 것이 주성분 분석 PCA다.

특이값 분해(Singular Value Decomposition, SVD)

Cov\((X) = \left( \frac{1}{N-1} X ^ T X \right)\)의 eigen vector들인 \(V\)는 특이값 분해(SVD)를 이용해서 찾을 수 있다. 특이값 분해란 \(N\times d\) 행렬 \(A\)를 다음과 같이 분해하는 것이다.

\[A = U \Sigma V^T\]

  • \(U = [\mathbf u_1, \ldots, \mathbf u_N]\), \(\mathbf u_i \in R^N\)은 \(N \times N\) 행렬이고 \(V = [\mathbf v_1, \ldots, \mathbf v_d]\), \(\mathbf v_i \in R^d\)은 \(d \times d\) 행렬로 다음을 만족한다.
\[U^T U = I_N, \quad V^T V = I_d\]
  • \(\Sigma\)는 \(N\times d\) 행렬로 \(A^T A\)의 \(d\)개의 eigen value \(\lambda_1 \geq \lambda_{2} \cdots \geq \lambda_d \geq 0\)들의 제곱근을 대각성분으로 갖는 \(d \times d\) 대각행렬(diagonal matrix)과 모든 원소가 0 인 \((N-d)\times d\)행렬의 행 결합(row bind)으로 구성 되어있다.
X = np.array([[1,2,3],
[2,3,4],
[3,4,5],
[1,2,5]])

def SVD(x):
    x = x - x.mean(axis=0)  #각 열의 평균을 0으로 조정
    U, s, Vt = np.linalg.svd(x) #Numpy에서 제공하는 SVD 함수 
    st= np.diag(s)
    ss = np.concatenate((st, np.zeros([x.shape[0]-x.shape[1],x.shape[1]])), axis=0)
    return U, ss, Vt

SVD(X)

위 코드를 실행하면 \(X\)를 아래와 같이 분해할 수 있다(소숫점 3자리에서 반올림).

\[\begin{align*} &\tiny{\underbrace{\left(\begin{array}{cc} -0.75 & -0.75 & -1.25\\ 0.25 & 0.25 & -0.25 \\1.25 &1.25 & 0.75 \\ -0.75 & -0.75 & 0.75 \end{array}\right)}_{X}} \\ & \\ &= \tiny{\underbrace{\left(\begin{array}{cc} -0.595& 0.478& -0.645 & 0. \\ 0.082& 0.277& 0.129 & 0.949 \\ 0.760& 0.075& -0.645 & 0. \\ -0.247& -0.830& -0.387 & 0.316 \end{array}\right)}_{U}\, \underbrace{\left(\begin{array}{cc} 2.523 & -0 & 0\\ 0 & 1.373 & 0 \\0 &0 & 0 \\ 0 & 0 & 0 \end{array}\right)}_{\Sigma}\, \underbrace{\left(\begin{array}{cc} 0.635& 0.635& 0.439\\ 0.311& 0.311& -0.898 \\ -0.707& 0.707& 0. \end{array}\right)}_{V^T}} \end{align*}\]

Remark of PCA

  • PCA는 \(X\)의 선형변환 \(Z=XV\) 변수들의 분산이 최대가 되도록 하는 \(V\)를 찾고 분산이 큰 변수들만 선택해서 차원축소하는 기법이다. 여기서 \(V\)는 \(X^T X\)의 eigen vector 열 벡터로 구성된 행렬 \(V=[\mathbf v_1, \ldots, \mathbf v_d]\) 이다. 이 벡터들을 주성분이라고 부르고 각 \(\mathbf v_i\)에 대응하는 eigen value \(\lambda_i\)들은 다음과 같이 크기 순으로 배열한다.
\[\lambda_1 \geq \lambda_2 \geq \cdots \geq \lambda_d \geq 0.\]
  • \(Z=XV\) 분산은 eigen value \((\lambda_1, \lambda_2, \ldots, \lambda_d)\)와 비례하기 때문에 주성분의 개수 \(p\)를 선택할 때 앞에서 부터 \(p\)개를 선택하면 된다.
  • Scikit-learn의 PCA 함수를 사용하면 각 열들의 평균을 0으로 조정할 필요없이 \(X\)데이터로 부터 PCA의 결과를 얻을 수 있다.
from sklearn.decomposition import PCA
  • 데이터가 선형으로 분리되지 않은 경우 PCA를 이용해서 데이터의 차원을 낮추게 되는 경우 원래 데이터가 갖고 있는 특성을 잃어 버릴 수 있다. 이럴 때는 비선형 kernel PCA를 사용하거나 Auto-encoder를 사용해야 한다.

Auto-encoder

Auto-encoder는 특징 추출을 목적으로 주어진 데이터를 변환하는 비지도 학습(unsupervised) 방식의 딥러닝 알고리즘이다. 이미지 처럼 쉽게 눈에 보이는 형태로 나타낼 수 없어서 쉽게 특징을 찾기 어려울 때 주로 사용하는 방법이다. 즉, 변환된 데이터를 이용해서 데이터에서 찾기 어려웠던 단서를 찾기 위해 사용한다. 또한 학습이 잘 진행되지 않는 분류나 예측 문제에서 parameter들의 초깃값 설정을 위한 fine tuning에도 활용될 수 있다. Auto-encoder 모델은 다음과 같이 encoder와 decoder로 구성되어있다(encoder와 decoder map에 히든 레이어를 원하는 만큼 넣을 수 있다).

\[X \underbrace{\longrightarrow}_{encoder} L \underbrace{\longrightarrow}_{decoder} H\]
  • \(L\): 주어진 데이터 \(X\)의 변수(특징)들을 변환해서 얻은 새로운 변수
  • \(H\): 모델의 예측값으로 독립변수 \(X\)와 동일한 값으로 학습

비지도 학습(unsupervised learning)으로 종속변수 \(Y\)값은 모델 학습에 필요하지 않고 \(X\)값만으로 모델을 제작하는 것이 회귀분석(regression) 예측 모델과의 큰 차이점이다. 모델의 예측값 \(H\)가 \(X\)값과 가까워지도록 학습을 진행하기 때문에 \(X\)에서 encoder와 decoder 합성이 항등함수(identity function)가 되도록 하는 것이 학습의 목표다.

Cost function

Auto-encoder의 cost function은 최종 레이어인 \(H\)와 입력값 \(X\)와의 오차제곱평균 MSE로 정의한다.

\[\frac{1}{N}\sum_{i=1}^{N}\left ( H_i - X_i \right )^2\]

데이터의 개수는 \(N\)개이고 \(X_i\)와 \(H_i:=H(X_i)\)는 \(i\)번 째 입력과 예측값이다. 여기에 과적합(overfitting)을 피하기 위해 다음과 같이 regularization term을 추가할 수도 있다.

\[\frac{1}{N}\sum_{i=1}^{N}\left ( H_i - X_i \right )^2 + \lambda \sum_{weight} W^2\]

Encoder를 통해서 변환된 새로운 변수 \(L\)의 노드의 수(차원 수)는 일반적으로 \(X\)의 차원보다 작게해서 데이터 차원을 축소한다. 하지만 저차원 벡터로 데이터의 특징을 찾기 어렵거나 관계가 명확하게 드러나지 않는 경우 \(L\)의 차원을 \(X\)의 차원보다 크게 하는 경우도 있다.

Remark of Auto-encoder

  • Encoder와 decoder의 자유롭게 구성할 수 있다. \(X\)가 이미지 데이터인 경우 encoder에서 convolution 연산을 하고 decoder에서 deconvolution연산을 할 수 있다. 이미지 데이터가 아닌 경우 encoder와 decoder는 MLP로 구성할 수 있다. 일반적으로 encoder와 decoder의 구성은 대칭으로 한다(경험상 대칭인 경우 학습 진행이 잘 됨).
  • Encoder에 의해서 \(X\)가 \(L\)로 변환되고 다시 \(L\)은 decoder에 의해서 \(H\)로 변환되어 구분하지만 엄밀히 말하면 이 구분은 명확하지 않다. \(X\)의 특징을 찾기 위한 새로운 변수 \(L\)은 그냥(just) 항등함수를 모델링하는 하나의 히든레이어다. 쉽게 말해서 아래와 같은 레이어를 구성하고 \(H\)를 \(X\)와 동일한 값이 나오도록 auto-encoder와 같이 학습을 진행한다고 하자.
\[X \longrightarrow H0 \longrightarrow H1 \longrightarrow H2 \longrightarrow H3 \longrightarrow H4 \longrightarrow H\]

대칭으로 모델을 구성하지 않았다라고 한다면 \(H1\)이 \(L\)이 될 수도 있고 \(H2\)가 \(L\)이 될 수도 있다. 즉, 앞에서 다룬 PCA처럼 \(L\)이 수학적으로 의미가 부여되지 않을 수 있다. PCA에서 \(L\)은 데이터 \(X\)의 분산을 최대로 보존하도록 찾은 직교 기저(basis)로 부터 얻은 성분들이다.

  • 다음과 같이 encoder와 decoder에 hidden layer가 없는 경우
\[X \longrightarrow L \longrightarrow H\]

\(L\)은 PCA 결과와 유사하게 선형분리의 효과만 얻을 수 있다. 아래 그림은 Iris 데이터 4차원의 \(X\)데이터를 PCA와 auto-encoder를 이용하여 2차원으로 차원축소한 결과를 보여준다.

from sklearn.datasets import load_iris
iris = load_iris()
x_data = iris.data[:,:]

Auto-encoer는 위와 같이 encoder와 decoder에 hidden layer가 없는 형태이고 \(L\)의 activation 함수로 항등함수로 주었다. Weight와 bias들의 초기값에 의해서 조금 차이를 보이지만 PCA결과와 유사한 결과를 보여준다.

참고: Auto-association by multilayer perceptrons and singular value decomposition, PCA vs Autoencoders

  • 예측값 \(H\)가 \(X\)와 얼마나 비슷한지 확인해서 학습이 잘 됬는지 여부를 판단한다. 이미지 데이터인 경우 원본이미지와 얼마나 가까운 이미지가 생성됬는지 확인하여 판단하기 때문에 일반적으로 정량적인 지표로 학습 여부를 확인하기 어렵다.

간단한 예를 살펴보자. 아래 \(X\)는 2차원 데이터로 산포도를 그리면 다음과 같다. 데이터가 고차원이 아니어서 굳이 PCA를 적용해서 차원축소 할 필요는 없다. 하지만 PCA를 이용해서 특징 추출을 하게 되면 원래 데이터의 특징을 놓칠 수 있다는 것을 보여주는 좋은 예다. 즉, 산포도에서 보는 것 처럼 직선으로 두 집단을 분리하기 어렵다.

PCA를 적용해서 1차원의 데이터로 차원을 축소해서 점을 찍으면 아래 그림의 위 그래프 처럼 표현된다. 파란색 점과 녹색점이 어느 정도 겹쳐지는지 확인하기 위해서 집단(class)을 기준으로 분리했다. 아래 그림과 같이 선형 분리가 되지 않는 데이터를 PCA를 이용해서 차원축소하게 되면 원래 데이터의 특징을 잃어버리게 된다.

동일한 데이터를 비선형 kernel 방식인 auto-encoder에 적용해서 1차원으로 차원축소하게 되면 다음과 같은 결과를 얻는다. 신기하게 데이터의 라벨값을 지정하지 않았는데도 불구하고 클래스를 고려한 것 처럼 정확하게 데이터가 분리 되었다.

TensorFlow code

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import tensorflow as tf
from sklearn.decomposition import PCA
from sklearn.datasets import make_moons

%matplotlib inline

#Moon 데이터 생성
x_moon, y_moon = make_moons(n_samples=1000, shuffle=True, noise=0.05, random_state=10)

plt.scatter(x_moon[y_moon == 0, 0], x_moon[y_moon == 0, 1], label="Class 0", alpha=0.7)
plt.scatter(x_moon[y_moon == 1, 0], x_moon[y_moon == 1, 1], label="Class 1", alpha=0.7)
plt.legend()

#PCA를 이용해서 1차원으로 축소
X2 = PCA(1).fit_transform(x_moon)

plt.figure(figsize=(8,6))
plt.subplot(211)
plt.title("PCA")
plt.scatter(X2[y_moon == 0, 0], np.zeros(500), label="Class 0", alpha=0.5)
plt.scatter(X2[y_moon == 1, 0], np.zeros(500), label="Class 1", alpha=0.5)
plt.subplot(212)
plt.title("PCA")
plt.scatter(X2[y_moon == 0, 0], np.zeros(500), label="Class 0", alpha=0.5)
plt.scatter(X2[y_moon == 1, 0], np.ones(500), label="Class 1", alpha=0.5)
plt.legend()

#Auto-encoder 적용

dim = 1
tf.set_random_seed(100)

X = tf.placeholder(shape=[None,2], dtype=tf.float32)


H0 = tf.layers.dense(X, 8, activation=tf.nn.sigmoid)
encoder = tf.layers.dense(H0, dim, activation=tf.nn.sigmoid)
H_1 = tf.layers.dense(encoder, 8, activation=tf.nn.sigmoid)
H = tf.layers.dense(H_1, np.shape(x_moon)[1], activation=tf.identity)


loss = tf.reduce_mean(tf.square(X - H))
optimizer = tf.train.AdamOptimizer(0.01).minimize(loss)

init = tf.global_variables_initializer()
sess = tf.Session()
sess.run(init)

iteration = 10000

for i in range(iteration):
    _, cost = sess.run([optimizer, loss], feed_dict={X: x_moon})


X2 = PCA(dim).fit_transform(x_moon)
X2_auto = sess.run(encoder, feed_dict={X: x_moon})

plt.figure(figsize=(16,12))
plt.subplot(221)
plt.title("PCA")
plt.scatter(X2[y_moon == 0, 0], np.zeros(500), label="Class 0", alpha=0.5)
plt.scatter(X2[y_moon == 1, 0], np.ones(500), label="Class 1", alpha=0.5)
plt.legend()


plt.subplot(222)
plt.title("Autoencoder")
plt.scatter(X2_auto[y_moon == 0, 0], np.zeros(500), label="Class 0", alpha=0.5)
plt.scatter(X2_auto[y_moon == 1, 0], np.ones(500), label="Class 1", alpha=0.5)

plt.legend()