[Python] 머신러닝 완벽가이드 - 07. 군집화[실습]
Updated:
파이썬 머신러닝 완벽가이드 교재를 토대로 공부한 내용입니다.
실습과정에서 필요에 따라 내용의 누락 및 추가, 수정사항이 있습니다.
기본 세팅
import numpy as np
import pandas as pd
import matplotlib as mpl
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
%matplotlib inline
%config InlineBackend.figure_format = 'retina'
mpl.rc('font', family='NanumGothic') # 폰트 설정
mpl.rc('axes', unicode_minus=False) # 유니코드에서 음수 부호 설정
# 차트 스타일 설정
sns.set(font="NanumGothic", rc={"axes.unicode_minus":False}, style='darkgrid')
plt.rc("figure", figsize=(10,8))
warnings.filterwarnings("ignore")
1. Online Retail 데이터
실습 예제로는 UCI Machine Learning Repository에서 다운 받을 수 있는 Online Retail 데이터를 사용한다.
해당 데이터로 고객 세그먼테이션(다양한 기준으로 고객을 분류하는 기법)을 진행할 것이다.
고객 세그먼테이션에선 어떤 요소를 기반으로 군집화할 것인지가 중요하다.
여기서는 RFM기법을 사용해본다.
-
RECENCY (R): 가장 최근 상품 구입 일에서 오늘까지의 기간
-
FREQUENCY (F): 상품 구매 횟수
-
MONETARY VALUE (M): 총 구매 금액
1.1 데이터 핸들링
데이터 구조
-
InvoiceNo: 주문번호 (C로 시작하는 것은 취소 주문)
-
StockCode: 제품 코드
-
Description: 제품 설명
-
Quantity: 주문 제품 건수
-
InvoiceDate: 주문 일자
-
UnitPrice: 제품 단가
-
CustomerID: 고객 번호
-
Country: 주문 고객의 국적
retail_df = pd.read_excel("./Online_Retail.xlsx")
retail_df.head()
InvoiceNo | StockCode | Description | Quantity | InvoiceDate | UnitPrice | CustomerID | Country | |
---|---|---|---|---|---|---|---|---|
0 | 536365 | 85123A | WHITE HANGING HEART T-LIGHT HOLDER | 6 | 2010-12-01 08:26:00 | 2.55 | 17850.0 | United Kingdom |
1 | 536365 | 71053 | WHITE METAL LANTERN | 6 | 2010-12-01 08:26:00 | 3.39 | 17850.0 | United Kingdom |
2 | 536365 | 84406B | CREAM CUPID HEARTS COAT HANGER | 8 | 2010-12-01 08:26:00 | 2.75 | 17850.0 | United Kingdom |
3 | 536365 | 84029G | KNITTED UNION FLAG HOT WATER BOTTLE | 6 | 2010-12-01 08:26:00 | 3.39 | 17850.0 | United Kingdom |
4 | 536365 | 84029E | RED WOOLLY HOTTIE WHITE HEART. | 6 | 2010-12-01 08:26:00 | 3.39 | 17850.0 | United Kingdom |
retail_df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 541909 entries, 0 to 541908
Data columns (total 8 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 InvoiceNo 541909 non-null object
1 StockCode 541909 non-null object
2 Description 540455 non-null object
3 Quantity 541909 non-null int64
4 InvoiceDate 541909 non-null datetime64[ns]
5 UnitPrice 541909 non-null float64
6 CustomerID 406829 non-null float64
7 Country 541909 non-null object
dtypes: datetime64[ns](1), float64(2), int64(1), object(4)
memory usage: 33.1+ MB
-
데이터는 541,909 x 8로 이루어져 있다.
-
Description, CustomerID는 결측이 존재하며, CustomerID는 약 13만 건 정도의 결측값이 존재한다.
-
여기선 고객 세그먼테이션을 수행하므로 CustomerID가 결측이면 모두 제거한다.
retail_df.describe().T
count | mean | std | min | 25% | 50% | 75% | max | |
---|---|---|---|---|---|---|---|---|
Quantity | 541909.0 | 9.552250 | 218.081158 | -80995.00 | 1.00 | 3.00 | 10.00 | 80995.0 |
UnitPrice | 541909.0 | 4.611114 | 96.759853 | -11062.06 | 1.25 | 2.08 | 4.13 | 38970.0 |
CustomerID | 406829.0 | 15287.690570 | 1713.600303 | 12346.00 | 13953.00 | 15152.00 | 16791.00 | 18287.0 |
-
Quantity는 주문 제품 건수로 음수가 존재해선 안되는데 min 값에 음수가 있다.
-
UnitPrice 역시 제품 단가로 음수가 존재해선 안되는데 min 값에 음수가 있다.
-
사실 여기서 음수는 취소된 건수, 단가를 의미한다고 한다(InvoiceNo가 C로 시작).
-
여기선 음수값들은 모두 제거하고 분석을 진행하도록 한다.
# 데이터 핸들링
con_1 = retail_df.Quantity > 0
con_2 = retail_df.UnitPrice > 0
con_3 = retail_df.CustomerID.notnull()
retail_df = retail_df[con_1 & con_2 & con_3]
print(retail_df.shape)
print(retail_df.isnull().sum())
(397884, 8)
InvoiceNo 0
StockCode 0
Description 0
Quantity 0
InvoiceDate 0
UnitPrice 0
CustomerID 0
Country 0
dtype: int64
-
앞서 설정한 조건으로 데이터를 삭제하여 397,884건만 남겨두었다.
-
더불어서 Description의 결측값도 모두 삭제되었다.
# 주문 국적 확인
retail_df.Country.value_counts()[:10]
United Kingdom 354321
Germany 9040
France 8341
EIRE 7236
Spain 2484
Netherlands 2359
Belgium 2031
Switzerland 1841
Portugal 1462
Australia 1182
Name: Country, dtype: int64
- 주문 국적은 전체 데이터의 대부분이 영국으로 나타나 영국만 남기기로 한다.
# 주문 국적 영국만 남기기
retail_df = retail_df[retail_df["Country"] == "United Kingdom"]
print(retail_df.shape)
(354321, 8)
# 데이터 추가 가공
retail_df["sale_amount"] = retail_df["Quantity"] * retail_df["UnitPrice"]
retail_df["CustomerID"] = retail_df["CustomerID"].astype(int)
-
Quantity(주문 제품 건수)와 UnitPrice(제품 단가)를 곱하여 sale_amount(주문 금액)을 생성하였다.
-
CustomerID는 기존 float형을 int형으로 바꿔주었다.
# 주문번호 + 상품코드
print(retail_df.groupby(["InvoiceNo", "StockCode"]).size().mean())
1.028702077315023
-
주문번호 + 상품코드 갯수의 평균은 거의 1로서 주문번호 + 상품코드는 식별자 레벨로 사용 가능하다.
-
다만 여기선 RFM 기반의 고객 세그먼테이션으로 고객 레벨로 분석을 해야한다.
-
따라서, 주문번호 + 상품코드 기준 데이터를 고객 기준의 RFM 데이터로 변경한다.
# Recency (R): 가장 최근 상품 구입 일에서 오늘까지의 기간
# Frequency (F): 상품 구매 횟수
# Monetary Value (M): 총 구매 금액
aggregations = {
"InvoiceDate": "max", # 최근 구입 일자
"InvoiceNo": "count", # 상품 구매 횟수
"sale_amount": "sum" # 총 구매 금액
}
# 고객 기준의 RFM 데이터
cust_df = retail_df.groupby("CustomerID").agg(aggregations)
cust_df = cust_df.rename(columns = {"InvoiceDate":"Recency",
"InvoiceNo":"Frequency",
"sale_amount":"Monetary"}
)
cust_df = cust_df.reset_index()
cust_df.head(3)
CustomerID | Recency | Frequency | Monetary | |
---|---|---|---|---|
0 | 12346 | 2011-01-18 10:01:00 | 1 | 77183.60 |
1 | 12747 | 2011-12-07 14:34:00 | 103 | 4196.01 |
2 | 12748 | 2011-12-09 12:20:00 | 4595 | 33719.73 |
-
RFM 데이터를 만들기 위해 기존 컬럼을
agg()
를 활용해서 각각 다른 연산을 수행하였다. -
Recency의 경우 현재 최근 구입 날짜로 변경해서 한번 더 작업이 필요하다.
import datetime as dt
# Recency
cust_df['Recency'] = dt.datetime(2011,12,10) - cust_df['Recency']
cust_df['Recency'] = cust_df['Recency'].apply(lambda x: x.days+1)
cust_df.head(3)
CustomerID | Recency | Frequency | Monetary | |
---|---|---|---|---|
0 | 12346 | 326 | 1 | 77183.60 |
1 | 12747 | 3 | 103 | 4196.01 |
2 | 12748 | 1 | 4595 | 33719.73 |
-
현재 사용하는 데이터는 2010년 12월 1일 ~ 2011년 12월 9일까지의 데이터이다.
-
따라서 Recency를 구할 때 오늘까지의 기간은 2011년 12월 10일까지의 기간으로 계산하였다.
print(cust_df.shape)
(3920, 4)
- 지금까지의 가공으로 고객 기준의 RFM 데이터는 3,920 x 4로 이루어져있다.
fig, axs = plt.subplots(1,3, figsize=(15,4))
sns.histplot(cust_df['Recency'], kde=True, ax=axs[0])
axs[0].set_title('Recency Histogram')
sns.histplot(cust_df['Frequency'], kde=True, ax=axs[1])
axs[1].set_title('Frequency Histogram')
sns.histplot(cust_df['Monetary'], kde=True, ax=axs[2])
axs[2].set_title('Monetary Histogram')
plt.show()
-
cust_df의 R,F,M의 분포를 확인하였다.
-
모두 치우친 분포로 나타났으며 특히, F와 M은 매우 심하게 치우쳐있다.
-
이는 이 데이터가 개인 고객과 소매업체의 대규모 주문이 포함되어 있어 나타난 현상이다.
cust_df.describe().iloc[:,1:]
Recency | Frequency | Monetary | |
---|---|---|---|
count | 3920.000000 | 3920.000000 | 3920.000000 |
mean | 92.742092 | 90.388010 | 1864.385601 |
std | 99.533485 | 217.808385 | 7482.817477 |
min | 1.000000 | 1.000000 | 3.750000 |
25% | 18.000000 | 17.000000 | 300.280000 |
50% | 51.000000 | 41.000000 | 652.280000 |
75% | 143.000000 | 99.250000 | 1576.585000 |
max | 374.000000 | 7847.000000 | 259657.300000 |
-
각 컬럼들의 평균은 중앙값보다 훨씬 크게 나타나며, 75% 분위수에 비해 최대값이 훨씬 크다.
-
왜곡이 심한 데이터를 이용해서 군집분석을 진행해보자.
1.2 군집분석
실루엣 계수 및 군집 시각화 함수는 교재 소스코드를 사용하였다.
실루엣 계수 시각화 함수
### 여러개의 클러스터링 갯수를 List로 입력 받아 각각의 실루엣 계수를 면적으로 시각화한 함수 작성
def visualize_silhouette(cluster_lists, X_features):
from sklearn.datasets import make_blobs
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_samples, silhouette_score
import matplotlib.pyplot as plt
import matplotlib.cm as cm
import math
# 입력값으로 클러스터링 갯수들을 리스트로 받아서, 각 갯수별로 클러스터링을 적용하고 실루엣 개수를 구함
n_cols = len(cluster_lists)
# plt.subplots()으로 리스트에 기재된 클러스터링 수만큼의 sub figures를 가지는 axs 생성
fig, axs = plt.subplots(figsize=(4*n_cols, 4), nrows=1, ncols=n_cols)
# 리스트에 기재된 클러스터링 갯수들을 차례로 iteration 수행하면서 실루엣 개수 시각화
for ind, n_cluster in enumerate(cluster_lists):
# KMeans 클러스터링 수행하고, 실루엣 스코어와 개별 데이터의 실루엣 값 계산.
clusterer = KMeans(n_clusters = n_cluster, max_iter=500, random_state=0)
cluster_labels = clusterer.fit_predict(X_features)
sil_avg = silhouette_score(X_features, cluster_labels)
sil_values = silhouette_samples(X_features, cluster_labels)
y_lower = 10
axs[ind].set_title('Number of Cluster : '+ str(n_cluster)+'\n' \
'Silhouette Score :' + str(round(sil_avg,3)) )
axs[ind].set_xlabel("The silhouette coefficient values")
axs[ind].set_ylabel("Cluster label")
axs[ind].set_xlim([-0.1, 1])
axs[ind].set_ylim([0, len(X_features) + (n_cluster + 1) * 10])
axs[ind].set_yticks([]) # Clear the yaxis labels / ticks
axs[ind].set_xticks([0, 0.2, 0.4, 0.6, 0.8, 1])
# 클러스터링 갯수별로 fill_betweenx( )형태의 막대 그래프 표현.
for i in range(n_cluster):
ith_cluster_sil_values = sil_values[cluster_labels==i]
ith_cluster_sil_values.sort()
size_cluster_i = ith_cluster_sil_values.shape[0]
y_upper = y_lower + size_cluster_i
color = cm.nipy_spectral(float(i) / n_cluster)
axs[ind].fill_betweenx(np.arange(y_lower, y_upper), 0, ith_cluster_sil_values, \
facecolor=color, edgecolor=color, alpha=0.7)
axs[ind].text(-0.05, y_lower + 0.5 * size_cluster_i, str(i))
y_lower = y_upper + 10
axs[ind].axvline(x=sil_avg, color="red", linestyle="--")
군집갯수별 시각화 함수
### 여러개의 클러스터링 갯수를 List로 입력 받아 각각의 클러스터링 결과를 시각화
def visualize_kmeans_plot_multi(cluster_lists, X_features):
from sklearn.cluster import KMeans
from sklearn.decomposition import PCA
import pandas as pd
import numpy as np
# plt.subplots()으로 리스트에 기재된 클러스터링 만큼의 sub figures를 가지는 axs 생성
n_cols = len(cluster_lists)
fig, axs = plt.subplots(figsize=(4*n_cols, 4), nrows=1, ncols=n_cols)
# 입력 데이터의 FEATURE가 여러개일 경우 2차원 데이터 시각화가 어려우므로 PCA 변환하여 2차원 시각화
pca = PCA(n_components=2)
pca_transformed = pca.fit_transform(X_features)
dataframe = pd.DataFrame(pca_transformed, columns=['PCA1','PCA2'])
# 리스트에 기재된 클러스터링 갯수들을 차례로 iteration 수행하면서 KMeans 클러스터링 수행하고 시각화
for ind, n_cluster in enumerate(cluster_lists):
# KMeans 클러스터링으로 클러스터링 결과를 dataframe에 저장.
clusterer = KMeans(n_clusters = n_cluster, max_iter=500, random_state=0)
cluster_labels = clusterer.fit_predict(pca_transformed)
dataframe['cluster']=cluster_labels
unique_labels = np.unique(clusterer.labels_)
markers=['o', 's', '^', 'x', '*']
# 클러스터링 결과값 별로 scatter plot 으로 시각화
for label in unique_labels:
label_df = dataframe[dataframe['cluster']==label]
if label == -1:
cluster_legend = 'Noise'
else :
cluster_legend = 'Cluster '+str(label)
axs[ind].scatter(x=label_df['PCA1'], y=label_df['PCA2'], s=70,\
edgecolor='k', marker=markers[label], label=cluster_legend)
axs[ind].set_title('Number of Cluster : '+ str(n_cluster))
axs[ind].legend(loc='upper right')
plt.show()
1.2.1 StandardScaler
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_samples, silhouette_score
# StandardScaler
X_features = cust_df[['Recency','Frequency','Monetary']].values
X_features_scaled = StandardScaler().fit_transform(X_features)
# KMeans
kmeans = KMeans(n_clusters=3, random_state=0)
labels = kmeans.fit_predict(X_features_scaled)
cust_df["cluster_label"] = labels
# 실루엣 스코어
silhouette_kmeans = silhouette_score(X_features_scaled, labels)
print(f"실루엣 스코어: {silhouette_kmeans:.3f}")
실루엣 스코어: 0.592
-
KMeans 군집분석을 진행하였고 피처는 표준화하였다.
-
실루엣 스코어가 0.592로 제법 좋은 수치로 나타났다.
-
각 군집별 실루엣 스코어를 확인해보자.
visualize_silhouette([2,3,4,5],X_features_scaled)
visualize_kmeans_plot_multi([2,3,4,5],X_features_scaled)
-
군집수가 2일 때는 0번 군집의 실루엣 계수가 대부분 평균 아래이다.
-
군집수가 3인 경우는 2번 군집, 4인 경우는 2,3번 군집의 데이터 건수가 매우 작다.
-
이는 앞서 왜곡된 분포를 확인했듯이 소매업체의 대규모 주문이 소수의 군집을 이룬것이다.
-
이렇게 특이한 데이터 셋을 분리하는 것이 군집화의 목표 중 하나이다.
-
다만 이 경우 왜곡정도가 너무 심해 굳이 군집분석하지 않아도 충분히 분리 가능하다.
-
실제 이런 자료를 다루는 도메인이 있다면 이런 결과를 미리 알았을 것이다.
1.2.2 로그 변환
# 로그 변환
cust_df['Recency_log'] = np.log1p(cust_df['Recency'])
cust_df['Frequency_log'] = np.log1p(cust_df['Frequency'])
cust_df['Monetary_log'] = np.log1p(cust_df['Monetary'])
# StandardScaler
X_features = cust_df[['Recency_log','Frequency_log','Monetary_log']].values
X_features_scaled = StandardScaler().fit_transform(X_features)
# KMeans
kmeans = KMeans(n_clusters=3, random_state=0)
labels = kmeans.fit_predict(X_features_scaled)
cust_df["cluster_label"] = labels
# 실루엣 스코어
silhouette_kmeans = silhouette_score(X_features_scaled, labels)
print(f"실루엣 스코어: {silhouette_kmeans:.3f}")
실루엣 스코어: 0.303
-
이번엔 왜곡 정도를 낮추기 위해 피처 로그 변환을 적용하였다.
-
나머지 과정은 동일하게 수행하였을 때 실루엣 스코어는 0.303으로 낮게 나타났다.
visualize_silhouette([2,3,4,5],X_features_scaled)
visualize_kmeans_plot_multi([2,3,4,5],X_features_scaled)
-
전체 실루엣 스코어가 감소한 대신 각 군집별 실루엣 스코어가 균일하게 나타났다.
-
이처럼 왜곡된 데이터 셋은 로그 변환으로 1차 변환 후 적용하면 더 나은 결과가 나오기도 한다.
-
참고로 위 시각화 코드를 자세히 보면 실루엣 스코어에서의 군집은 입력한 피처를 사용한다.
-
반면 군집 시각화 함수는 입력한 피처를 받아 PCA 적용 후 군집을 만든다.
Leave a comment