비지도학습이란?

1편에서 비지도학습은 정답 없이 데이터 자체의 구조를 발견하는 학습 방식이라고 했습니다. 4~6편에서 다룬 지도학습과 달리, 레이블이 없는 데이터에서 의미 있는 패턴을 찾습니다.

비지도학습 유형목표대표 알고리즘
군집화유사한 데이터끼리 그룹화K-Means, DBSCAN, 계층적
차원 축소고차원 → 저차원 압축/시각화PCA, t-SNE, UMAP
이상 탐지비정상 데이터 식별Isolation Forest, Autoencoder

1. K-Means 군집화

핵심 아이디어

데이터를 K개의 그룹으로 나누되, 각 그룹의 중심(centroid)과 소속 데이터 간 거리를 최소화합니다.

알고리즘 과정

  1. K개의 초기 중심점을 랜덤으로 선택
  2. 각 데이터를 가장 가까운 중심점의 그룹에 할당
  3. 각 그룹의 평균을 새로운 중심점으로 업데이트
  4. 중심점이 변하지 않을 때까지 2~3 반복

목적 함수: 이너셔 (Inertia)

$$J = \sum_{i=1}^{K}\sum_{x \in C_i}|x - \mu_i|^2$$

  • $$\mu_i$$: 클러스터 $$i$$의 중심점
  • 이너셔가 작을수록 그룹 내 응집도가 높음

Python 실습: 알고리즘 동작 시각화

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_blobs

# 데이터 생성 (3개 군집)
X, y_true = make_blobs(n_samples=300, centers=3, cluster_std=1.0, random_state=42)

# K-Means 수동 구현 (과정 시각화)
def kmeans_step_by_step(X, K, max_iters=6):
    np.random.seed(42)
    # 초기 중심점 랜덤 선택
    idx = np.random.choice(len(X), K, replace=False)
    centroids = X[idx].copy()

    fig, axes = plt.subplots(2, 3, figsize=(15, 10))
    axes = axes.ravel()

    for i in range(max_iters):
        # 할당: 각 데이터를 가장 가까운 중심에 배정
        distances = np.linalg.norm(X[:, np.newaxis] - centroids, axis=2)
        labels = np.argmin(distances, axis=1)

        # 시각화
        ax = axes[i]
        for k in range(K):
            mask = labels == k
            ax.scatter(X[mask, 0], X[mask, 1], s=20, alpha=0.6)
        ax.scatter(centroids[:, 0], centroids[:, 1],
                   c='black', marker='X', s=200, zorder=5)
        ax.set_title(f'반복 {i+1}')

        # 업데이트: 중심점을 그룹 평균으로 이동
        new_centroids = np.array([X[labels == k].mean(axis=0) for k in range(K)])
        if np.allclose(centroids, new_centroids):
            ax.set_title(f'반복 {i+1} (수렴!)')
            break
        centroids = new_centroids

    plt.suptitle('K-Means 알고리즘 동작 과정', fontsize=14)
    plt.tight_layout()
    plt.show()

kmeans_step_by_step(X, K=3)

최적 K 결정: 엘보우 방법

from sklearn.cluster import KMeans

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_)

plt.figure(figsize=(8, 5))
plt.plot(K_range, inertias, 'o-')
plt.xlabel('K (클러스터 수)')
plt.ylabel('이너셔 (Inertia)')
plt.title('엘보우 방법: 최적 K 찾기')
plt.axvline(x=3, color='red', linestyle='--', label='K=3 (엘보우)')
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()

실루엣 분석

from sklearn.metrics import silhouette_score, silhouette_samples

fig, axes = plt.subplots(1, 3, figsize=(15, 4))

for ax, k in zip(axes, [2, 3, 4]):
    kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
    labels = kmeans.fit_predict(X)
    score = silhouette_score(X, labels)

    ax.scatter(X[:, 0], X[:, 1], c=labels, cmap='viridis', s=20, alpha=0.6)
    ax.scatter(kmeans.cluster_centers_[:, 0], kmeans.cluster_centers_[:, 1],
               c='red', marker='X', s=200, zorder=5)
    ax.set_title(f'K={k}, 실루엣 점수: {score:.3f}')

