본문으로 건너뛰기

061 최적 클러스터 수 결정하기

키워드: 엘보우, 실루엣

개요

클러스터링에서 가장 어려운 문제 중 하나는 최적의 클러스터 수(K)를 결정하는 것입니다. 이 글에서는 다양한 방법을 사용하여 최적 K를 찾는 방법을 알아봅니다.

실습 환경

  • Python 버전: 3.11 권장
  • 필요 패키지: pycaret[full]>=3.0

왜 최적 K가 중요한가?

K가 너무 작으면:
- 서로 다른 그룹이 하나로 합쳐짐
- 중요한 패턴을 놓칠 수 있음

K가 너무 크면:
- 같은 그룹이 불필요하게 분리됨
- 과적합과 유사한 문제
- 해석이 어려워짐

1. 엘보우 방법 (Elbow Method)

관성(Inertia) = 각 점에서 클러스터 중심까지 거리의 제곱합

from pycaret.clustering import *
from pycaret.datasets import get_data
from sklearn.cluster import KMeans
import matplotlib.pyplot as plt

data = get_data('jewellery')
clust = setup(data, normalize=True, session_id=42, verbose=False)

X = get_config('X').values

# 061 K별 관성 계산
inertias = []
K_range = range(1, 11)

for k in K_range:
kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
kmeans.fit(X)
inertias.append(kmeans.inertia_)

# 061 시각화
plt.figure(figsize=(10, 6))
plt.plot(K_range, inertias, 'bo-', linewidth=2, markersize=10)
plt.xlabel('Number of Clusters (K)', fontsize=12)
plt.ylabel('Inertia (Within-cluster sum of squares)', fontsize=12)
plt.title('Elbow Method', fontsize=14)
plt.grid(True, alpha=0.3)
plt.xticks(K_range)

# 061 엘보우 포인트 강조
plt.annotate('Elbow Point', xy=(4, inertias[3]),
xytext=(6, inertias[3] + 50),
arrowprops=dict(arrowstyle='->', color='red'),
fontsize=12, color='red')

plt.savefig('elbow_method.png', dpi=150)

엘보우 자동 탐지

from kneed import KneeLocator  # pip install kneed

# 061 엘보우 포인트 자동 탐지
kneedle = KneeLocator(
list(K_range), inertias,
curve='convex', direction='decreasing'
)

print(f"자동 탐지된 엘보우: K = {kneedle.elbow}")

2. 실루엣 분석 (Silhouette Analysis)

실루엣 점수 = (b - a) / max(a, b)

  • a: 같은 클러스터 내 평균 거리
  • b: 가장 가까운 다른 클러스터와의 평균 거리
  • 범위: -1 ~ 1 (1에 가까울수록 좋음)
from pycaret.clustering import *
from pycaret.datasets import get_data
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score, silhouette_samples
import matplotlib.pyplot as plt
import numpy as np

data = get_data('jewellery')
clust = setup(data, normalize=True, session_id=42, verbose=False)

X = get_config('X').values

# 061 K별 실루엣 점수
scores = []
K_range = range(2, 11)

for k in K_range:
kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
labels = kmeans.fit_predict(X)
score = silhouette_score(X, labels)
scores.append(score)

# 061 시각화
plt.figure(figsize=(10, 6))
plt.bar(K_range, scores, color='steelblue', edgecolor='black')
plt.xlabel('Number of Clusters (K)', fontsize=12)
plt.ylabel('Silhouette Score', fontsize=12)
plt.title('Silhouette Analysis', fontsize=14)
plt.xticks(K_range)
plt.grid(True, alpha=0.3, axis='y')

# 061 최적 K 표시
best_k = K_range[np.argmax(scores)]
plt.axvline(x=best_k, color='red', linestyle='--', label=f'Best K = {best_k}')
plt.legend()

plt.savefig('silhouette_analysis.png', dpi=150)

print(f"최적 K: {best_k} (실루엣 점수: {max(scores):.4f})")

실루엣 다이어그램

from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_samples
import matplotlib.pyplot as plt
import matplotlib.cm as cm
import numpy as np
from pycaret.clustering import *
from pycaret.datasets import get_data

data = get_data('jewellery')
clust = setup(data, normalize=True, session_id=42, verbose=False)

X = get_config('X').values

fig, axes = plt.subplots(2, 2, figsize=(12, 10))
K_values = [2, 3, 4, 5]

