[혼공머신]

[혼공머신] 5주차

해야지11 2025. 8. 10. 19:29

06-1 (군집 알고리즘)

 
타깃이 없을 때 사용하는 머신러닝 알고리즘을 비지도 학습이라고 한다.
 
먼저 과일 사진 데이터를 준비했고 넘파이 배열을 통해 크기를 확인했다.

import numpy as np
import matplotlib.pyplot as plt

fruits = np.load('fruits_300.npy')

print(fruits.shape)
배열 크기 출력

 
그 다음엔 픽셀 100개에 들어있는 값을 출력했다. 이 넘파이 배열은 흑백 사진을 담고 있고 0~255까지의 정수값을 가진다.

 
matplotlib의 imshow() 함수를 사용하면 넘파이 배열로 저장된 이미지를 쉽게 그릴 수 있다. 흑백 이미지이므로 cmap 매개변수를 'gray'로 지정했다.

이 코드를 통해 첫 번째 이미지는 사과라고 알 수 있다. 또한 0에 가까울수록 검게 나타나고 높은 값은 밝게 표시된다.
우리의 관심을 바탕이 아니라 사과이기에 흑백이미지를 반전 시킬 것이다. 이는 cmap 매개변수를 'gray_r'로 지정하면 된다.

흑백이 반전된 사과 이미지

 
픽셀값을 분석하기 위해서 100 X 100 2차원 배열을 길이가 10,000인 1차원 배열로 만들 것이다. 이렇게 펼치면 이미지로 출력하긴 어렵지만 배열을 계산할 때 편리하다.

apple = fruits[0:100].reshape(-1, 100*100)
pineapple = fruits[100:200].reshape(-1, 100*100)
banana = fruits[200:300].reshape(-1, 100*100)

사과, 파인애플, 바나나 중 하나의 크기를 확인해보면 아래와 같다.

 
사과 샘플 100개에 대한 픽셀 평균값을 계산하고 이를 히스토그램으로 표현해 볼 것이다.

사과와 파인애플은 90~100 사이에 많이 모여있고 바나나 사진의 평균값은 40 아래에 집중되어 있다.
 
 
 
이번에는 각 픽셀의 평균을 구해볼 것이다. axis=0으로 지정하면 쉽게 계산할 수 있다.

fig, axs = plt.subplots(1, 3, figsize=(20,5))
axs[0].bar(range(10000), apple.mean(axis=0))
axs[1].bar(range(10000), pineapple.mean(axis=0))
axs[2].bar(range(10000), banana.mean(axis=0))
plt.show()
각 픽셀의 평균을 막대그래프로 표현

순서대로 사과, 파인애플, 바나나 그래프이다. 각각 값이 높은 구간이 다르다. 사과는 사진 중앙에 상대적으로 값이 작은 영역이 보이고 파인애플 그래프는 비교적 고르면서 높다. 바나나는 확실히 중앙의 픽셀값이 높다.
 
픽셀 평균값을 100 X 100 크기로 바꿔서 이미지 처럼 출력하여 위 그래프와 비교해 봤다.

apple_mean = apple.mean(axis=0).reshape(100,100)
pineapple_mean = pineapple.mean(axis=0).reshape(100,100)
banana_mean = banana.mean(axis=0).reshape(100,100)
fig, axs = plt.subplots(1, 3, figsize=(20,5))
axs[0].imshow(apple_mean, cmap='gray_r')
axs[1].imshow(pineapple_mean, cmap='gray_r')
axs[2].imshow(banana_mean, cmap='gray_r')
plt.show()

 

각 픽셀의 평균을 이미지로 출력

 
 
 
이번에는 사과 사진의 평균값과 가까운 사진을 고를 것이다. 그러기 위해서는 모든 샘플에서 apple_mean으 뺀 절댓값을 평균을 계산하면 된다. 이때 넘파이 abs() 함수라는 절댓값을 계산하는 함수를 이용할 것이다. 이 함수는 np.absolute() 함수의 다른 이름이다.

abs_diff = np.abs(fruits - apple_mean)
abs_mean = np.mean(abs_diff, axis=(1,2))
print(abs_mean.shape)

 
여기서 abs_diff는 (300, 100, 100) 크기의 배열이다. 따라서 각 샘플에 대한 평균을 구하기 위해 axis 두 번째, 세 번째 차원을 모두 지정했다. 이렇게 계산한 abs_mean은 각 샘플의 오차 평균이므로 크기가 (300,)인 1차원 배열이다.
그러고나서 이 값이 가장 작은 순서대로 100개를 고른다. 이것은 apple_mean과 오차가 가장 작은 샘플 100개를 고르는 것이다. np.argsort() 함수는 작은 것에서 큰 순서대로 나열한 abs_mean 배열의 인덱스를 반환한다. 이 인덱스 중에서 처음 100개를 선택해 10 X 10 격자로 이루어진 그래프를 그렸다.

