본문 바로가기

머신러닝

고객 데이터 클러스터링: K-Means, Agglomerative Clustering, PCA를 통한 분석

오늘은 "클러스터링"에 대해 다루어보겠습니다. 클러스터링은 데이터를 비슷한 그룹으로 나누는 작업인데요, K-Means와 Agglomerative Clustering을 사용해 고객 데이터를 클러스터링하고, PCA 기법을 통해 결과를 개선하겠습니다.

 

> Kaggle에서 제공하는 고객 데이터를 사용했어요~

import pandas as pd
import numpy as np
from sklearn.preprocessing import MinMaxScaler
import matplotlib.pyplot as plt
import pandas as pd
from sklearn.preprocessing import LabelEncoder
import seaborn as sns
from matplotlib import colors
from sklearn.cluster import KMeans
df = pd.read_csv("customer_segmentation.csv")
df.describe()

 

데이터셋 정보

 

df.shape
(2240, 29)

 

1. 데이터 클리닝

우선 데이터에 null 값이 있는지 확인해본 결과, True 값이 나와서 이를 처리해야 할 필요성이 있어요.

df.isnull().any().any()
True
null_values = df.isnull().sum()
print(null_values[null_values > 0])
Income    24
dtype: int64
df = df.dropna(subset=['Income'])
print("The total number of data-points after removing the rows with missing values are:", len(df))
The total number of data-points after removing the rows with missing values are: 2216
df.info()
<class 'pandas.core.frame.DataFrame'>
Index: 2216 entries, 0 to 2239
Data columns (total 29 columns):
 #   Column               Non-Null Count  Dtype  
---  ------               --------------  -----  
 0   ID                   2216 non-null   int64  
 1   Year_Birth           2216 non-null   int64  
 2   Education            2216 non-null   object 
 3   Marital_Status       2216 non-null   object 
 4   Income               2216 non-null   float64
 5   Kidhome              2216 non-null   int64  
 6   Teenhome             2216 non-null   int64  
 7   Dt_Customer          2216 non-null   object 
 8   Recency              2216 non-null   int64  
 9   MntWines             2216 non-null   int64  
 10  MntFruits            2216 non-null   int64  
 11  MntMeatProducts      2216 non-null   int64  
 12  MntFishProducts      2216 non-null   int64  
 13  MntSweetProducts     2216 non-null   int64  
 14  MntGoldProds         2216 non-null   int64  
 15  NumDealsPurchases    2216 non-null   int64  
 16  NumWebPurchases      2216 non-null   int64  
 17  NumCatalogPurchases  2216 non-null   int64  
 18  NumStorePurchases    2216 non-null   int64  
 19  NumWebVisitsMonth    2216 non-null   int64  
 20  AcceptedCmp3         2216 non-null   int64  
 21  AcceptedCmp4         2216 non-null   int64  
 22  AcceptedCmp5         2216 non-null   int64  
 23  AcceptedCmp1         2216 non-null   int64  
 24  AcceptedCmp2         2216 non-null   int64  
 25  Complain             2216 non-null   int64  
 26  Z_CostContact        2216 non-null   int64  
 27  Z_Revenue            2216 non-null   int64  
 28  Response             2216 non-null   int64  
dtypes: float64(1), int64(25), object(3)
memory usage: 519.4+ KB

 

Dt_Customer 변수는 object 타입으로 되어 있어서, 먼저 이를 datetime 형식으로 변환

# Changing to datetime
df["Dt_Customer"] = pd.to_datetime(df["Dt_Customer"], dayfirst=True)
current_date = pd.to_datetime("today")

 

각 고객의 가입일부터 현재까지의 일수를 계산하여 Customer_Lifetime_Days라는 새로운 열 생성

df["Customer_Lifetime_Days"] = (current_date - df["Dt_Customer"]).dt.days

 

Customer_Lifetime_Days 값을 기준으로 고객을 세 가지 수준으로 구분하는 함수 customer_level을 정의한 후, 각 고객에 대해 이를 적용하여 Customer_Level이라는 새로운 열 추가

def customer_level(row):
    if row["Customer_Lifetime_Days"] <= 365:  # 0-1 year
        return "New Customer"
    elif 365 < row["Customer_Lifetime_Days"] <= 1095:  # 1-3 years
        return "Mid-Term Customer"
    else:  # 3 years
        return "Loyal Customer"
    
df["Customer_Level"] = df.apply(customer_level, axis=1)

