Project/2023 BDA 데이터 분석 공모전

[2023 BDA 데이터 분석 공모전] Track1: 시각화 인사이트 (3) - 고객 군집화 시도

문세희 2023. 7. 3. 12:57

이번 track1에서 내가 주도적으로 맡은 부분은 고객 군집화를 위해 클러스터링 기법을 시도한 것이었다.

군집화가 잘 이루어지면, 고객 별 특성에 맞는 마케팅 전략을 제시할 수 있기 때문이다.

하지만 대부분의 변수가 범주형 변수였기 때문에, 거리 기반의 k-means 클러스터링을 진행하는 것은 부적합하다고 판단했다.

따라서 최빈값을 기반으로 비유사도가 낮은 군집을 만들어내는 k-modes 클러스터링을 시도해 보았다.


데이터 전처리

- 변수 추출

총 29개의 변수 중에서 클러스터링에 활용에 필요한 컬럼 15개를 추출해준다.

drop_list = ['Unnamed: 0', '거래처', '거래처주문번호', '주문일', '출고예정일', '운송장등록일자', '상품순번', '상품코드', '덤상품수량', '자재코드', '자재명', '단독배송여부', '등록일자', 'GS홈쇼핑주문번호']

for k in drop_list:
  df_naver.drop(k, axis=1, inplace=True)
drop_list_e = ['Unnamed: 0', '거래처', '거래처주문번호', '주문일', '출고예정일', '운송장등록일자', '상품순번', '상품코드', '덤상품수량', '자재코드', '자재명', '단독배송여부', 'GS홈쇼핑주문번호']

for k in drop_list_e:
  df_eleven.drop(k, axis=1, inplace=True)

네이버 데이터셋과 11번가 데이터셋 모두 

사은품여부, 주문/배송상태, 클레임 상태, 상품명, 상품수량, 상품유형, 상품타입, 매출액, 초기자재수량, 주문수량, 최소수량, 반품수량, 배송유형, 마감구분, 플랜트 정보

총 15개의 변수를 가지는 데이터셋으로 만들어 주었다.

 

- '클레임 상태' 변수

클레임 상태 변수는 고객이 클레임을 하지 않았을 시 '-', 클레임을 했을 시 최소완료, 반품처리중 등 상태명 값을 가졌다.

따라서 '-'값인 경우 'X', 아닐 경우에는 'O'을 부여하여 클레임 여부 변수로 변환해주었다.

# 열 이름
target_column = '클레임 상태'

# 특정 열의 행 값을 '-'가 아닐 때 1로, '-'일 때 0으로 바꾸기
df_naver[target_column] = df_naver[target_column].apply(lambda x: 'O' if x != '-' else 'X')
df_eleven[target_column] = df_eleven[target_column].apply(lambda x: 'O' if x != '-' else "X")

- '매출액' 변수

연속형 값을 가지는 매출액 변수의 이상치를 먼저 제거해준 뒤, 최빈값 중심의 클러스터링인 k-modes 클러스터링을 진행하기 위하여 금액을 구간화해주었다.

def get_outlier(df=None, column=None, weight=1.5):
  # target 값과 상관관계가 높은 열을 우선적으로 진행
  quantile_25 = np.percentile(df['매출액'].values, 25)
  quantile_75 = np.percentile(df['매출액'].values, 75)

  IQR = quantile_75 - quantile_25
  IQR_weight = IQR*weight

  lowest = quantile_25 - IQR_weight
  highest = quantile_75 + IQR_weight

  outlier_idx = df['매출액'][ (df['매출액'] < lowest) | (df['매출액'] > highest) ].index
  return outlier_idx

# 함수 사용해서 이상치 값 삭제
outlier_idx = get_outlier(df=df_naver, column='매출액', weight=1.5)
df_naver_drop = df_naver.drop(outlier_idx, axis=0)
import pandas as pd

# 구간(bin) 정의
bins = [0, 10000, 20000, 30000, 40000, 50000, float('inf')]  # 여기서는 0 이상 1000 미만, 1000 이상 5000 미만, 5000 이상 10000 미만, 10000 이상을 구간으로 나누었습니다.
labels = [0, 1, 2, 3, 4, 5]

# 금액을 구간화하여 새로운 열 추가
df_eleven['매출액구간'] = pd.cut(df_eleven['매출액'], bins=bins, labels=labels, right=False)

# 결과 출력
df_naver

- '상품수량' 변수

상품수량 변수 역시 연속형 값을 가지므로 iqr과 상자 수염 그래프를 이용해 이상치를 제거해 주고, 구간화를 진행하려 했으나 상품수량의 경우 2 이하의 데이터가 압도적으로 많았기 때문에, 구간화 보다는 '다량구매'와 '소량구매'로 나누어 주는 것이 적합하다고 판단하였다.

따라서 5 이상의 상품수량 값을 가지는 데이터는 '다량구매', 2 미만의 상품수량 값을 가지는 데이터는 '소량구매'로 매핑해 주었다.

# 새로운 열 추가
df_naver['다량구매'] = df_naver['상품수량'].apply(lambda x: '소량구매' if x in [1, 2] else '다량구매')
df_naver.drop('상품수량', axis=1, inplace=True)
# 결과 출력
df_naver