plt.suptitle('실루엣 점수 비교 (1에 가까울수록 좋음)')
plt.tight_layout()
plt.show()

실루엣 점수: -1 ~ 1 사이의 값으로, 1에 가까울수록 군집이 잘 분리되어 있음을 의미합니다.

K-Means의 한계

from sklearn.datasets import make_moons, make_circles

fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# 초승달 데이터
X_moons, _ = make_moons(n_samples=300, noise=0.1, random_state=42)
labels = KMeans(n_clusters=2, random_state=42, n_init=10).fit_predict(X_moons)
axes[0].scatter(X_moons[:, 0], X_moons[:, 1], c=labels, cmap='RdBu', s=20)
axes[0].set_title('K-Means on 초승달 (실패)')

# 동심원 데이터
X_circles, _ = make_circles(n_samples=300, noise=0.1, factor=0.5, random_state=42)
labels = KMeans(n_clusters=2, random_state=42, n_init=10).fit_predict(X_circles)
axes[1].scatter(X_circles[:, 0], X_circles[:, 1], c=labels, cmap='RdBu', s=20)
axes[1].set_title('K-Means on 동심원 (실패)')

plt.suptitle('K-Means의 한계: 비구형 군집')
plt.tight_layout()
plt.show()

2. DBSCAN (밀도 기반 군집화)

핵심 아이디어

밀도가 높은 영역을 군집으로 정의합니다. K-Means와 달리 K를 지정할 필요 없고, 비구형 군집도 찾을 수 있습니다.

핵심 파라미터

파라미터의미
eps (ε)이웃으로 간주하는 최대 거리
min_samples핵심 포인트가 되기 위한 최소 이웃 수

포인트 분류

  • 핵심 포인트: ε 반경 내에 min_samples 이상의 이웃이 있는 점
  • 경계 포인트: 핵심 포인트의 ε 반경 내에 있지만, 자신은 핵심이 아닌 점
  • 노이즈 포인트: 어떤 핵심 포인트의 ε 반경에도 속하지 않는 점 (라벨 = -1)

Python 실습

from sklearn.cluster import DBSCAN

fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# 초승달 데이터에 DBSCAN
labels = DBSCAN(eps=0.2, min_samples=5).fit_predict(X_moons)
axes[0].scatter(X_moons[:, 0], X_moons[:, 1], c=labels, cmap='viridis', s=20)
axes[0].set_title(f'DBSCAN on 초승달 (군집 수: {len(set(labels) - {-1})})')

# 동심원 데이터에 DBSCAN
labels = DBSCAN(eps=0.2, min_samples=5).fit_predict(X_circles)
axes[1].scatter(X_circles[:, 0], X_circles[:, 1], c=labels, cmap='viridis', s=20)
axes[1].set_title(f'DBSCAN on 동심원 (군집 수: {len(set(labels) - {-1})})')

plt.suptitle('DBSCAN: 비구형 군집도 정확히 분리')
plt.tight_layout()
plt.show()

3. 계층적 군집화 (Hierarchical Clustering)

핵심 아이디어

데이터를 트리(덴드로그램) 구조로 계층적으로 병합하거나 분할합니다.

  • 병합(Agglomerative): 각 데이터가 개별 군집 → 가까운 군집끼리 점점 병합
  • 분할(Divisive): 전체가 하나의 군집 → 점점 분할

연결 방법 (Linkage)

방법군집 간 거리 정의특징
단일 연결가장 가까운 점 간 거리체인 효과 발생 가능
완전 연결가장 먼 점 간 거리균일한 크기 군집
평균 연결모든 점 쌍의 평균 거리균형 잡힌 결과
워드 연결병합 시 분산 증가 최소화가장 많이 사용

Python 실습: 덴드로그램

