인지야공

인지야공/ADP/7번째 글

ADP 실기 — 군집·연관·네트워크, 정답이 없는 문제들

군집은 정답이 없다. 그래서 배점이 “군집의 특성을 분석할 것”, “비즈니스적 판단을 제시할 것”에 붙는다(25회·26회). 클러스터를 만드는 것까지는 다들 한다. 그 뒤에 각 군집이 어떤 고객인지 문장으로 쓰는 게 점수다.

25회와 26회 연속으로 구매 데이터 군집이 나왔다. 사실상 RFM 을 만들 줄 아느냐를 물은 것이다.

계층 군집

from scipy.cluster.hierarchy import dendrogram, linkage, fcluster
from sklearn.preprocessing import StandardScaler

X_std = StandardScaler().fit_transform(X)

Z = linkage(X_std, method='ward')

plt.figure(figsize=(10, 8))
plt.title('Hierarchical Clustering Dendrogram')
plt.xlabel('Sample index'); plt.ylabel('Distance')
dendrogram(Z, leaf_rotation=90., leaf_font_size=8.)
plt.show()

clusters = fcluster(Z, 10, criterion='distance')   # 거리 10 에서 잘라 군집 번호를 얻는다

덴드로그램은 그림이지 결과가 아니다. fcluster 로 잘라야 각 관측치의 소속이 나온다. 23회에 “계층적 군집 분석을 위해 덴드로그램 작성”이 10점이었다.

연결 방식(linkage)은 물어볼 수 있으니 외웠다.

method두 군집의 거리를
single가장 가까운 두 점의 거리로 — 사슬처럼 길게 이어진다
complete가장 먼 두 점의 거리로 — 둥글고 비슷한 크기로 뭉친다
average모든 쌍의 평균 거리로
weightedaverage 와 비슷하되 군집 크기로 가중
centroid두 중심점 사이의 거리로
median두 중간점 사이의 거리로
ward군집 내 제곱합(SSE) 증가가 가장 작은 쪽으로 합친다

기본은 ward 다. 세 가지를 다 그려서 비교하라는 문제가 나오면 single / complete / average 를 나란히 그린다.

for method in ['single', 'complete', 'average']:
    dendrogram(linkage(X_std, method=method, metric='euclidean'))
    plt.title(method); plt.show()

KMeans 와 k 정하기

from sklearn.cluster import KMeans

sse = []
for k in range(1, 11):
    kmeans = KMeans(n_clusters=k, random_state=0).fit(X)
    sse.append(kmeans.inertia_)

plt.plot(range(1, 11), sse, marker='o')
plt.xlabel('Number of clusters (k)'); plt.ylabel('SSE')
plt.show()

팔꿈치(elbow) 는 SSE 가 꺾이는 지점이다. inertia_ 가 군집 내 제곱합이고, k 를 늘리면 무조건 줄어들기 때문에 최솟값을 고르면 k = n 이 된다. 그래서 꺾이는 곳을 본다.

팔꿈치가 애매하면 실루엣을 같이 본다. 이쪽이 더 확실할 때가 많다.

from sklearn.metrics import silhouette_score

for k in [2, 3, 4, 5, 6, 7, 8]:
    labels = KMeans(n_clusters=k, max_iter=50).fit(X_scaled).labels_
    print("k={0}: silhouette = {1}".format(k, silhouette_score(X_scaled, labels)))

군집 평가 세 가지

25회의 “군집 결과의 적합성을 군집 내 응집도, 군집 간 분리도의 개념을 사용해서 서술”이 이 표 전체를 묻는 문제였다.

지표계산방향
실루엣 계수같은 군집과의 거리 vs 다른 군집과의 거리. −1∼1-1 \sim 11 에 가까울수록 좋다. 음수면 잘못 묶였다
Calinski-Harabasz군집 간 분산 / 군집 내 분산높을수록 좋다
Davies-Bouldin군집 내 산포 / 군집 간 거리낮을수록 좋다
from sklearn.metrics import silhouette_score, calinski_harabasz_score, davies_bouldin_score

print(silhouette_score(X, labels))
print(calinski_harabasz_score(X, labels))
print(davies_bouldin_score(X, labels))

세 지표가 하는 말은 결국 같다 — 안은 촘촘하게(응집도), 밖은 멀게(분리도). Davies-Bouldin 만 방향이 반대라 여기서 자주 틀린다.

DBSCAN

from sklearn.cluster import DBSCAN

dbscan = DBSCAN(eps=0.5, min_samples=5).fit(X)
plt.scatter(X[:, 0], X[:, 1], c=dbscan.labels_, cmap='rainbow')
plt.show()
파라미터뜻
eps이웃으로 인정하는 반경
min_samples핵심점이 되기 위한 최소 이웃 수

