선형대수학

[R] PCA(주성분분석) 모티베이션,증명,실습,해석방법

JSMATH 2025. 4. 7. 03:37

PCA : Principal Component Analysis

머신러닝,통계적 학습,딥러닝에서까지 자주 등장하는 기법이다.

다들 파이썬으로  sklearn.decomposition.PCA() 이런 함수로 그냥 구하고는 한다. 자매품으로 R에서는 prcomp()라는 함수가 있다.   물론, 이 방식이 틀린 방식이라고 말하는게 절대 아니다.

말의 요지는 데이터 전처리를 할 때 그냥 위와 같이하면 편하다.

솔직히 이 글을 쓰고 있는 사람도 prcomp()으로 그냥 쓴다.

하지만 알고는 써야하지 않을까??  

다행스럽게도, 그렇게 깊은 선형대수적 지식을 요구하지 않는다. 이참에 확실히 배워두고 가면 좋을 것 같다.

 

흔히 분산을 최대화하는 문제로 잘 알려져있는 PCA를 A~Z까지 설명 할 것이고 이를 통한 실습코드도 준비해두었다.

다음은 이 글을 읽는데 필요한 선수지식이다.

1. 선형대수의 벡터 정사영

2. 최적근사정리 (정사영 벡터를 이용한 오차벡터가 가장 작다는 것)

2. 벡터 미분

3. 라그랑주 승수법 

 

 

여기서 중요한 가정은  이미 센터링(평균=0)이라는 점이다. 

이론적 증명은 이정도로 마친다.

 

다음은 R코드로 이론적으로 전개되는 코드와 ,prcomp()함수를 쓰는 딸깍(?) 코드를 소개하겠다.

#install.package(palmerpenguins)
library(palmerpenguins)

data("penguins")

# 사용할 변수: 부리 길이, 부리 깊이, 지느러미 길이, 몸무게
penguins_numeric <- penguins[, c("bill_length_mm", "bill_depth_mm", "flipper_length_mm", "body_mass_g")]

# 결측치 제거
penguins_numeric <- na.omit(penguins_numeric)

# 데이터 중심화 및 스케일링 (평균 0, 표준편차 1)
X <- scale(penguins_numeric, center = TRUE, scale = TRUE)

# 공분산 행렬 계산
cov_mat <- cov(X) #smallsum = ({1}over{n})*(t(X)%*%X) R에서 cov(X)는 표본공분산이라 (n-1)을 나눔.
cat("공분산 행렬:\n")
print(cov_mat)

# 고유값분해 수행 (대칭행렬이므로 eigen() 사용 가능)
eigen_decomp <- eigen(cov_mat) #smallsum(cov(X))의 고유값 lambda를 이용.
eigen_values <- eigen_decomp$values
eigen_vectors <- eigen_decomp$vectors

cat("\n고유값:", eigen_values)

cat("\n고유벡터 (각 열이 한 고유벡터):")
print(matrix(c(eigen_vectors),byrow=F,ncol=4))


# 주성분 점수(PC scores) 계산: 각 샘플을 고유벡터(주성분) 축으로 투영
PC_scores <- X %*% eigen_vectors
X %>% head()

PC_scores %>% head()
# 각 주성분이 설명하는 분산 비율 계산
explained_variance <- eigen_values / sum(eigen_values)
cat("\n각 주성분의 분산 설명 비율:\n",explained_variance)


# PC1과 PC2를 이용한 산점도
plot(PC_scores[,1], PC_scores[,2],
     main = "PCA on Palmer Penguins Data",
     xlab = paste0("PC1 (", round(explained_variance[1]*100, 1), "%)"),
     ylab = paste0("PC2 (", round(explained_variance[2]*100, 1), "%)"),
     pch = 19, col = "steelblue")

이론적 코드

library(palmerpenguins)
library(magrittr)
data("penguins")

penguins_num <- penguins[,3:6]

penguins_num %<>% na.omit() 

# PCA : prcomp() 함수 이용, 변수 스케일링 포함(scale. = TRUE)
pca_result <- prcomp(penguins_num, scale. = TRUE)
pca_result %>% summary()