apple_index = np.argsort(abs_mean)[:100]
apple_index = apple_index.reshape(10,10)
fig, axs = plt.subplots(10, 10, figsize=(10,10))
for i in range(10):
  for j in range(10):
    axs[i,j].imshow(fruits[apple_index[i,j]],cmap='gray_r')
    axs[i,j].axis('off')
plt.show()
위 코드 출력

 
apple_mean과 가장 가까운 사진 100개를 골랐더니 모두 사과이다. 위 코드를 좀 더 설명하자면 subplots() 함수로 10 X 10, chd 100개의 서브 그래프를 만든다. 그래프가 많기에 figsize = (10, 10)으로 조금 크게 지정하고 2중 for문을 통해 10개의 행과 열에 이미지를 출력한다. i,j 두 첨자를 사용하여 서브 그래프 위치를 지정하였고 axis('off')를 사용하여 좌표축을 그리지 않았다.
 
흑백 사진에 있는 픽셀값을 사용해 과일 사진을 모으는 작업을 하는 것과 같이 비슷한 샘플끼리 그룹으로 모으는 작업을 군집이라 한다. 군집은 대표적인 비지도 학습 작업 중 하나이고 군집 알고리즘에서 만든 그룹을 클러스터라고 한다.
 


06-2 (k-평균)

1절에서는 사과, 파인애플, 바나나 사진임을 미리 알고 있었기에 각 과일을 평균을 구했다. 하지만 진짜 비지도 학습에서는 사진에 어던 과일이 들어있는지 알지 못한다. 이럴 경우에는 k-평균 군집 알고리즘이 평균값을 자동으로 찾아준다. 이 평균값이 클러스터의 중심에 위치하기 때문에 클러스터 중심 또는 센트로이드라고 부른다. 
 
k-평균 알고리즘의 작동 방식은 다음과 같다.

  1. 무작위로 k개의 클러스터 중심을 정한다.
  2. 각 샘플에서 가장 가까운 클러스터 중심을 찾아 해당 클러스터의 샘플로 지정한다.
  3. 클러스터에 속한 샘플의 평균값으로 클러스터 중심을 변경한다.
  4. 클러스터 중심에 변화가 없을 때까지 2번으로 돌아가 반복한다.

사이킷런의 k-평균 알고리즘은 sklearn.cluster 모듈 아래 KMeans 클래스에 구현되어 있다.
클러스터 개수를 정정하는 매개변수는 n_clusrers이다. 비지도 학습이기에 fit() 메서드에서 타깃 데이터를 사용하지 않는다.

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

from sklearn.cluster import KMeans
km = KMeans(n_clusters=3, random_state=42)
km.fit(fruits_2d)

label_ 배열의 값은 0, 1, 2 중 하나이다.
 

각 샘플이 어떤 레이블에 해당되는지 확인

 
레이블값 0, 1, 2와 레이블 순서에는 어떤 의미도 없고, 실제 레이블 0, 1, 2가 어떤 과일 사진을 주로 모았는지 알아보려면 직접 이미지를 출력하는 것이 최선이다. 그 전에 레이블 0, 1, 2로 모은 샘플의 개수를 확인해봤다.

 
draw_fruits() 함수는 (샘플 개수, 너비, 높이)의 3차원 배열을 입력받아 가롤로 10개씩 이미지를 출력한다. 또한 아래 코드에서 불리언 인덱싱을 사용한다.

