Go together

If you want to go fast, go alone. If you want to go far, go together.

파이썬 머신 러닝 완벽 가이드

군집화 실습 - 고객 세그먼테이션

NowChan 2021. 12. 2. 20:07

배운 내용

  1. DataFrame.isnull()
  2. Series.value_counts()
  3. DataFrame.astype()
  4. DataFrame.groupby(columns).method()
  5. GroupBy.agg()
  6. dt.datetime()
  7. pd.to_datetime()
  8. Series.apply(lambda)
  9. silhouette_score
  10. silhouette_samples

고객 세그먼테이션의 정의와 기법

고객을 세그먼테이션(Customer Segmentation)은 다양한 기준으로 고객을 분류하는 기법을 지칭합니다. 고객 세그먼테이션은 CRM이나 마케팅의 중요 기반 요소입니다. 고객 세그먼테이션의 주요 목표는 타깃 마케팅입니다. 타깃 마케팅이란 고객을 여러 특성에 맞게 세분화해서 그 유형에 따라 맞춤형 마케팅이나 서비스를 제공하는 것입니다. 

 

기업의 마케팅은 고객의 상품 구매 이력에서 출발합니다. 고객 세그먼테이션은 고객의 어떤 요소를 기반으로 군집화할 것인지 결정하는 것이 중요한데, 여기서는 기본적인 고객 분석 요소인 RFM 기법을 이용하겠습니다. RFM의 의미는 다음과 같습니다.

  • RECENCY (R) : 가장 최근 상품 구입 일에서 오늘까지의 시간
  • FREQUENCY (F) : 상품 구매 횟수
  • MONETARY VALUE (M) : 총 구매 금액

 

 

데이터 세트 로딩과 데이터 클렌징

온라인 판매 데이터를 기반으로 고객 세그먼테이션을 군집화 기반으로 수행해보겠습니다. 데이터 세트는 https://www.kaggle.com/vijayuv/onlineretail에서 내려받을 수 있습니다.

import pandas as pd
import datetime
import math
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline

retail_df = pd.read_csv('/content/drive/MyDrive/military/Online_Retail/\
						OnlineRetail.csv', encoding='latin1')
retail_df.head(3)
'''
결과1
'''

결과1

Invoice(주문번호) + StockCode(제품코드)를 기반으로 아래의 칼럼으로 구성돼있습니다.

  • InvoiceNo: 주문번호, 'C'로 시작하는 것은 취소 주문입니다.
  • StockCode: 제품 코드(Item Code)
  • Description: 제품 설명
  • Quantity: 주문 제품 건수
  • InvoiceDate: 주문 일자
  • UnitPrice: 제품 단가
  • CustomerID: 고객 번호
  • Country: 국가명(주문 고객의 국적)

 

 

데이터 세트의 전체 건수, 칼럼 타입, Null 개수를 확인해보겠습니다.

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  object 
 5   UnitPrice    541909 non-null  float64
 6   CustomerID   406829 non-null  float64
 7   Country      541909 non-null  object 