from scipy.cluster.hierarchy import dendrogram, linkage
from sklearn.cluster import AgglomerativeClustering

# 소규모 데이터로 덴드로그램 시각화
X_small, _ = make_blobs(n_samples=30, centers=3, cluster_std=0.8, random_state=42)

# 덴드로그램
Z = linkage(X_small, method='ward')

plt.figure(figsize=(12, 5))
dendrogram(Z, leaf_rotation=90)
plt.title('계층적 군집화 덴드로그램 (Ward 연결)')
plt.xlabel('데이터 포인트')
plt.ylabel('거리')
plt.axhline(y=7, color='red', linestyle='--', label='절단선 (3개 군집)')
plt.legend()
plt.show()
# 전체 데이터에 적용
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
linkages = ['ward', 'complete', 'average']

for ax, link in zip(axes, linkages):
    model = AgglomerativeClustering(n_clusters=3, linkage=link)
    labels = model.fit_predict(X)
    ax.scatter(X[:, 0], X[:, 1], c=labels, cmap='viridis', s=20, alpha=0.6)
    ax.set_title(f'{link} 연결')

plt.suptitle('계층적 군집화: 연결 방법 비교')
plt.tight_layout()
plt.show()

4. PCA (주성분 분석)

핵심 아이디어

고차원 데이터의 **분산을 가장 잘 보존하는 방향(주성분)**을 찾아 저차원으로 투영합니다.

수학적 원리

  1. 데이터의 공분산 행렬 $$\Sigma = \frac{1}{n}X^TX$$ 계산
  2. 고유값 분해 → 고유벡터(주성분 방향)와 고유값(설명 분산) 획득
  3. 가장 큰 고유값의 고유벡터 k개를 선택하여 투영

$$Z = XW_k$$

  • $$W_k$$: 상위 k개 고유벡터로 이루어진 투영 행렬

Python 실습

from sklearn.decomposition import PCA
from sklearn.datasets import load_iris

# 4차원 → 2차원 축소
iris = load_iris()
X_iris = iris.data
y_iris = iris.target

pca = PCA(n_components=2)
X_pca = pca.fit_transform(X_iris)

# 설명 분산 비율
print(f"PC1 설명 분산: {pca.explained_variance_ratio_[0]:.2%}")
print(f"PC2 설명 분산: {pca.explained_variance_ratio_[1]:.2%}")
print(f"합계: {sum(pca.explained_variance_ratio_):.2%}")

# 시각화
plt.figure(figsize=(8, 6))
for i, name in enumerate(iris.target_names):
    mask = y_iris == i
    plt.scatter(X_pca[mask, 0], X_pca[mask, 1], label=name, s=30, alpha=0.7)
plt.xlabel(f'PC1 ({pca.explained_variance_ratio_[0]:.1%})')
plt.ylabel(f'PC2 ({pca.explained_variance_ratio_[1]:.1%})')
plt.title('PCA: 붓꽃 데이터 (4D → 2D)')
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()

스크리 플롯: 적절한 차원 수 결정

pca_full = PCA().fit(X_iris)

plt.figure(figsize=(8, 5))
plt.bar(range(1, 5), pca_full.explained_variance_ratio_, alpha=0.7, label='개별')
plt.plot(range(1, 5), np.cumsum(pca_full.explained_variance_ratio_),
         'o-', color='red', label='누적')
plt.axhline(y=0.95, color='gray', linestyle='--', label='95% 기준')
plt.xlabel('주성분')
plt.ylabel('설명 분산 비율')
plt.title('스크리 플롯')
plt.legend()
plt.xticks(range(1, 5))
plt.show()

5. t-SNE와 UMAP

PCA는 선형 축소만 가능합니다. 비선형 구조를 보존하는 시각화 기법들입니다.

t-SNE (t-distributed Stochastic Neighbor Embedding)

  • 가까운 점은 가까이, 먼 점은 멀리 배치하여 지역 구조를 보존
  • 시각화 전용 (새 데이터에 적용 불가)
  • perplexity 파라미터가 결과에 큰 영향