k 를 정하지 않아도 되고, 이상치를 -1 로 따로 빼 준다는 게 장점이다. 밀도가 다른 군집이 섞여 있으면 약하다. 26회처럼 “KMeans, DBSCAN 등으로 군집을 생성”이라는 문제는 둘 다 돌리고 실루엣으로 비교하라는 뜻이다.

silhouette_k = silhouette_score(X_scaled, kmeans.labels_)
silhouette_d = silhouette_score(X_scaled, dbscan.labels_)
print(silhouette_k, silhouette_d)

혼합분포 군집 (EM)

from sklearn.mixture import GaussianMixture

gmm = GaussianMixture(n_components=4).fit(X)
y_pred = gmm.predict(X)
plt.scatter(X[:, 0], X[:, 1], c=y_pred, cmap='viridis')
plt.show()
파라미터
n_components혼합분포의 개수
covariance_typefull 각자 / tied 공통 / diag 대각만 / spherical 등방
max_iter · tolEM 의 최대 반복과 수렴 기준

KMeans 가 “가장 가까운 중심”으로 딱 잘라 배정한다면, GMM 은 각 군집에 속할 확률을 준다 (predict_proba). 경계가 겹치는 데이터에서 유리하고, 타원형 군집도 잡는다.

차원 축소로 그리기

군집 결과는 변수가 3개만 넘어도 못 그린다. PCA 로 2차원에 눕힌다.

from sklearn.decomposition import PCA

X_pca = PCA(n_components=2).fit_transform(X_std)
plt.scatter(X_pca[:, 0], X_pca[:, 1], c=clusters, cmap='viridis')
plt.xlabel('PC1'); plt.ylabel('PC2')
plt.show()

PCA 는 군집을 만들기 위한 게 아니라 보여 주기 위한 것이다. 군집은 원래 공간에서 만들고, 그림만 축소된 공간에서 그린다.

RFM — 25·26회에 실제로 나온 흐름

거래 로그를 고객 단위로 접는 것이 문제의 절반이다.

retail = pd.read_csv('data.csv', encoding='utf-8')
retail = retail.dropna()
retail['CustomerID'] = retail['CustomerID'].astype(str)

# Monetary — 고객별 총 구매액
retail['Amount'] = retail['Quantity'] * retail['UnitPrice']
rfm_m = retail.groupby('CustomerID')['Amount'].sum().reset_index()

# Frequency — 고객별 구매 횟수
rfm_f = retail.groupby('CustomerID')['InvoiceNo'].count().reset_index()
rfm_f.columns = ['CustomerID', 'Frequency']

rfm = pd.merge(rfm_m, rfm_f, on='CustomerID', how='inner')

# Recency — 마지막 구매로부터 지난 날
retail['InvoiceDate'] = pd.to_datetime(retail['InvoiceDate'], format='%m/%d/%Y %H:%M')
retail['Diff'] = retail['InvoiceDate'].max() - retail['InvoiceDate']
rfm_p = retail.groupby('CustomerID')['Diff'].min().reset_index()
rfm_p['Diff'] = rfm_p['Diff'].dt.days

rfm = pd.merge(rfm, rfm_p, on='CustomerID', how='inner')
rfm.columns = ['CustomerID', 'Amount', 'Frequency', 'Recency']

Recency 는 min() 이다. 각 고객의 “가장 최근” 구매를 찾는 것이고, max_date - 구매일 이 작을수록 최근이니 최솟값을 취한다. max() 를 쓰면 가장 오래된 구매가 잡힌다 — 이걸 시험장에서 헷갈리면 결과 전체가 뒤집힌다.

그다음은 이상치 정리 → 표준화 → elbow → 실루엣 → 최종 모델 → 군집별 박스플롯이다.

rfm_df_scaled = StandardScaler().fit_transform(rfm[['Amount', 'Frequency', 'Recency']])

kmeans = KMeans(n_clusters=6, max_iter=50).fit(rfm_df_scaled)
rfm['Cluster_Id'] = kmeans.labels_

for col in ['Amount', 'Frequency', 'Recency']:
    sns.boxplot(x='Cluster_Id', y=col, data=rfm)
    plt.show()

이 박스플롯 세 장이 “군집의 특성을 분석하라”에 대한 답이다. Amount 높고 Frequency 높고 Recency 낮은 군집은 충성 고객, 셋 다 반대면 이탈 고객이다. 여기까지 문장으로 쓰면 배점을 다 가져간다.

26회는 한 발 더 나갔다 — “각 군집 별 대표 추천 상품을 도출”, “CustomerID 12413 고객에게 상품 추천”. 군집을 붙인 뒤 그 군집에서 많이 팔린 상품을 세면 된다.