dtypes: float64(2), int64(1), object(5)
memory usage: 33.1+ MB
'''

CustomerID값이 Null인 데이터가 13만 5천 건이나 되는데, 고객 세그먼테이션을 수행하는데 고객 식별 번호가 없는 데이터는 필요가 없기에 삭제합니다. 또한, Quantity, UnitPrice가 0보다 작은 경우는 대표적인 오류 데이터입니다. 이 경우 InvoiceNo의 앞자리가 'C'로 돼 있는데, 분석의 효율성을 위해 이 데이터는 모두 삭제하겠습니다.

 

DataFrame.isnull()

불린 인덱싱을 적용해 Quantity > 0, UnitPrice > 0이고 CustmerID이 Not Null인 값만 다시 필터링하겠습니다.

retail_df = retail_df[retail_df['Quantity'] > 0]
retail_df = retail_df[retail_df['UnitPrice'] > 0]
retail_df = retail_df[retail_df['CustomerID'].notnull()]
print(retail_df.shape)
retail_df.isnull().sum()
'''
(397884, 8)
InvoiceNo      0
StockCode      0
Description    0
Quantity       0
InvoiceDate    0
UnitPrice      0
CustomerID     0
Country        0
dtype: int64
'''

전체 데이터가 54만 건에서 39만 건으로 줄었습니다. 하지만, Null값은 칼럼에 존재하지 않습니다. 주목해야할 점은 Country 칼럼은 주문 고객 국가입니다.

 

Series.value_counts()

주요 주문 고객은 영국인데, 이 외에도 EU의 여러 나라와 영연방 국가들이 포함돼있습니다.

retail_df['Country'].value_counts()[:5]
'''
United Kingdom    354321
Germany             9040
France              8341
EIRE                7236
Spain               2484
Name: Country, dtype: int64
'''

영국이 대다수를 차지하므로, 다른 국가의 데이터는 모두 제외하겠습니다.

retail_df = retail_df[retail_df['Country']=='United Kingdom']
print(retail_df.shape)
'''
(354321, 8)
'''

최종 데이터는 354,321건이며 이로써 데이터 정제를 마치겠습니다.

 

 

RFM 기반 데이터 가공

고객 세그먼테이션 군집화를 RFM 기반으로 수행하겠습니다. 이를 위해 필요한 데이터를 가공해보겠습니다. 먼저 UnitPriceQuantity를 곱해서 주문 금액 데이터를 만들겠습니다. 그리고 CustomerNo도 더 편리한 식별성을 위해 int형으로 변경하겠습니다.

 

DataFrame.astype()

retail_df['sale_amount'] = retail_df['Quantity'] * retail_df['UnitPrice']
retail_df['CustomerID'] = retail_df['CustomerID'].astype(int)

해당 온라인 판매 데이터 세트는 주문 횟수와 금액이 각 개인 별로 차이가 압도적으로 많이 나는 특성을 가지고 있습니다. Top-5 주문 건수와 주문 금액을 가진 고객 데이터를 추출해 보겠습니다.

 

DataFrame.groupby(columns).method()

print(retail_df['CustomerID'].value_counts().head(5))
print(retail_df.groupby('CustomerID')['sale_amount'].sum().sort_values(ascending=False)[:5])
'''
17841    7847
14096    5111
12748    4595
14606    2700
15311    2379
Name: CustomerID, dtype: int64
CustomerID
18102    259657.30
17450    194550.79
16446    168472.50
17511     91062.38
16029     81024.84
Name: sale_amount, dtype: float64
'''

몇몇 특정 고객이 많은 주문 건수와 주문 금액을 가지고 있습니다.

 

주어진 데이터 세트는 전형적인 판매 데이터 세트와 같이 주문번호(InvoiceNo)와 상품코드(StockCode) 레벨의 식별자로 돼 있습니다. 두 칼럼으로 Group by를 수행하면 거의 1에 가깝게 유일한 식별자 레벨이 됨을 알 수 있습니다.

retail_df.groupby(['InvoiceNo', 'StockCode'])['InvoiceNo'].count().mean()
'''
1.028702077315023
'''

InvoiceNoStockCode 기준의 데이터를 개별 고객(CustomerID) 기준 데이터로 Group by를 해야 합니다. DataFrame.groupby()만 수행해서는 서로 다른 aggregation 연산을 함께 수행하기 어려우므로 agg()에 columns와 수행할 함수명을 딕셔너리 형태로 입력하면 서로 다른 aggregation 연산을 수행할 수 있습니다.

 

GroupBy.agg()

# DataFrame의 groupby()의 multiple 연산을 위해 agg() 이용
# Recency는 InvoiceDate에 max(), Frequency는 InvoiceNO에 count(),
# Moneraty Value는 sale_amount에 sum()을 적용
aggregations ={
    'InvoiceDate' : 'max',
    'InvoiceNo' : 'count',
    'sale_amount' : 'sum'
}

cust_df = retail_df.groupby('CustomerID').agg(aggregations)
# groupby된 결과 칼럼 값을 Recency, Frequency, Monetary로 변경
cust_df = cust_df.rename(columns={
    'InvoiceDate':'Recency',
    'InvoiceNo':'Frequency',
    'sale_amount':'Monetary'
})
cust_df=cust_df.reset_index()
cust_df.head(3)
'''
결과2
'''

결과2

Recency는 가장 최근의 주문인데, (현재 날짜 - 주문한 날짜)를 한 후 일자 데이터(days)만 추출해 생성하겠습니다. 데이터가 2010/12/1 ~ 2011/12/9까지의 데이터 이므로, 오늘 날짜는 2011/12/10로 하겠습니다. 데이터를 수집한 날짜를 기준으로 해야하므로 오늘 날짜를 이렇게 설정한 것입니다.

 

dt.datetime() & pd.to_datetime() & Series.apply(lambda)

import datetime as dt

cust_df['Recency'] = dt.datetime(2011, 12, 10) - pd.to_datetime(cust_df['Recency'])
cust_df['Recency'] = cust_df['Recency'].apply(lambda x: x.days+1)
print('cust_df 로우와 칼럼 건수는 ', cust_df.shape)
cust_df.head(3)
'''
cust_df 로우와 칼럼 건수는  (3920, 4)
'''
'''
결과3
'''

결과3

이제 고객 별로 RFM 분석에 필요한 칼럼을 모두 생성했으니, 생성된 고객 RFM 데이터 세트의 특성을 개괄적으로 알아보고 RFM 기반 고객 세그먼테이션을 수행하겠습니다.

 

 

RFM 기반 고객 세그먼테이션

온라인 판매 데이터 세트는 소매업체의 대규모 주문을 포함하고 있습니다. 이로 인해 업체 주문, 개인 주문 간의 주문 금액에서 매우 큰 차이가 있어 왜곡된 데이터 분포도를 가지게 됩니다. 이는 군집화가 한 쪽 군집에만 집중되는 현상을 일으킵니다. 히스토그램으로 데이터 세트의 칼럼 별 왜곡된 정도를 확인하고, 왜곡된 데이터에 군집화를 수행한 결과를 알아보겠습니다.

fig, (ax1, ax2, ax3) = plt.subplots(figsize=(12, 4), nrows=1, ncols=3)
ax1.set_title('Recency Histogram')
ax1.hist(cust_df['Recency'])

ax2.set_title('Frequency Histogram')
ax2.hist(cust_df['Frequency'])

ax3.set_title('Monetary Histogram')
ax3.hist(cust_df['Monetary'])
'''
결과4
'''

결과4

Recency, Frequency, Monetary 칼럼 모두 왜곡됐지만, F와 M의 경우 심하게 왜곡돼있습니다. 각 칼럼의 데이터 값 백분위로 어떻게 값이 분포돼 있는지 확인하겠습니다.

cust_df[['Recency', 'Frequency', 'Monetary']].describe()
'''
결과5
'''

결과5

Recency는 평균이 139인데, 50%(중위값)이 110인 것을 보아 왜곡이 있음을 알 수 있습니다. Frequency는 평균이 90인데 비해 중위값이 41로 왜곡이 심함을 알 수 있습니다. Monetary는 평균이 1864인데 비해 중위 값이 652로 왜곡이 심함을 알 수 있습니다.

 

KMeans

왜곡도가 높을 때 k-평균 군집을 적용하면, 중심의 개수를 증가시키더라도 변별력이 떨어지는 군집화가 수행됩니다.

 

silhouette_score & silhouette_samples

from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score, silhouette_samples

X_features = cust_df[['Recency', 'Frequency', 'Monetary']].values
X_features_scaled = StandardScaler().fit_transform(X_features)

kmeans = KMeans(n_clusters=3, random_state=0)
labels = kmeans.fit_predict(X_features_scaled)
cust_df['cluster_label'] = labels

print('실루엣 스코어는 : {0:.3f}'.format(silhouette_score(X_features_scaled, labels)))
'''
실루엣 스코어는 : 0.511
'''

군집을 3개로 구성할 때 실루엣 스코어는 0.51로 안정적인 수치가 나왔습니다.

 

각 군집 별 실루엣 계수 값을 알아보겠습니다. 군집 평가 예제에서 사용한 visualized_silhouette() 함수와  군집 개수 별로 군집화 구성을 시각화하는 visualize_kmeans_plot_multi()함수를 생성하여 개별 군집 실루엣 계수값과 데이터 구성을 알아보겠습니다. 

### 여러개의 클러스터링 갯수를 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()
visualize_silhouette([2, 3, 4, 5], X_features_scaled)
visualize_kmeans_plot_multi([2, 3, 4, 5], X_features_scaled)
'''
결과6
'''

결과6

결과6을 통해 모든 n_clusters에서 데이터 세트의 개수가 너무 작은 군집이 하나 이상 만들어집니다. 이 소수의 데이터 세트는 바로 앞에서 왜곡된 데이터 값인 특정 소매점의 대량 주문 구매 데이터입니다. 이 데이터 세트의 경우 거리 기반으로 광범위하게 퍼져 있어서 군집 수를 계속 늘려봐야 군집만 계속 분리되어 의미 없는 군집화 결과로 이어집니다.

 

지나치게 왜곡된 데이터 세트는 k-평균과 같은 거리 기반 군집화 알고리즘에서 지나치게 일반적인 군집화 결과를 도출하게 됩니다. 비지도학습 알고리즘의 하나인 군집화의 기능적 의미는 숨어 있는 새로운 집단을 발견하는 것입니다. 새로운 군집 내의 데이터 값을 분석하고 이해함으로써 이 집단에 새로운 의미를 부여할 수 있습니다.

 

데이터 세트의 왜곡도를 낮추기 위해 로그 변환을 수행한 뒤 다시 K-평균 알고리즘을 수행하겠습니다.

from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score, silhouette_samples

# Recency, Frequency, Monetary 칼럼에 np.log1p()로 Log Transformation
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'])

# Log Transformation 데이터에 StandardScaler 적용
X_features = cust_df[['Recency_log', 'Frequency_log', 'Monetary_log']].values
X_features_scaled = StandardScaler().fit_transform(X_features)

kmeans = KMeans(n_clusters=3, random_state=0)
labels = kmeans.fit_predict(X_features_scaled)
cust_df['cluster_label'] = labels
print('실루엣 스코어는 : {0:.3f}'.format(silhouette_score(X_features_scaled, labels)))
'''
실루엣 스코어는 : 0.390
'''

실루엣 스코어의 절대치가 절대적으로 중요한 것이 아니라, 어떻게 개별 군집이 더 균일하게 나뉠 수 있는지가 더 중요합니다.

로그 변환한 데이터 세트의 실루엣 계수와 군집화 구성을 시각화하겠습니다.

visualize_silhouette([2, 3, 4, 5], X_features_scaled)
visualize_kmeans_plot_multi([2, 3, 4, 5], X_features_scaled)
'''
결과7
'''

결과7

실루엣 스코어는 로그 변환 전보다 떨어지지만, 앞의 경우보다 더 균일하게 군집화가 구성됨을 알 수 있습니다. 이처럼 왜곡된 데이터 세트에 대해서 로그 변환으로 군집화를 수행하면 더 나은 결과를 도출할 수 있습니다.

 


궁금한 점

  1. 영국이 대다수를 차지하므로, 다른 국가의 데이터는 모두 제외하겠습니다. why? 나라 마다 특성에 차이가 있을 수 있어서?
  2. retail_df.groupby(['InvoiceNo', 'StockCode'])['InvoiceNo'].count()의 결과는 아래와 같은데, 이 분류들의 평균 즉, 값들이 대부분 1이라는 뜻이다.
InvoiceNo  StockCode
536365     21730        1
           22752        1
           71053        1
           84029E       1
           84029G       1
                       ..
581585     84946        1
581586     20685        1
           21217        1
           22061        1
           23275        1
Name: InvoiceNo, Length: 344435, dtype: int64

 

출처: 파이썬 머신러닝 완벽 가이드(권철민)

사진 출처:

 

'파이썬 머신 러닝 완벽 가이드' 카테고리의 다른 글

텍스트 분류 실습 - 20 뉴스그룹 분류  (0) 2021.12.05
텍스트 분석  (0) 2021.12.03
군집화  (0) 2021.11.28
차원 축소  (0) 2021.11.26
회귀 실습 - 캐글 주택 가격: 고급 회귀 기법  (0) 2021.11.24