UMAP (Uniform Manifold Approximation and Projection)

  • t-SNE보다 빠르고 전역 구조도 더 잘 보존
  • 새 데이터에 변환 적용 가능
  • n_neighbors, min_dist가 주요 파라미터

Python 실습: 3가지 비교

from sklearn.manifold import TSNE
# UMAP 설치: pip install umap-learn
# import umap

fig, axes = plt.subplots(1, 3, figsize=(18, 5))

# PCA
X_pca = PCA(n_components=2).fit_transform(X_iris)
for i, name in enumerate(iris.target_names):
    mask = y_iris == i
    axes[0].scatter(X_pca[mask, 0], X_pca[mask, 1], label=name, s=30, alpha=0.7)
axes[0].set_title('PCA')
axes[0].legend()

# t-SNE
X_tsne = TSNE(n_components=2, perplexity=30, random_state=42).fit_transform(X_iris)
for i, name in enumerate(iris.target_names):
    mask = y_iris == i
    axes[1].scatter(X_tsne[mask, 0], X_tsne[mask, 1], label=name, s=30, alpha=0.7)
axes[1].set_title('t-SNE (perplexity=30)')
axes[1].legend()

# UMAP (주석 처리 — umap 미설치 시)
# reducer = umap.UMAP(n_neighbors=15, min_dist=0.1, random_state=42)
# X_umap = reducer.fit_transform(X_iris)
# for i, name in enumerate(iris.target_names):
#     mask = y_iris == i
#     axes[2].scatter(X_umap[mask, 0], X_umap[mask, 1], label=name, s=30, alpha=0.7)
# axes[2].set_title('UMAP')
# axes[2].legend()

# UMAP 미설치 시 t-SNE perplexity 변경으로 대체
X_tsne2 = TSNE(n_components=2, perplexity=5, random_state=42).fit_transform(X_iris)
for i, name in enumerate(iris.target_names):
    mask = y_iris == i
    axes[2].scatter(X_tsne2[mask, 0], X_tsne2[mask, 1], label=name, s=30, alpha=0.7)
axes[2].set_title('t-SNE (perplexity=5)')
axes[2].legend()

plt.suptitle('차원 축소 기법 비교 (붓꽃 데이터)')
plt.tight_layout()
plt.show()

비교 표

기법선형/비선형속도전역 구조새 데이터 변환주용도
PCA선형매우 빠름보존가능전처리, 시각화
t-SNE비선형느림미보존불가시각화 전용
UMAP비선형빠름부분 보존가능시각화, 전처리

6. 이상 탐지 (Anomaly Detection)

Isolation Forest

정상 데이터는 분리하기 어렵고, 이상 데이터는 쉽게 분리된다는 아이디어입니다.

from sklearn.ensemble import IsolationForest

# 정상 데이터 + 이상치 생성
np.random.seed(42)
X_normal = np.random.randn(300, 2) * 0.5 + [2, 2]
X_outlier = np.random.uniform(-4, 8, (20, 2))
X_all = np.vstack([X_normal, X_outlier])

# Isolation Forest
iso = IsolationForest(contamination=0.06, random_state=42)
labels = iso.fit_predict(X_all)  # 1: 정상, -1: 이상

plt.figure(figsize=(8, 6))
plt.scatter(X_all[labels == 1, 0], X_all[labels == 1, 1],
            c='blue', s=20, alpha=0.6, label='정상')
plt.scatter(X_all[labels == -1, 0], X_all[labels == -1, 1],
            c='red', s=50, marker='x', label='이상')
plt.title('Isolation Forest 이상 탐지')
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()

7. 토이 프로블럼 ①: 고객 세분화

마케팅에서 가장 흔한 비지도학습 응용입니다.

7-1. 데이터 생성 및 탐색

import pandas as pd
from sklearn.datasets import make_blobs

# 가상 고객 데이터 생성 (연간 소비액, 방문 빈도, 평균 구매 금액)
np.random.seed(42)
n_customers = 500