# 주성분 점수를 이용하여 1,2번째 주성분 산점도 그리기
plot(pca_result$x[, 1:2],
     main = "prcomp() : PCA of Palmer Penguins Data ",
     xlab = "PC1",
     ylab = "PC2",
     pch = 19, col = "steelblue")

*분산비율 해석 

> pca_result %>% summary()
Importance of components:
                          PC1    PC2     PC3     PC4
Standard deviation     1.6594 0.8789 0.60435 0.32938
Proportion of Variance 0.6884 0.1931 0.09131 0.02712
Cumulative Proportion  0.6884 0.8816 0.97288 1.00000

#위에서의 proportion of variance와 아래를 비교해보자.

> cat("\n각 주성분의 분산 설명 비율:\n",explained_variance)

각 주성분의 분산 설명 비율:
 0.6884388 0.1931292 0.09130898 0.02712305

 

해석 : PC1만으로 전체데이터의 약 68% 설명. PC2만으로는 약 19%설명이 가능하다.

 

*그럼 무엇을 가지고 PCA를 써야 할 지 생각을 해야 할까?

library(corrplot)
corrplot(cov_mat, method = "color", is.corr = FALSE)

부리(bill) 길이와 몸 질량,  펭귄지느러미(flipper) 길이와 몸 질량,.. 이런식으로 강한 상관관계가 나타난다. 이들을 묶어서 차원을 줄이려고 할 때 쓴다.

제일 중요한 점은 PC1,PC2 무슨 스코어를 매겼는데, 이들이 의미하는 바가 무엇인지 아직 밝히지 않았다.

 

 

*PC1,PC2 의미와 투영벡터(고유벡터)의 의미

PC1 : 분산이 가장 큰 고유벡터를 이용 ->  Xw1 (w1은 람다값 중 최댓값에 대응하는 고유벡터)

eigen_vectors[,1]은 람다값 중 최댓값에 대응하는 고유벡터 즉, w1에 해당한다.  이를 기여도(loading)이라고 표현한다.

X의 컬럼을 c1,c2,c3,c4로 할 때 w11*c1+w12*c2+w13*c3+w14*c4 이런식으로 열공간으로 나타나기 때문이라고 생각한다.

w11*(bill_length) + w12*(bill_depth) + w13*(flipper_length) +w14*(body_mass) 흔히 선형회귀의 beta값들과 같은 역할을 하는 가중치느낌이라고 본다.

 

eigen_vectors

           [,1]         [,2]       [,3]       [,4]
[1,]  0.4552503 -0.597031143  0.6443012  0.1455231  ← bill_length_mm
[2,] -0.4003347 -0.797766572 -0.4184272 -0.1679860  ← bill_depth_mm
[3,]  0.5760133 -0.002282201 -0.2320840 -0.7837987  ← flipper_length_mm
[4,]  0.5483502 -0.084362920 -0.5966001  0.5798821  ← body_mass_g

 

> eigen_vectors[,1]
[1]  0.4552503 -0.4003347  0.5760133  0.5483502

이를 표로 한번 나타내보자.

변수 기여도(loading) 해석
bill_length_mm +0.455 좀 큼
bill_depth_mm −0.400 - 중에서는 큼
flipper_length_mm +0.576 + 중에서는 제일 큼
body_mass_g +0.548 좀 많이 큼

 

사실대로 적었다.

이를 볼 때 PC1은 전반적으로 큰 펭귄이고 부리 깊이가 작아진다는 것을 나타내는 축으로 볼 수 있겠다. 아래는 위에 내용에 대한 시각화다.

barplot(eigen_vectors[,1],
        names.arg = colnames(penguins_numeric),
        main = "PC1 Loadings",
        col = "skyblue")

 

> eigen_vectors[,2]
[1] -0.597031143 -0.797766572 -0.002282201 -0.084362920

이를 표로 한번 나타내보자.

변수 기여도(loading) 해석
bill_length_mm −0.597 - 중에서 좀 많이 큼
bill_depth_mm −0.798 - 중에서 제일 큼
flipper_length_mm −0.002 영향력 적음
body_mass_g −0.084 영향력 적음

PC2는 부리(bill)과 관련된 축으로 알 수 있다. 아래의 그래프는 위에 대한 시각화코드다.

barplot(eigen_vectors[,2],
        names.arg = colnames(penguins_numeric),
        main = "PC2 Loadings",
        col = "orange")