merged = retail.merge(rfm[['CustomerID', 'Cluster_Id']], on='CustomerID')
top_by_cluster = (merged.groupby(['Cluster_Id', 'StockCode'])['Quantity']
                        .sum().reset_index()
                        .sort_values(['Cluster_Id', 'Quantity'], ascending=[True, False]))

target_cluster = rfm.loc[rfm.CustomerID == '12413', 'Cluster_Id'].iloc[0]
print(top_by_cluster[top_by_cluster.Cluster_Id == target_cluster].head(5))

이미 산 상품은 빼고 추천한다는 한 줄까지 쓰면 더 낫다.

연관분석

apyori 는 시험 환경에 없다. mlxtend 로 푼다. 이게 이 글에서 제일 중요한 한 줄이다.

from mlxtend.preprocessing import TransactionEncoder
from mlxtend.frequent_patterns import apriori, association_rules

# 거래를 리스트의 리스트로 만든다
transactions = []
for i in range(len(df)):
    transactions.append([*df.iloc[i, 1:]])

te = TransactionEncoder()
te_ary = te.fit(transactions).transform(transactions)
df_te = pd.DataFrame(te_ary, columns=te.columns_)

frequent_itemsets = apriori(df_te, min_support=0.002, use_colnames=True)
rules = association_rules(frequent_itemsets, metric="confidence", min_threshold=0.8)

TransactionEncoder 가 거래 리스트를 True/False 원-핫 표로 바꾼다. apriori 는 그 형태만 받는다. 원본이 이미 표 형태라면 이 단계를 건너뛴다.

규칙이 수천 개 나오므로 길이로 거른다.

rules['antecedents_len'] = rules['antecedents'].apply(len)
rules['consequents_len'] = rules['consequents'].apply(len)
rules = rules[(rules['antecedents_len'] >= 2) & (rules['antecedents_len'] <= 6)
              & (rules['consequents_len'] == 1)]

top_rules = rules.sort_values('lift', ascending=False)
top_rules[:30].to_csv('result.csv', index=False)

세 지표

지표식읽는 법
지지도 supportP(A∩B)P(A \cap B)전체 거래 중 A 와 B 가 같이 나온 비율
신뢰도 confidenceP(B∣A)P(B\lvert A)A 를 산 사람 중 B 도 산 비율
향상도 liftP(B∣A)P(B)\dfrac{P(B\lvert A)}{P(B)}1 보다 크면 양의 관계, 1 이면 독립, 1 보다 작으면 음의 관계

실제로 나온 규칙 하나를 예로 들면 이렇게 읽는다.

items: {'Party of 5 or more', 'High Tip'}
support = 0.0287, confidence = 0.78, lift = 3.58

전체 거래의 2.87% 에서 둘이 같이 나타났고, 5인 이상 일행이면 78% 가 팁을 많이 냈으며, 그 확률은 일반적인 경우보다 3.58배 높다. 향상도가 1을 넘으므로 우연이 아니다.

신뢰도만 높고 향상도가 1 근처인 규칙은 쓸모없다. B 가 원래 자주 팔리는 물건이면 무엇을 사든 B 가 따라 나온다. 향상도가 그 함정을 걸러 준다.

네트워크 분석

18회에 SOM, 그 외에도 관계형 데이터가 나오면 networkx 다.

import networkx as nx

edges = df[['sender', 'receiver']].drop_duplicates()
G = nx.from_pandas_edgelist(edges, "sender", "receiver")

pos = nx.spring_layout(G, k=0.1)
plt.figure(figsize=(20, 20))
nx.draw_networkx_nodes(G, pos, node_size=5)
nx.draw_networkx_edges(G, pos, alpha=0.1)
plt.axis("off")
plt.show()

중심성으로 “누가 중요한가”를 뽑는다.

degree_centrality = nx.degree_centrality(G)          # 연결 중심성
for node, c in sorted(degree_centrality.items(), key=lambda x: x[1], reverse=True)[:10]:
    print(f"{node}: {c:.4f}")
중심성뜻
연결 degree직접 연결된 이웃이 많다
근접 closeness다른 모두에게 평균적으로 가깝다
매개 betweenness최단 경로 위에 자주 놓인다. 끊기면 망이 갈라진다
위세 eigenvector중요한 노드와 연결돼 있다

커뮤니티는 모듈성 최적화로 나눈다.

from networkx.algorithms.community import greedy_modularity_communities

communities = greedy_modularity_communities(G)
for idx, community in enumerate(communities):
    print(f"Community {idx+1}: {len(community)} nodes")

노드가 수천 개를 넘어가면 spring_layout 이 한참 걸린다. 시험 시간에 이걸로 몇 분을 날린 적이 있어서, 큰 그래프는 레이아웃을 그리기 전에 상위 노드만 잘라 서브그래프로 만든다.

표시는 이 브라우저에만 남는다. 서버로 가는 것은 없다.