# 4개 고객 군집 시뮬레이션
centers = [
    [100, 50, 20],    # VIP: 높은 소비, 자주 방문, 높은 단가
    [30, 30, 15],     # 일반: 중간 소비, 중간 방문
    [10, 5, 10],      # 비활성: 낮은 소비, 드문 방문
    [60, 10, 50],     # 고가구매: 중간 소비, 드문 방문, 높은 단가
]
X_cust, y_true = make_blobs(n_samples=n_customers, centers=centers,
                             cluster_std=[15, 10, 5, 12], random_state=42)
X_cust = np.abs(X_cust)  # 음수 방지

df_cust = pd.DataFrame(X_cust, columns=['연간소비(만원)', '방문횟수', '평균구매(만원)'])
print(df_cust.describe().round(1))

7-2. 전처리 및 최적 K 탐색

from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_cust)

# 엘보우 + 실루엣 동시 분석
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

inertias = []
sil_scores = []
K_range = range(2, 9)

for k in K_range:
    km = KMeans(n_clusters=k, random_state=42, n_init=10)
    labels = km.fit_predict(X_scaled)
    inertias.append(km.inertia_)
    sil_scores.append(silhouette_score(X_scaled, labels))

axes[0].plot(K_range, inertias, 'o-')
axes[0].set_xlabel('K')
axes[0].set_ylabel('이너셔')
axes[0].set_title('엘보우 방법')

axes[1].plot(K_range, sil_scores, 'o-')
axes[1].set_xlabel('K')
axes[1].set_ylabel('실루엣 점수')
axes[1].set_title('실루엣 분석')

plt.tight_layout()
plt.show()

7-3. 군집화 수행 및 해석

# K=4로 군집화
kmeans = KMeans(n_clusters=4, random_state=42, n_init=10)
df_cust['군집'] = kmeans.fit_predict(X_scaled)

# 군집별 특성 요약
summary = df_cust.groupby('군집').agg(['mean', 'count']).round(1)
print("=== 군집별 요약 ===")
print(summary)

# 군집별 프로필 명명
cluster_names = {0: 'VIP 고객', 1: '일반 고객', 2: '비활성 고객', 3: '고가 구매 고객'}
df_cust['고객유형'] = df_cust['군집'].map(cluster_names)
# 3D 시각화
from mpl_toolkits.mplot3d import Axes3D

fig = plt.figure(figsize=(10, 8))
ax = fig.add_subplot(111, projection='3d')

for cluster in sorted(df_cust['군집'].unique()):
    mask = df_cust['군집'] == cluster
    ax.scatter(df_cust.loc[mask, '연간소비(만원)'],
               df_cust.loc[mask, '방문횟수'],
               df_cust.loc[mask, '평균구매(만원)'],
               label=cluster_names.get(cluster, f'군집 {cluster}'),
               s=20, alpha=0.6)

ax.set_xlabel('연간 소비 (만원)')
ax.set_ylabel('방문 횟수')
ax.set_zlabel('평균 구매 금액 (만원)')
ax.set_title('고객 세분화 결과')
ax.legend()
plt.show()

7-4. 마케팅 전략 도출

# 군집별 비교 차트
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
features = ['연간소비(만원)', '방문횟수', '평균구매(만원)']

for ax, feat in zip(axes, features):
    df_cust.groupby('고객유형')[feat].mean().plot(kind='bar', ax=ax, color=['#e74c3c', '#3498db', '#2ecc71', '#f39c12'])
    ax.set_title(feat)
    ax.set_ylabel('평균')
    ax.tick_params(axis='x', rotation=45)

plt.suptitle('고객 군집별 특성 비교')
plt.tight_layout()
plt.show()