import matplotlib.pyplot as plt
def draw_fruits(arr, ratio=1):
  n = len(arr)
  rows = int(np.ceil(n/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:
        axs[i, j ].imshow(arr[i*10 + j], cmap='gray_r')
        axs[i,j].axis('off')
  plt.show()

 
위 코드를 통해 각 라벨이 어떤 과일을 나타내는지 알아봤다.

draw_fruits(fruits[km.labels_==0])
레이블이 0일때의 출력
draw_fruits(fruits[km.labels_==1])
레이블이 1일 때의 출력
draw_fruits(fruits[km.labels_==2])
레이블이 2일 때 출력

 
위 출력들을 통해 레이블이 0은 대부분 파인애플, 1은 바나나, 2는 사과가 출력되었음을 알 수 있다. 샘플들을 완벽하게 구별하지는 못했어도 비슷한 샘플들을 잘 모았다.
 
 
 
KMeans 클래스가 최종적으로 찾은 클러스터 중심은 cluster_centers_ 속성에 저장되어 있다. 이 배열은 fruits_2d 샘플의 클러스터 중심이기 때문에 각 중심을 이미지로 출력하려면 100 X 100 크기의 2차원 배열로 바꿔야 한다.

 
KMeans 클래스는 훈련 데이터 샘플에서 클러스터 중심까지 거리로 변환해 주는 transform() 메서드를 가지고 있다. 이 메서는 마치 StandardScaler 클래스처럼 특성값을 변환하는 도구로 사용할 수 있다는 의미이다.
transform() 메서드를 적용할 때, fit() 메서드와 마찬가지로 2차원 배열을 기대한다. fruits_2d[100]처럼 쓰면 (10000,) 크기의 배열이 되므로 에러가 발생한다. 그러므로 슬라이싱 연산자를 사용해서 (1, 10000) 크기의 배열을 전달했다.

첫 번째 클러스터(레이블 0), 두 번째 클러스터(레이블 1), 세 번째 클러스터(레이블 2)가 각각 첫 번째 원소, 두 번째 원소, 세 번째 원소의 값이다. 이중 첫 번째 클러스터까지의 거리가 가장 작기에 이 샘플을 레이블 0에 속했다.
 
KMeans 클래스는 가장 가까운 클러스터 중심을 예측 클래스로 출력하는 predict() 메서드도 제공한다.

몇 번째 레이블과 가장 가까운지 출력
샘플 확인

transform()의 결과에서 짐작했듯이 이 샘플은 파인애플이 맞았다.
 
k-평균 알고리즘은 앞에서 설명햇들이 반복적으로 클러스터 중심을 옮기면서 최적의 클러스터를 찾는다. 알고리즘이 반복한 횟수는 KMeans 클래스의 n_iter_ 속성에 저장된다.

 
만약 우리가 n_cluster를 지정할 수 없을 때 최적의 클러스터를 어떻게 구해야할지 고민될 것이다. 적절한 k 값을 찾기 위한 완벽한 방법은 없다. 몇 가지 도구가 있지만 저마다의 장단점이 있다. 그 중 우린 대표적인 방법은 엘보우 방법에 대해서 알아볼 것이다.
 
k-평균 알고리즘은 클러스터 중심과 클러스터에 속한 샘플 사이의 거리를 잴 수 있다. 이 거리의 제곱 합을 이너셔라고 부르고 이너셔는 클러스터에 속한 샘플이 얼마나 가깝게 모여 있는지를 나타내는 값으로 생각할 수 있다. 일반적으로 클러스터 개수가 늘어나면 클러스터 개개의 크기는 줄어들기에 이너셔도 줄어든다. 엘보우 방법은 클러스터 개수를 늘려가면서 이너셔의 변화를 관찰하여 최적의 클러스터 개수를 찾는 방법이다.
 
클러스터 개수를 증가시키면서 이너셔를 그래프로 그리면 감소하는 속도가 꺾이는 지점이 있다. 이 지접부터는 클러스터 개수를 늘려도 클러스터에 잘 밀집된 정도가 크게 개선되지 않기에 이 지점을 k로 사용한다. 이 지점이 마치 팔꿈치 모양이어서 엘보우 방법이라 부른다.

하지만 위 그래프는 그래프의 기울기가 조금 바뀌었지만 지점이 명확하지 않다.
 


 

06-3 (주성분 분석)

너무 많은 사진이 등록되어 저장 공간이 부족해질 수가 있다. 이때 차원을 축소하면 된다. 이때 머신러닝에서의 차원은 예를 들어 과일 사진의 경우 10,000개의 픽셀이 있기 때문에 10,000개의 특성이 있는 셈이고 이러한 특성을 차원이라고 한다. 10,000개의 차원을 줄이기 위한 비지도 학습 작업 중 하나인 차원 축소 알고리즘을 다룰 것이다. 
 
특성이 많으면 선형 모델의 성능이 높아지고 훈련 데이터에 과대적합된다. 차원 축소는 데이터를 가장 잘 나타내는 일부 특성을 선택하여 데이터 크기를 줄이고 지도 학습 모델의 성능을 향상시킬 수 있는 방법이다. 또한 줄어든 차원에서 다시 원본 차원으로 손실을 최대한 출이면서 복원할 수도 있다.
 
대표적인 차원 축소 알고리즘인 주성분 분석에 대해 배우 것이며 주성분 분석을 간단히 PCA라고도 부른다.
 
주성분 분석에 대해서 소개하자면 주성분 분석은 데이터에 있는 분산이 큰 방향을 찾는 것으로 이해할 수 있다. 분산은 데이터가 널리 퍼져있는 정도를 말하며 분산이 큰 방향은 데이터를 잘 표현하는 어떤 벡터라고 생각할 수 있다.
 
2차원 데이터를 생각해보면 데이터의 분포를 가장 잘 표현하는 길게 늘어진 대각선을 생각할 수 있고 이 대각선 방향이 분산이 가장 크다고 말할 수 있다. 화살표의 위치는 중요하지 않고 분산이 큰 방향을 찾는 것이 중요하다. 이 직선이 원점에서 출발한다면 두 원소로 이루어진 벡터를 쓸 수 있다. 이 벡터를 주성분이라고 부른다. 샘플 데이터를 주성분에 직각으로 투영하면 1차원 데이터를 만들 수 있다. 
 
주성분이 가장 분산이 큰 방향이기에 주성분에 투영하여 바꾼 데이터는 원본이 가지고 있는 특성을 가장 잘 나타내고 있을 것이다.
첫 번째 주성분을 찾은 다음 이 벡터에 수직이고 분산이 가장 큰 다음 방향을 찾는다. 이 벡터가 두 번째 주성분이다. 여기서는 2차원이기 때문에 두 번째 주성분의 방향은 하나뿐이다.
 

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)

 
 

위 사진과 같이 n_components를 50으로 지정했기에 첫 번째 차원은 50개, 즉 40개의 주성분을 찾았고 두 번째 차원은 항상 원본 데이터의 특성 개수와 같은 10,000이다.
 
또한 주성분을 그림으로 그려보았다.

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))
주성분을 그림으로 출력

 
 