- '취소수량' 변수

취소수량 역시 데이터 값이 존재하는 경우 '여', 존재하지 않는 경우 '부'를 매핑하여 취소여부 변수로 변환해 주었다.

df_naver['취소여부'] = df_naver['취소수량'].apply(lambda x: '부' if x == 0 else '여')
df_naver.drop('취소수량', axis=1, inplace=True)
df_eleven['취소여부'] = df_eleven['취소수량'].apply(lambda x: '부' if x == 0 else '여')
df_eleven.drop('취소수량', axis=1, inplace=True)

- '반품수량' 변수

반품수량 마찬가지로 같은 방식을 활용해 반품여부 변수로 변환해 주었다.

df_naver['반품여부'] = df_naver['반품수량'].apply(lambda x: '부' if x == 0 else '여')
df_naver.drop('반품수량', axis=1, inplace=True)
df_eleven['반품여부'] = df_eleven['반품수량'].apply(lambda x: '부' if x == 0 else '여')
df_eleven.drop('반품수량', axis=1, inplace=True)

- '상품명' 변수

이전에 진행했던 함수를 사용하여 브랜드, 카테고리, 수량 정보를 추출해서 새로운 변수로 만들어 주었다.


아래 사진은 위의 과정을 거쳐서 연속형 변수를 범주형 변수로 처리해준 뒤 새롭게 파생된 변수들이다.

파생변수

결측치 확인 결과, 상품 유형과 상품 타입 변수에서 결측치가 나타남을 확인할 수 있었지만 그 비율이 1% 미만으로 낮아 drop시키는 방법을 선택했다.

그 후, labelencoder를 이용하여 모델에 학습시킬 수 있도록 int type으로 변환해 주었다.

from sklearn import preprocessing
df_naver_dn = df_naver_c.dropna()
df_eleven_dn = df_eleven_c.dropna()
le = preprocessing.LabelEncoder()
df_naver_le = df_naver_dn.apply(le.fit_transform)
le = preprocessing.LabelEncoder()
df_eleven_le = df_eleven_dn.apply(le.fit_transform)
df_naver_le
df_eleven_le

머신에 학습시킬 수 있는 데이터셋이 준비되었다.


K-Modes Clustering

K-means method의 변형으로 categorical data에 대해서도 적용이 가능하다.

Seed point로 평균 : mean(centroid) 대신 최빈값: mode을 이용한다.

각 군집간의 비유사도가 가장 낮은 방향으로 갱신하며 데이터끼리 군집을 형성하는 방법이다.

 

이 방식의 군집화 역시도 군집의 갯수 k값을 지정해주어야 하는데,

이를 위해 elbow-method를 비롯한 여러 가지 방법을 활용할 수 있다.

K-means에서는 inertia값을 측정하는 방식으로 그래프를 그려 elbow point를 찾아냈다면, K-modes에서는 elbow point를 찾기 위해 cost값을 측정한다.

import matplotlib.pyplot as plt
import numpy as np
cost = []
K = range(1, 7)
for num in K:
    kmode = KModes(n_clusters=num, init = "random", n_init = 1, verbose=1)
    kmode.fit_predict(df_naver_dn)
    cost.append(kmode.cost_)

plt.plot(K, cost, 'bx-')
plt.xlabel('number of clusters')
plt.ylabel('cost')
plt.title('elbow method for finding optimal K')
plt.show()

naver data
11번가 data

네이버 데이터에서는 군집의 개수를 5개, 11번가 데이터에서는 4개로 결정하였다.

km_cao = KModes(n_clusters=5, init = "random", n_init = 1, verbose=1)
fitClusters_naver = km_cao.fit_predict(df_naver_dn)
fitClusters_naver
clusterCentroidsDf = pd.DataFrame(km_cao.cluster_centroids_)
clusterCentroidsDf.columns = df_naver_dn.columns
clusterCentroidsDf

맨 오른쪽 열에 클러스터 넘버를 새로운 변수로 만들어 넣어 주었다.

 

-시각화 : 각 컬럼별로 클러스터 별 데이터 갯수를 나타낸 그래프이다. 왼쪽은 naver 데이터, 오른쪽은 11번가 데이터를 나타낸다.

상품 유형, 매출액 구간, 브랜드, 다량구매, 반품 및 취소 여부 컬럼이 추후 해석에 용이하게 작용할 듯 하다.

 
 
 

그렇다면 해당 클러스터링 기법을 통해서 실제 고객을 그룹화할 수 있을 정도로 유의미한 결과를 도출할 수 있었는가?

결론적으로 말하면 아니다. 

k-modes의 중앙값 지정이 random으로 세팅을 하고 진행을 한 탓에 클러스터링 결과값이 실행을 계속할수록 하늘과 땅 차이로 다르게 도출되었다.

그렇게 되면 결국 각 군집에 대한 중앙값을 임의로 지정해 주어야 하는데, 이 과정에서 주관이 상당히 개입되어 중앙값 지정에 대한 객관적인 지표가 필요했는데, 우리가 얻고자 하는 목표를 달성하는데 있어 시간과 비용을 다른 곳데 집중하자는 결론을 얻었다.