*고유값 해석

> eigen_values
[1] 2.7537551 0.7725168 0.3652359 0.1084922

위의 고유값은 각 분산을 뜻한다. 제일 큰 분산을 담당하는 고유값이 2.7이다.

 

*PC_score 해석

PC_scores <- X %*% eigen_vectors
PC_scores[1,]
# [1] -1.840748 -0.04763243 -0.2324536 0.5231365

행렬 X의 행은 각 하나의 펭귄을 뜻한다. 5행은 5번째 펭귄 이런식이다. 

아래는 X의 출처다. 

# 사용할 변수: 부리 길이, 부리 깊이, 지느러미 길이, 몸무게
penguins_numeric <- penguins[, c("bill_length_mm", "bill_depth_mm", "flipper_length_mm", "body_mass_g")]

# 결측치 제거
penguins_numeric <- na.omit(penguins_numeric)

# 데이터 중심화 및 스케일링 (평균 0, 표준편차 1)
X <- scale(penguins_numeric, center = TRUE, scale = TRUE)

아무튼, PC_socres[1,]1번째 펭귄이 각 스코어별로 어떤 점수(스코어별 고유벡터로 투영된 거리)를 가졌는지에 대한 내용이다.

1번째 펭귄은 PC1에 대해서 -1.84, PC2에 대해서는 -0.04, PC3에 대해서는 -0.23, PC4에 대해서는 0.523이다. 아~ 그러면 PC1하고 PC4가 여기선 중요하구나! 라고 할 수 있지만.. 우리는 앞서 PC4의 분산설명비율이 2.7%임을 보았다.  제아무리 0.523이여도 전체 분산설명비율이 2.7%면 의미없다.

그렇다면 0.027*0.523으로 의미있는 값이 나올 수 있을까? 예로들어 사과1개에 500g인데 4개면? 4(개)*500(g) 이런식으로 말이다. 결론은 곱할 수는 있으나.. 우리가 원하는 해석(분산기여도,정보량)은 아니다. PCA의 본질은 "분산"이므로 정보량을 따지려면 제곱으로 해야한다.

PC4에서 1번째 샘플의 기여도 = PC4_{i4}^2/sum_{i=1}^{n} {PC_k4 ^2}

> # PC score의 4번째 주성분(PC4) 점수 벡터
> PC_scores %>% head()
     bill_length_mm bill_depth_mm flipper_length_mm body_mass_g
[1,]      -1.840748   -0.04763243        -0.2324536   0.5231365
[2,]      -1.304850    0.42772154        -0.0295191   0.4018377
[3,]      -1.367178    0.15425039         0.1983816  -0.5272343
[4,]      -1.876078    0.00204541        -0.6176912  -0.4776785
[5,]      -1.908951   -0.82799642        -0.6855795  -0.2071241
[6,]      -1.760446    0.35096537         0.0276311   0.5039784
> pc4_scores <- PC_scores[, 4]
> 
> # i번째 (예: 1번째) 펭귄의 기여도
> i <- 1
> individual_contribution <- (pc4_scores[i])^2 / sum(pc4_scores^2)
> individual_contribution
[1] 0.007397365

즉, 첫번째 펭귄이 PC4축에 0.7%기여했음을 알 수 있다. 다음은 더 나아가 상위 6명까지 PC4에 얼마나 기여하는지 확인해보자.

# PC4 점수
pc4_scores <- PC_scores[, 4]

# 상위 6개 샘플의 PC4 기여도 계산
contributions <- (pc4_scores[1:6])^2 /  sum(pc4_scores^2)
contributions
> contributions
[1] 0.007397365 0.004364639 0.007513708 0.006167631 0.001159601 0.006865479

PC4 축에 첫 번째 펭귄이 0.7%,  두 번째 펭귄이 0.4% ,  세 번째 펭귄이 0.7%,  네번째 펭귄이 0.6%, .. 이런식으로 기여했음을 알 수 있다.

다만, 첫번째 펭귄이  PC4가 점수가 크고 고유값이 작다는 것은 이 개체가 튀는개체(outliner)라는 것으로 나중에 특이값 탐지나 잔차분석에 쓸 수는 있겠다.