원본 데이터를 주성분에 투영하여  특성 개수를 10,000개에서 50개로 줄일 것이다. PCA의 transform() 메서드를 사용해서 원본 데이터의 차원을 50으로 줄였다.

print(fruits_2d.shape)

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

 
 
앞에서 특성을 50개로 줄였다. 어느 정도 손실이 발생할 수밖에 없지만 최대한 분산이 큰 방향으로 데이터를 투영했기에 원본 데이터를 상당 부분 재구성할 수 있다. PCA 클래스는 이를 위해 inverse_transform() 메서드를 제공한다.

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

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

print(np.sum(pca.explained_variance_ratio_))

 
또한 적절한 주성분의 개수를 찾기 위해 설명된 분산의 비율을 그래프로 그려보았다.

plt.plot(pca.explained_variance_ratio_)
plt.show()
설명된 분산의 비율 그래프

그래프를 통해 처음 10개의 주성분이 대부분의 분산을 표현함을 을 수 있다.
 
 
 
원본 데이터와 축소한 데이터를 지도 학습에 적용해보고 어던 차이가 있는지 알아보기 위해 로지스틱 회귀 분석을 사용하였다.

from sklearn.linear_model import LogisticRegression
lr = LogisticRegression()

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

 
cross_validate()로 교차 검증을 수행하였다.

원본 데이터 및 PCA로 축소한 데이터를 통한 교차검증 수행

 
정확도가 동일하지만 훈련시간은 줄어들었다.
 
설명된 분산의 50%에 달하는 주성분을 찾도록 PCA 모델을 만들어보았다. 주성분 개수 대신 0~1 사이의 비율을 실수로 입력하면된다.

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

print(pca.n_components_)

이 결과 2개의 특성만으로 원본 데이터에 있는 분산의 50%를 표현할 수 있다.
 
이 모델로 원본 데이터를 변환하고 교차 검증을 확인해 보았다.

 
2개의 특성만으로도 정확도가 높은 것을 알 수 있다.
 
이번에는 차원 축소된 데이터를 사용해 k-평균 알고리즘으로 클러스터를 찾아볼 것이다.

원본 데이터와 거의 비슷한 결과이다.
 
훈련 데이터의 차원을 줄이면 얻을 수 있는 장점은 시각화이다. 3개 이하로 차원을 줄이면 화면에 출력하기 비교적 쉽다. fruits_pca 데이터는 2개의 특성이 있기 때문에 2차원으로 표현할 수 있다.

시각화 출력

 


 

숙제

k-평균 알고리즘의 작동 방식은 다음과 같다.

  1. 무작위로 k개의 클러스터 중심을 정한다.
  2. 각 샘플에서 가장 가까운 클러스터 중심을 찾아 해당 클러스터의 샘플로 지정한다.
  3. 클러스터에 속한 샘플의 평균값으로 클러스터 중심을 변경한다.
  4. 클러스터 중심에 변화가 없을 때까지 2번으로 돌아가 반복한다.

'[혼공머신]' 카테고리의 다른 글

[혼공머신] 4주차  (5) 2025.07.27
[혼공머신] 3주차  (3) 2025.07.20
[혼공머신] 2주차  (1) 2025.07.13
[혼공머신] 1주차  (0) 2025.07.06