print("\n=== 마케팅 전략 제안 ===")
strategies = {
    'VIP 고객': '로열티 프로그램, 전용 혜택, 개인 맞춤 서비스',
    '일반 고객': '업그레이드 유도, 교차 판매, 리워드 프로그램',
    '비활성 고객': '재활성화 캠페인, 할인 쿠폰, 이탈 방지',
    '고가 구매 고객': '프리미엄 상품 추천, VIP 전환 유도',
}
for segment, strategy in strategies.items():
    count = (df_cust['고객유형'] == segment).sum()
    print(f"  [{segment}] ({count}명): {strategy}")

8. 토이 프로블럼 ②: 와인 데이터 시각화 및 분석

8-1. 데이터 로드

from sklearn.datasets import load_wine

wine = load_wine()
X_wine = pd.DataFrame(wine.data, columns=wine.feature_names)
y_wine = wine.target

print(f"샘플 수: {X_wine.shape[0]}, 특성 수: {X_wine.shape[1]}")
print(f"클래스: {wine.target_names}")
print()
print(X_wine.describe().round(2))

8-2. PCA로 시각화

scaler = StandardScaler()
X_wine_scaled = scaler.fit_transform(X_wine)

pca = PCA(n_components=2)
X_pca = pca.fit_transform(X_wine_scaled)

plt.figure(figsize=(8, 6))
for i, name in enumerate(wine.target_names):
    mask = y_wine == i
    plt.scatter(X_pca[mask, 0], X_pca[mask, 1], label=name, s=40, alpha=0.7)

plt.xlabel(f'PC1 ({pca.explained_variance_ratio_[0]:.1%})')
plt.ylabel(f'PC2 ({pca.explained_variance_ratio_[1]:.1%})')
plt.title('PCA: 와인 데이터 (13D → 2D)')
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()

8-3. 군집화 (레이블 없이)

# K-Means로 3개 군집 찾기 (정답 모른다고 가정)
kmeans = KMeans(n_clusters=3, random_state=42, n_init=10)
pred_labels = kmeans.fit_predict(X_wine_scaled)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# K-Means 결과
for k in range(3):
    mask = pred_labels == k
    axes[0].scatter(X_pca[mask, 0], X_pca[mask, 1], s=40, alpha=0.7, label=f'군집 {k}')
axes[0].set_title(f'K-Means 군집화 (실루엣: {silhouette_score(X_wine_scaled, pred_labels):.3f})')
axes[0].legend()

# 실제 레이블
for i, name in enumerate(wine.target_names):
    mask = y_wine == i
    axes[1].scatter(X_pca[mask, 0], X_pca[mask, 1], s=40, alpha=0.7, label=name)
axes[1].set_title('실제 레이블 (참고)')
axes[1].legend()

plt.tight_layout()
plt.show()

# 군집화 성능 평가 (ARI)
from sklearn.metrics import adjusted_rand_score
print(f"Adjusted Rand Index: {adjusted_rand_score(y_wine, pred_labels):.4f}")
print("(1.0이면 완벽한 매칭, 0이면 랜덤)")

8-4. t-SNE로 더 선명한 시각화

X_tsne = TSNE(n_components=2, perplexity=30, random_state=42).fit_transform(X_wine_scaled)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

for ax, (labels, title) in zip(axes, [
    (pred_labels, 'K-Means 군집'),
    (y_wine, '실제 와인 종류')
]):
    for k in range(3):
        mask = labels == k
        ax.scatter(X_tsne[mask, 0], X_tsne[mask, 1], s=40, alpha=0.7, label=f'{k}')
    ax.set_title(title)
    ax.legend()

plt.suptitle('t-SNE 시각화: 와인 데이터')
plt.tight_layout()
plt.show()

알고리즘 선택 가이드

상황추천 알고리즘
구형 군집 + K를 알 때K-Means
비구형 군집, 노이즈 존재DBSCAN
계층적 관계 파악계층적 군집화 + 덴드로그램
고차원 데이터 전처리PCA
시각화 (탐색)t-SNE 또는 UMAP
이상치 탐지Isolation Forest

다음 글에서는 시리즈의 마지막으로, 신경망부터 CNN, RNN, Transformer까지 딥러닝의 핵심을 다룹니다.