인지야공/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 | 모든 쌍의 평균 거리로 |
| weighted | average 와 비슷하되 군집 크기로 가중 |
| 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 에 가까울수록 좋다. 음수면 잘못 묶였다 |
| 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_type | full 각자 / tied 공통 / diag 대각만 / spherical 등방 |
max_iter · tol | EM 의 최대 반복과 수렴 기준 |
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)
세 지표
| 지표 | 식 | 읽는 법 |
|---|---|---|
| 지지도 support | 전체 거래 중 A 와 B 가 같이 나온 비율 | |
| 신뢰도 confidence | A 를 산 사람 중 B 도 산 비율 | |
| 향상도 lift | 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 이 한참 걸린다. 시험 시간에 이걸로 몇 분을
날린 적이 있어서, 큰 그래프는 레이아웃을 그리기 전에 상위 노드만 잘라 서브그래프로
만든다.