for idx, k in enumerate(K_values):
ax = axes[idx // 2, idx % 2]

kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
labels = kmeans.fit_predict(X)

silhouette_avg = silhouette_score(X, labels)
sample_silhouette_values = silhouette_samples(X, labels)

y_lower = 10
for i in range(k):
cluster_silhouette_values = sample_silhouette_values[labels == i]
cluster_silhouette_values.sort()

size_cluster_i = cluster_silhouette_values.shape[0]
y_upper = y_lower + size_cluster_i

color = cm.nipy_spectral(float(i) / k)
ax.fill_betweenx(np.arange(y_lower, y_upper),
0, cluster_silhouette_values,
facecolor=color, edgecolor=color, alpha=0.7)
ax.text(-0.05, y_lower + 0.5 * size_cluster_i, str(i))
y_lower = y_upper + 10

ax.axvline(x=silhouette_avg, color='red', linestyle='--')
ax.set_title(f'K={k}, Avg Score={silhouette_avg:.3f}')
ax.set_xlabel('Silhouette Coefficient')
ax.set_ylabel('Cluster Label')

plt.tight_layout()
plt.savefig('silhouette_diagram.png', dpi=150)

3. 갭 통계량 (Gap Statistic)

실제 데이터와 균일 분포 랜덤 데이터의 클러스터링 품질 차이 비교

from sklearn.cluster import KMeans
import numpy as np
from pycaret.clustering import *
from pycaret.datasets import get_data

data = get_data('jewellery')
clust = setup(data, normalize=True, session_id=42, verbose=False)

X = get_config('X').values

def compute_gap_statistic(X, k_range, n_references=10):
"""갭 통계량 계산"""
gaps = []
s_k = []

for k in k_range:
# 실제 데이터 클러스터링
kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
kmeans.fit(X)
W_k = kmeans.inertia_

# 랜덤 데이터 클러스터링
W_kb = []
for _ in range(n_references):
# 균일 분포 랜덤 데이터
random_data = np.random.uniform(
low=X.min(axis=0),
high=X.max(axis=0),
size=X.shape
)
kmeans_random = KMeans(n_clusters=k, random_state=42, n_init=10)
kmeans_random.fit(random_data)
W_kb.append(kmeans_random.inertia_)

# 갭 계산
gap = np.mean(np.log(W_kb)) - np.log(W_k)
gaps.append(gap)
s_k.append(np.std(np.log(W_kb)) * np.sqrt(1 + 1/n_references))

return gaps, s_k

K_range = range(1, 11)
gaps, s_k = compute_gap_statistic(X, K_range)

# 061 최적 K: Gap(k) >= Gap(k+1) - s(k+1)인 가장 작은 k
optimal_k = 1
for k in range(len(gaps) - 1):
if gaps[k] >= gaps[k + 1] - s_k[k + 1]:
optimal_k = k + 1
break

print(f"갭 통계량 기반 최적 K: {optimal_k}")

# 061 시각화
import matplotlib.pyplot as plt

plt.figure(figsize=(10, 6))
plt.errorbar(K_range, gaps, yerr=s_k, fmt='o-', capsize=5)
plt.xlabel('Number of Clusters (K)')
plt.ylabel('Gap Statistic')
plt.title('Gap Statistic Method')
plt.axvline(x=optimal_k, color='red', linestyle='--', label=f'Optimal K = {optimal_k}')
plt.legend()
plt.grid(True, alpha=0.3)
plt.savefig('gap_statistic.png', dpi=150)

4. 칼린스키-하라바스 지수 (Calinski-Harabasz Index)

클러스터 간 분산 / 클러스터 내 분산 비율 (높을수록 좋음)

from sklearn.cluster import KMeans
from sklearn.metrics import calinski_harabasz_score
import matplotlib.pyplot as plt
from pycaret.clustering import *
from pycaret.datasets import get_data

data = get_data('jewellery')
clust = setup(data, normalize=True, session_id=42, verbose=False)

X = get_config('X').values

scores = []
K_range = range(2, 11)

for k in K_range:
kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
labels = kmeans.fit_predict(X)
score = calinski_harabasz_score(X, labels)
scores.append(score)

plt.figure(figsize=(10, 6))
plt.plot(K_range, scores, 'go-', linewidth=2, markersize=10)
plt.xlabel('Number of Clusters (K)')
plt.ylabel('Calinski-Harabasz Index')
plt.title('Calinski-Harabasz Index')
plt.xticks(K_range)
plt.grid(True, alpha=0.3)

best_k = K_range[np.argmax(scores)]
plt.axvline(x=best_k, color='red', linestyle='--', label=f'Best K = {best_k}')
plt.legend()
plt.savefig('calinski_harabasz.png', dpi=150)

5. 데이비스-볼딘 지수 (Davies-Bouldin Index)

클러스터 내 분산과 클러스터 간 거리 비율 (낮을수록 좋음)

from sklearn.cluster import KMeans
from sklearn.metrics import davies_bouldin_score
import matplotlib.pyplot as plt
import numpy as np
from pycaret.clustering import *
from pycaret.datasets import get_data

data = get_data('jewellery')
clust = setup(data, normalize=True, session_id=42, verbose=False)

X = get_config('X').values

scores = []
K_range = range(2, 11)

for k in K_range:
kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
labels = kmeans.fit_predict(X)
score = davies_bouldin_score(X, labels)
scores.append(score)

plt.figure(figsize=(10, 6))
plt.plot(K_range, scores, 'mo-', linewidth=2, markersize=10)
plt.xlabel('Number of Clusters (K)')
plt.ylabel('Davies-Bouldin Index (lower is better)')
plt.title('Davies-Bouldin Index')
plt.xticks(K_range)
plt.grid(True, alpha=0.3)

best_k = K_range[np.argmin(scores)]
plt.axvline(x=best_k, color='red', linestyle='--', label=f'Best K = {best_k}')
plt.legend()
plt.savefig('davies_bouldin.png', dpi=150)

종합 분석

from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score, calinski_harabasz_score, davies_bouldin_score
import pandas as pd
import numpy as np
from pycaret.clustering import *
from pycaret.datasets import get_data

data = get_data('jewellery')
clust = setup(data, normalize=True, session_id=42, verbose=False)

X = get_config('X').values

# 061 모든 지표 계산
results = []
K_range = range(2, 11)

for k in K_range:
kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
labels = kmeans.fit_predict(X)

results.append({
'K': k,
'Inertia': kmeans.inertia_,
'Silhouette': silhouette_score(X, labels),
'Calinski-Harabasz': calinski_harabasz_score(X, labels),
'Davies-Bouldin': davies_bouldin_score(X, labels)
})

df = pd.DataFrame(results)

print("=== 최적 K 종합 분석 ===\n")
print(df.to_string(index=False))

# 061 각 지표별 최적 K
print("\n지표별 최적 K:")
print(f" 실루엣 (최대): K = {df.loc[df['Silhouette'].idxmax(), 'K']}")
print(f" 칼린스키-하라바스 (최대): K = {df.loc[df['Calinski-Harabasz'].idxmax(), 'K']}")
print(f" 데이비스-볼딘 (최소): K = {df.loc[df['Davies-Bouldin'].idxmin(), 'K']}")

PyCaret 자동 분석

from pycaret.clustering import *
from pycaret.datasets import get_data

data = get_data('jewellery')
clust = setup(data, normalize=True, session_id=42, verbose=False)

# 061 K-Means 모델 생성 후 엘보우 플롯
kmeans = create_model('kmeans', num_clusters=4)

# 061 엘보우 플롯 (자동)
plot_model(kmeans, plot='elbow')

# 061 실루엣 플롯 (자동)
plot_model(kmeans, plot='silhouette')

방법별 특징 비교

방법장점단점적합한 상황
엘보우직관적, 계산 빠름주관적 해석빠른 탐색
실루엣해석 명확, 클러스터별 분석K=2일 때 편향클러스터 품질 평가
갭 통계량통계적 근거계산 비용 높음엄밀한 분석
칼린스키-하라바스빠른 계산볼록 클러스터 가정빠른 비교
데이비스-볼딘클러스터 형태 고려볼록 클러스터 가정보조 지표

정리

  • 단일 방법보다 여러 방법 종합 권장
  • 엘보우와 실루엣이 가장 많이 사용됨
  • 도메인 지식도 K 결정에 반영
  • 비즈니스 해석 가능성도 고려

다음 글 예고

다음 글에서는 클러스터링 평가와 시각화를 다룹니다.


PyCaret 머신러닝 마스터 시리즈 #061