print(df[["Dt_Customer", "Customer_Lifetime_Days", "Customer_Level"]].head())
  Dt_Customer  Customer_Lifetime_Days  Customer_Level
0  2012-09-04                    4459  Loyal Customer
1  2014-03-08                    3909  Loyal Customer
2  2013-08-21                    4108  Loyal Customer
3  2014-02-10                    3935  Loyal Customer
4  2014-01-19                    3957  Loyal Customer

 

df['Marital_Status'].value_counts()
Marital_Status
Married     857
Together    573
Single      471
Divorced    232
Widow        76
Alone         3
Absurd        2
YOLO          2
Name: count, dtype: int64

 

marital status을 "Has Partner"와 "No Partner"로 변환하는 작업

df["Living_With"] = df["Marital_Status"].replace({
    "Married": "Has Partner", 
    "Together": "Has Partner", 
    "Absurd": "No Partner", 
    "Widow": "No Partner", 
    "YOLO": "No Partner", 
    "Divorced": "No Partner", 
    "Single": "No Partner",
    "Alone": "No Partner"
})

 

# Creating Age column
df["Age"] = 2024-df["Year_Birth"]
# Total payment
df["Total_Payment"] = df["MntWines"]+ df["MntFruits"]+ df["MntMeatProducts"]+ \
df["MntFishProducts"]+ df["MntSweetProducts"]+ df["MntGoldProds"]
# Total children living with
df["Children"]=df["Kidhome"]+df["Teenhome"]
df['Family_Members'] = df["Living_With"].replace({"Has Partner": 2, "No Partner": 1}) + df['Children']
df["Is_Parent"] = np.where(df.Children> 0, 1, 0)
df['Education'].value_counts()
Education
Graduation    1116
PhD            481
Master         365
2n Cycle       200
Basic           54
Name: count, dtype: int64
df["Education"] = df["Education"].replace({
    "Basic": "Undergraduate",
    "2n Cycle": "Undergraduate",
    "Graduation": "Graduate",
    "Master": "Postgraduate",
    "PhD": "Postgraduate"
})
# Dropping unnecessary columns
to_drop = ["Marital_Status", "Dt_Customer", "Z_CostContact", "Z_Revenue", "Year_Birth", "ID", 'AcceptedCmp3', 'AcceptedCmp4', 'AcceptedCmp5', 'AcceptedCmp1','AcceptedCmp2', 'Complain', 'Response']
df = df.drop(to_drop, axis=1)
df.head()

 

# Set custom color palette for the plots
sns.set(rc={"axes.facecolor":"#F0F0F0", "figure.facecolor":"#F0F0F0"})  # Set background to light gray
pallet = ["#006994", "#4DB8B1", "#87C1B5", "#A1D9C8", "#AEE1D4", "#C1F2E6"]  # Blue-green colors
cmap = colors.ListedColormap(pallet)

# Select columns for plotting (modify this to match the columns you are interested in)
To_Plot = ["Income", "Recency", "Age", "Total_Payment", "Is_Parent"]

# Plot the pairplot with the selected columns and using 'Is_Parent' for color hue
sns.pairplot(df[To_Plot], hue="Is_Parent", palette=pallet)

# Display the plot
plt.show()

Is_Parent 색상 기준으로 설정

 

# Set custom color palette for the plots
sns.set(rc={"axes.facecolor":"#F0F0F0", "figure.facecolor":"#F0F0F0"})  # Set background to light gray

# Koyu ve açık pembe tonları
pallet = ["#D81B60", "#FF4081", "#FF80AB", "#FFB3C1", "#FFCCE5", "#FCE4EC"]  # Dark to soft pink tones
cmap = colors.ListedColormap(pallet)

# Select columns for plotting (modify this to match the columns you are interested in)
To_Plot = ["Income", "Recency", "Age", "Total_Payment", "Is_Parent", 'Living_With']

# Plot the pairplot with the selected columns and using 'Living_With' for color hue
sns.pairplot(df[To_Plot], hue="Living_With", palette=pallet)

# Display the plot
plt.show()

Living_With 색상 기준으로 설정

Age와 Income 분포:

# Set custom style
sns.set(style="whitegrid", rc={"axes.facecolor": "#F0F0F0", "figure.facecolor": "#F0F0F0"})  # Light gray background

# Visualize Age distribution 
plt.figure(figsize=(10, 6))
sns.histplot(df['Age'], bins=30, color='skyblue', kde=True, edgecolor='black')
plt.title('Age Distribution', fontsize=16, weight='bold')
plt.xlabel('Age', fontsize=12)
plt.ylabel('Frequency', fontsize=12)
plt.grid(True, linestyle='--', alpha=0.7)
plt.show()

# Visualize Income distribution 
plt.figure(figsize=(10, 6))
sns.histplot(df['Income'], bins=30, color='lightcoral', kde=True, edgecolor='black')
plt.title('Income Distribution', fontsize=16, weight='bold')
plt.xlabel('Income', fontsize=12)
plt.ylabel('Frequency', fontsize=12)
plt.grid(True, linestyle='--', alpha=0.7)
plt.show()

 

그래프를 통해 Age와 Income 열에 아웃라이어 값이 존재하는 것을 확인할 수 있는데, 이를 수정할거예요~~

df = df[df['Income'] <= 600000]  
print("The total number of data-points after removing wrong income:", len(df))
The total number of data-points after removing wrong income: 2215
df = df[df['Age'] <= 85]  
print("The total number of data-points after removing age >85:", len(df))
The total number of data-points after removing age >85: 2212

 

2. 데이터 전처리

카테고리형 변수들을 분석에 사용하기 위해 Label Encoding을 사용하여 numerical data로 변환하기

# List of categorical variables
s = (df.dtypes == 'object')
object_cols = list(s[s].index)
print("Categorical variables in the dataset:", object_cols)
Categorical variables in the dataset: ['Education', 'Customer_Level', 'Living_With']
# Label Encoding for the categorical variables
LE=LabelEncoder()
for i in object_cols:
    df[i]=df[[i]].apply(LE.fit_transform)

 

MinMaxScaler를 사용하여 데이터를 0과 1 사이로 정규화하기

df2 = df.copy()
scaler = MinMaxScaler()
df_scaled = scaler.fit(df2)
scaled_df = pd.DataFrame(scaler.transform(df2),columns= df2.columns)
scaled_df.head()

MinMaxScaler 후

 

이제 데이터의 값이 numerical로 변환되었으므로, 클러스터링 작업을 시작할 수 있어요. 먼저 Elbow 방법을 사용하여 최적의 클러스터 수를 결정하겠습니다.

inertia = []
for i in range(1, 11):
    kmeans = KMeans(n_clusters=i, random_state=42)
    kmeans.fit(scaled_df)  # df_scaled kullanılmalı
    inertia.append(kmeans.inertia_)

# Finding optimal topic number by graph
plt.plot(range(1, 11), inertia)
plt.title('Elbow Method')
plt.xlabel('Number of clusters')
plt.ylabel('Inertia')
plt.show()

Elbow Method 그래프

 

그래프에서 최적의 클러스터 수가 명확하게 보이지 않지만, 저는 5으로 설정하는 것이 적합하다고 생각했어요.

 

우리가 클러스터링 방법들의 성능을 Silhouette Score로 평가할거예요. 이 Silhouette Score은 각 데이터 포인트가 얼마나 잘 클러스터링되었는지를 측정하며, 값은 -1에서 1 사이로 나와요. 값이 1에 가까울수록 클러스터링이 잘 이루어졌다고 할 수 있어요.

  • 1에 가까운 값: 데이터 포인트들이 같은 클러스터 내에서 서로 잘 묶여 있고, 다른 클러스터와는 명확하게 구분되는 경우
  • 0에 가까운 값: 데이터 포인트들이 클러스터 내에서 비슷하지만, 다른 클러스터와의 경계가 모호해 구분이 어려운 경우
  • -1에 가까운 값: 클러스터링이 잘못되어, 데이터 포인트들이 잘못된 클러스터에 배치된 경우

K-means

  • K-Means는 데이터를 K개의 군집으로 나누는 비지도 학습 알고리즘입니다.
kmeans = KMeans(n_clusters=5, init='k-means++', random_state=50)
kmeans.fit(scaled_df)
cluster_labels = kmeans.labels_

df['Cluster'] = cluster_labels

from sklearn.metrics import silhouette_score
silhouette_avg = silhouette_score(scaled_df, cluster_labels)
print(f"Silhouette Score: {silhouette_avg}")
Silhouette Score: 0.21416380707085655

 

Agglomerative Clustering

  • Agglomerative Clustering은 데이터를 계층적으로 병합하여 군집을 형성하는 군집화 알고리즘입니다.
from sklearn.cluster import AgglomerativeClustering

# Agglomerative Clustering
agglo = AgglomerativeClustering(n_clusters=5) 
agglo_labels = agglo.fit_predict(scaled_df)

# Cluster labels
print("Agglomerative Clustering Labels:")
print(agglo_labels)

# Silhouette Score
silhouette_avg = silhouette_score(scaled_df, agglo_labels)
print(f"Silhouette Score for Agglomerative Clustering: {silhouette_avg}")
Agglomerative Clustering Labels:
[4 2 0 ... 4 3 1]
Silhouette Score for Agglomerative Clustering: 0.1880058080278573

 

  • Agglomerative Clustering을 3개와 5개의 군집으로 시도했지만, 결과적으로 성능이 크게 향상되지 않았어요. 그래서 PCA로 차원 축소를 진행한 후 군집화를 다시 시도해 본 결과, 군집화 성능이 조금 더 나아졌어요. PCA로 차원 축소 후 얻어진 Silhouette Score가 이전보다 더 높은 값을 보여주었기 때문에, 이 방법이 더 효과적이라고 판단했어요.

PCA Reduction

PCA는 데이터의 차원 축소를 위한 기법이예요. 고차원 데이터를 시각화하거나 분석할 때, 차원을 줄여서 중요한 정보는 유지하면서, 불필요한 변수를 제거하는 데 사용됩니다.

  • 차원 축소: 데이터를 2차원 또는 3차원으로 변환하여, 데이터를 쉽게 시각화하고 분석할 수 있어요.
  • 중요한 정보 보존: PCA는 주성분을 찾아 데이터를 압축하면서도 중요한 특성을 유지해요.
  • 노이즈 감소: 차원 축소는 데이터의 노이즈를 줄이고, 본질적인 패턴에 집중할 수 있도록 도와줘요.
from sklearn.decomposition import PCA

# Reduce the dimensions to 2 using PCA
pca = PCA(n_components=2)
pca.fit(scaled_df)  # scaled_df: The scaled version of your data
PCA_df = pd.DataFrame(pca.transform(scaled_df), columns=["PC1", "PC2"])
from sklearn.cluster import AgglomerativeClustering

# Apply Agglomerative Clustering
AC = AgglomerativeClustering(n_clusters=5)  # Set the number of clusters to 5
yhat_AC = AC.fit_predict(PCA_df)  # Apply clustering to the PCA-reduced 2D data

# Add cluster labels to the 2D PCA DataFrame
PCA_df["Clusters"] = yhat_AC

# Add cluster labels to the original DataFrame
scaled_df["Clusters"] = yhat_AC
print(PCA_df.head())  # Display the PCA DataFrame with added cluster labels
print(scaled_df[["Clusters"]].head())  # Display the original DataFrame with added cluster labels
        PC1       PC2  Clusters
0  2.686228  0.784727         3
1 -1.698752  0.231181         0
2  0.817617  0.310404         4
3 -0.610873 -0.420856         2
4 -0.520667 -0.332882         2
   Clusters
0         3
1         0
2         4
3         2
4         2
# 2D scatter plot
plt.figure(figsize=(10, 8))
plt.scatter(PCA_df["PC1"], PCA_df["PC2"], c=cluster_labels, cmap='viridis')  # cluster_labels indicate clusters for each observation
plt.xlabel('Principal Component 1')
plt.ylabel('Principal Component 2')
plt.title('2D PCA Projection')
plt.colorbar(label='Cluster')
plt.show()

# Calculate the Silhouette Score for the PCA-reduced data
sil_score = silhouette_score(PCA_df[["PC1", "PC2"]], PCA_df["Clusters"])

print("Silhouette Score for PCA-reduced data: ", sil_score)
Silhouette Score for PCA-reduced data:  0.8327815637518862

 

결과적으로, 먼저 K-Means 클러스터링을 적용한 결과 Silhouette Score0.214로 나타났습니다. 그 후, Agglomerative Clustering을 적용했을 때는 Silhouette Score0.188로 약간 더 낮은 값을 얻었습니다. 두 방법 모두 클러스터 간 구분이 명확하지 않다는 것을 시사했습니다.

그 후, PCA로 차원 축소를 적용한 후 Agglomerative Clustering을 다시 실행했더니, Silhouette Score0.832로 크게 향상되었습니다. 이는 PCA를 통해 데이터의 차원을 줄여 주요 특성만 남기고 불필요한 노이즈를 제거했기 때문에, 클러스터 간 구분이 명확해졌기 때문이예요. 이로 인해 PCA를 통한 차원 축소가 성능 향상에 큰 영향을 미쳤음을 알 수 있습니다.