데이콘 이커머스 고객 세분화 분석 아이디어 경진대회

Author

김희영

Published

April 8, 2024

고객평생가치 기반 세분화 분석

이커머스 고객 세분화 분석 아이디어 경진대회 개요

[주제] 이커머스 환경에서 발생한 데이터를 통해 고객 세분화 기법을 사용하여 솔루션 제시

[설명] 다양한 고객 세분화 기법(RFM 분석, Cohort 분석 등)을 적용하여, 주어진 데이터 내에서 의미 있는 인사이트를 도출하고,

그 결과를 바탕으로 혁신적인 비즈니스 솔루션을 제안해야 합니다.

분석 로드맵

  • 분석목적

    • 효과적인 고객 세분화
    • 고객 행동 패턴과 구매 경향을 이해

    → 기업이 더 나은 서비스를 제공할 수 있는 방안을 제시

  • 본론

    • 데이터 불러오기

    • 데이터 전처리

      • 연, 월, 일 분리
      • Net Price
        • 세전가격 = (평균비용 * 수량) * (1-할인율)
        • 세후가격 = 세전가격*(1+세율)
      • 고객별 카테고리 지출 비율 계산
    • 데이터 탐색

      • 카테고리별 판매금액 및 판매수량의 비중
    • 데이터 분석

      • 고객 세분화: 고객별 RFM/P 지표 도출
      • 결과 해석
  • 결론

    • 결과 기반 비즈니스 솔루션 제안

*고객가치란?

미래 고객 관계에 의해 발생할 수 있는 순이익의 예측 (과거 데이터를 사용하여)

Customer lifetime value: The present value of the future cash flows attributed to the customer during his/her entire relationship with the company.

온라인 거래는 ‘비계약적(non-contractual)’ 상황으로, 고객이탈이 명확하게 표현되지 않음 → 확률적 모델링

데이터 전처리

라이브러리 호출 및 한글 출력 관련 설정

import numpy as np
import pandas as pd
import matplotlib.font_manager as fm
fontpaths = "/kaggle/input/nanumsquare"
font_list = fm.findSystemFonts(fontpaths = fontpaths, fontext='ttf')
for font_file in font_list:
    fm.fontManager.addfont(font_file)
fm._load_fontmanager(try_read_cache=False)
<matplotlib.font_manager.FontManager at 0x7a781db01330>
import matplotlib.pyplot as plt
plt.rc('font', family='NanumSquare_ac')
import seaborn as sns
sns.set_theme(font="NanumSquare_ac")

데이터 불러오기

customer = pd.read_csv('/kaggle/input/marketing/Customer_info.csv')
discount = pd.read_csv('/kaggle/input/marketing/Discount_info.csv')
marketing = pd.read_csv('/kaggle/input/marketing/Marketing_info.csv')
onlinesales = pd.read_csv('/kaggle/input/marketing/Onlinesales_info.csv')
tax = pd.read_csv('/kaggle/input/marketing/Tax_info.csv')
month_dict = {
  'Jan': 1,
  'Feb': 2,
  'Mar': 3,
  'Apr': 4,
  'May': 5,
  'Jun': 6,
  'Jul': 7,
  'Aug': 8,
  'Sep': 9,
  'Oct': 10,
  'Nov': 11,
  'Dec': 12
}
discount['월'] = discount.월.replace(month_dict)
/tmp/ipykernel_226/3398978212.py:1: FutureWarning: Downcasting behavior in `replace` is deprecated and will be removed in a future version. To retain the old behavior, explicitly call `result.infer_objects(copy=False)`. To opt-in to the future behavior, set `pd.set_option('future.no_silent_downcasting', True)`
  discount['월'] = discount.월.replace(month_dict)
marketing['날짜'] = pd.to_datetime(marketing['날짜'], format='%Y-%m-%d')
onlinesales['거래날짜'] = pd.to_datetime(onlinesales['거래날짜'], format='%Y-%m-%d')
# 연, 월, 일 추출
onlinesales['연'] = onlinesales['거래날짜'].dt.year
onlinesales['월'] = onlinesales['거래날짜'].dt.month
onlinesales['일'] = onlinesales['거래날짜'].dt.day

고객의 실구매금액

  • 세전가격 = (평균비용 * 수량) * (1-할인율)
  • 세후가격(실구매금액) = 세전가격*(1+세율)
sales = (onlinesales
 .merge(customer, on='고객ID', how='left')
 .merge(tax, on='제품카테고리', how='left')
 .merge(marketing, left_on='거래날짜', right_on='날짜', how='left')
 .merge(discount, on=['월','제품카테고리'], how='left')
)

sales['세전가격'] = np.where(sales.쿠폰상태 == 'Used', 
                         (sales.평균금액 * sales.수량) * (1-(1/100 * sales.할인율)) + sales.배송료,
                         (sales.평균금액 * sales.수량) + sales.배송료
                        )
sales['세후가격'] = sales.세전가격*(1+sales.GST)

① 세분화 방식 선택

카테고리별 판매금액 및 판매수량의 비중

t3 = sales.loc[:, ['제품카테고리', '수량', '세후가격']].groupby('제품카테고리').sum()
t3 = pd.concat([t3, t3 / t3.sum()], axis=1)
t3.columns = ['판매수량', '판매금액', '판매수량_비율', '판매금액_비율']
t3.reset_index(inplace=True)
import squarify

label = t3.제품카테고리 + '\n(' + round(t3.판매금액_비율*100, 1).astype(str) + ')'
squarify.plot(t3.판매금액_비율, label=label, ec = 'black',
              color = sns.color_palette("Spectral", len(t3)))
plt.axis("off")
plt.suptitle('카테고리별 판매금액 비중')
plt.title('고가 제품인 Nest-USA가 전체 판매금액의 50%를 차지',fontsize=9, color='gray')
plt.show()

import squarify

label = t3.제품카테고리 + '\n(' + round(t3.판매수량_비율*100, 1).astype(str) + ')'
squarify.plot(t3.판매수량_비율, label=label, ec = 'black',
              color = sns.color_palette("Spectral", len(t3)))
plt.axis("off")
plt.suptitle('카테고리별 판매수량 비중')
plt.title('Office, Lifestyle, Apparel 등 가격이 낮은 카테고리의 판매수량 비중이 높음',fontsize=9, color='gray')
plt.show()

② 고객별 RFM/P(Recency, Frequency, Monetary value per Product category) 계산

Recency = (sales
.groupby(['고객ID', '제품카테고리'])['거래날짜']
.max()
.transform(lambda x: pd.to_datetime('2019-12-31') - x)
.rename('Recency')
.to_frame()
.reset_index()
)
Duration = (sales
.groupby(['고객ID', '제품카테고리'])['거래날짜']
.min()
.transform(lambda x: pd.to_datetime('2019-12-31') - x)
.rename('Duration')
.to_frame()
.reset_index()
)
Frequency = (sales
 .loc[:, ['고객ID', '제품카테고리', '거래날짜']]
 .groupby(['고객ID', '제품카테고리', '거래날짜'])
 .head(1)
 .groupby(['고객ID', '제품카테고리'])
 .count()
 .loc[:,'거래날짜']
 .to_frame()
 .rename({'거래날짜':'Frequency'}, axis=1)
 .reset_index()
 .assign(Frequency = lambda x: x.Frequency - 1)            
)
Monetary = (sales
 .loc[:,['고객ID','제품카테고리', '세후가격']]
 .groupby(['고객ID', '제품카테고리'])
 .sum()
 .rename({'세후가격':'Monetary'}, axis=1)
 .reset_index()           
)
RFM = pd.merge(Recency, Frequency, on=['고객ID', '제품카테고리'])
RFM = pd.merge(RFM, Duration, on=['고객ID', '제품카테고리'])
RFM = pd.merge(RFM, Monetary, on=['고객ID', '제품카테고리'])
RFM['Monetary'] = RFM['Monetary'] / (RFM['Frequency']+1)
RFM['Recency'] = RFM.Recency.dt.days
RFM['Duration'] = RFM.Duration.dt.days
RFM['Recency'] = np.where(RFM['Recency'] == RFM['Duration'], 0, RFM['Recency'])
RFM['Recency'] = RFM['Recency'] / 7
RFM['Duration'] = RFM['Duration'] / 7
RFM['Frequency'] = RFM['Frequency'].astype(float)

③ 고객별 RFM/P 결과 해석

import seaborn as sns

# 카테고리별 박스플롯
sns.boxplot(x='Monetary', y='제품카테고리', orient='h', data=RFM)

plt.xlabel('Monetary Value')
plt.ylabel('')
plt.suptitle('카테고리별 Monetary Value의 박스 그림')
plt.title('Nest 계열의 Monetary value \$200 을 상회하고 다른 카테고리는 \$200을 넘지 않음',fontsize=9, color='gray')
plt.show()

import seaborn as sns

# 카테고리별 박스플롯
sns.boxplot(x='Frequency', y='제품카테고리', orient='h', data=RFM)

plt.suptitle('카테고리별 Frequency의 박스 그림')
plt.title('Nest 계열과 다른 카테고리 간에 거의 차이 없으며 반복구매는 대부분 0~1회 발생함',fontsize=9, color='gray')
plt.ylabel('')
plt.show()

④ RFM/P로부터 고객평생가치(CLV, Customer Lifetime Value) 계산 및 고객 세분화

BG/NBD 모델 (예상구매횟수)

from scipy.special import gammaln
from scipy.optimize import minimize

def negative_log_likelihood(params:list, x:, t_x, T):
  
  '''
  BG-NBD 모델의 log-likelihood 함수를 정의
  
  Input
  --
  params: r, alpha, a, b 파라미터의 초깃값
  x: Frequency, 구매주기가 계산된 칼럼
  t_x: Recency, 가장 최근 구매 시점이 계산된 칼럼
  T: Transaction term, 첫 구매부터 분석 시점까지의 기간
  
  Output
  --
  BG-NBD 모델의 log-likelihood 값
  '''
  
    if np.any(np.asarray(params) <= 0):
        return np.inf
    
    r, alpha, a, b = params
    
    ln_A_1 = gammaln(r+x) - gammaln(r) + r*np.log(alpha + 1e-6)
    ln_A_2 = (gammaln(a+b) + gammaln(b+x) - gammaln(b) - gammaln(a+b+x))
    ln_A_3 = -(r+x) * np.log(alpha+T+1e-6)
    ln_A_4 = x.copy()
    ln_A_4[ln_A_4 > 0] = (
        np.log(a+ 1e-6) -
        np.log(b + ln_A_4[ln_A_4 > 0] - 1 + 1e-6) - 
        (r+ln_A_4[ln_A_4 > 0]) * np.log(alpha+t_x+ 1e-6)
    )
    
    delta = np.where(x>0, 1, 0)
    
    log_likelihood = ln_A_1 + ln_A_2 + np.log(np.exp(ln_A_3) + delta * np.exp(ln_A_4) + 1e-6)
    
    return -log_likelihood.sum()

def _func_caller(params, func_args, function):
    '''
    scipy.minimize 함수에서 목적함수 fun으로 사용하는 함수. 
    해당 함수의 파라미터인 function을 호출함.
    '''
    return function(params, *func_args)

def fit_bgf(grouped: pd.DataFrame):
  
  '''
  Negative log-likelihood function을 최소화하는 파라미터 r, alpha, a, b를 구함
  
  Input
  --
  grouped: "Frequency(x)", "Recency(t_x)", "Duration(T)"를 칼럼으로 가지는  Pandas 데이터프레임
  
  Output
  --
  negative log-likelihood 함수를 최소화 하는 r, alpha, a, b
  '''
    
    scale = 1 / grouped['Duration'].max()
    scaled_recency = grouped['Recency'] * scale
    scaled_T = grouped['Duration'] * scale

    current_init_params = np.ones(4)

    output = minimize(
        _func_caller,
        method='Nelder-Mead',
        tol=0.0001,
        x0=current_init_params,
        args=([grouped['Frequency'], scaled_recency, scaled_T], negative_log_likelihood),
        options={'maxiter':2000}
    )

    r = output.x[0]
    alpha = output.x[1]
    a = output.x[2]
    b = output.x[3]

    alpha /= scale

    print("r = {}".format(r))
    print("alpha = {}".format(alpha))
    print("a = {}".format(a))
    print("b = {}".format(b))
    
    return r, alpha, a, b


def calculate_conditional_expectation(t, x, t_x, T):
    '''
    적합된 파라미터 값을 바탕으로 주어진 t 시점의 조건부 예상 구매 횟수를 구함
    
    Input
    --
    t: 예상 구매 횟수를 구하고자 하는 시점
    x: Frequency, 구매주기가 계산된 칼럼
    t_x: Recency, 가장 최근 구매 시점이 계산된 칼럼
    T: Transaction term, 첫 구매부터 분석 시점까지의 기간 
    
    Output
    --
    고객별 예상 구매 횟수
    '''

    first_term = (a+b+x-1) / (a-1)
    hyp2f1_a = r+x
    hyp2f1_b = b+x
    hyp2f1_c = a + b + x - 1
    hyp2f1_z = t / (alpha + T + t)
    hyp_term = hyp2f1(hyp2f1_a, hyp2f1_b, hyp2f1_c, hyp2f1_z)
    second_term = (1 - ((alpha+T) / (alpha+T+t))**(r+x) * hyp_term)
    delta = np.where(x > 0, 1, 0)
    denominator = 1 + delta * (a/(b+x-1)) * ((alpha+T) / (alpha+t_x))**(r+x)
    
    return first_term * second_term / denominator

Gamma-Gamma 모델 (예상구매금액)

from scipy.special import gammaln
from scipy.optimize import minimize
from scipy.stats import invgamma

def negative_log_likelihood_ggf(params, x, bar_z):
    if np.any(np.asarray(params) <= 0):
        return np.inf
    
    p, q, gamma = params
    
    log_likelihood = np.where(x == 0, 0, gammaln(p*x + q) - gammaln(p*x) - gammaln(q) + q*np.log(gamma + 1e-6) + (p*x-1)*np.log(bar_z + 1e-6) + p*x*np.log(x + 1e-6)  - (p*x+q)*np.log(gamma + x*bar_z + 1e-6))
    
    return -log_likelihood.sum()

def fit_ggf(grouped):

    current_init_params = np.ones(3)

    output = minimize(
        _func_caller,
        method='Nelder-Mead',
        tol=0.0001,
        x0=current_init_params,
        args=([grouped['Frequency'], grouped['Monetary']], negative_log_likelihood_ggf),
        options={'maxiter':2000}
    )

    p = output.x[0]
    q = output.x[1]
    gamma = output.x[2]

    print("p = {}".format(p))
    print("q = {}".format(q))
    print("gamma = {}".format(gamma))
    
    return p, q, gamma

def calculate_conditional_expectation_ggf(x, z_bar):
    
    # q가 1보다 작은 경우 알려진 mean이 없음
    if q < 1:
        z = [np.mean(invgamma.rvs(q, scale=p*gamma, size=100)) for i in range(100)]
        expectation_z = np.mean(z)
        weight = (q-1)/(p*x+q-1)
        
    elif q == 1:
        expectation_z = 0
        weight = 0
        
    else:
        expectation_z = p*gamma/(q-1)
        weight = (q-1)/(p*x+q-1)
    
    return weight*expectation_z + (1-weight)*z_bar

고객평생가치 계산

df = pd.DataFrame(columns=['고객ID', '제품카테고리', 'Recency', 'Frequency', 
                      'Duration', 'Monetary','predicted_purchases', 'expected_average_profit'])
t = 52

for category, grouped in RFM.groupby('제품카테고리'):
    
    if (category == 'More Bags') | (category == 'Android'):
        continue
    
    print(category)
    print('----------------------')
    
    r, alpha, a, b = fit_bgf(grouped)
    grouped['predicted_purchases'] = calculate_conditional_expectation(t, 
                                                                 grouped['Frequency'],
                                                                 grouped['Recency'],
                                                                 grouped['Duration'])
    
    p, q, gamma = fit_ggf(grouped)
            
    grouped['expected_average_profit'] = calculate_conditional_expectation_ggf(grouped['Frequency'], grouped['Monetary'])
    
    df = pd.concat([df, grouped], ignore_index=True)
Accessories
----------------------
r = 0.13912008073722326
alpha = 8.176630570812936
a = 1052808.6234128857
b = 501402.9633816013
p = 1.8348050677519895e+133
q = 3.5580081632721924e+127
gamma = 4.6042778608761196e-05
Apparel
----------------------
r = 0.17923159086008145
alpha = 1.1903404140925717
a = 12.696467541252009
b = 27.601082880439954
p = 2.5129593127494996
q = 2.6069465924123243
gamma = 208.15006556477317
Backpacks
----------------------
r = 0.26646419623447243
alpha = 116.52392596923818
a = 9.394653723311084e-10
b = 2.2672142445478647
p = 3.727981318000165e+89
q = 2.5337574645747155e+84
gamma = 0.0005354572432201953
Bags
----------------------
r = 0.1268609869185903
alpha = 2.966232640652436
a = 7.316117332170791
b = 12.390568106079222
p = 3.4224680850941467
q = 2.77620821431516
gamma = 93.17829522329001
Bottles
----------------------
r = 0.32202021881812226
alpha = 58.88183726710254
a = 7.542067151205418e-08
b = 1.7094503770590088
p = 2.743222204193875e+64
q = 1.4604030300902113e+58
gamma = 7.526130258503049e-06
Drinkware
----------------------
r = 0.15220915102007612
alpha = 2.6623905053523713
a = 5.545536572500552
b = 9.173539222442841
p = 2.090490964871371
q = 2.4401314642392835
gamma = 139.90068112083412
Fun
----------------------
r = 0.256238525933553
alpha = 52.03785741362919
a = 6.258359013875896e-09
b = 0.9690240429752579
p = 0.3453777559426032
q = 1.9822687938570231
gamma = 157.9458801540525
Gift Cards
----------------------
r = 0.11283238487152375
alpha = 196.12245982545687
a = 0.048698349755692844
b = 0.001057245129677128
p = 538.310948990716
q = 165287096.21183765
gamma = 89546243.91024789
Google
----------------------
r = 0.08137060036161638
alpha = 36.15580120680644
a = 9.126081296868924e-08
b = 3.379306880534778
p = 0.22662065091385475
q = 23287769.554064788
gamma = 2726064192.3886366
Headgear
----------------------
r = 0.17314371224052738
alpha = 11.045263790422947
a = 669783.4179023418
b = 1467404.1515586074
p = 1.1190108558062278e+125
q = 4.911505698163156e+119
gamma = 0.0001564922415696879
Housewares
----------------------
r = 0.2515645253488443
alpha = 141.9793563383636
a = 9.664784317770056e-09
b = 1.7146547447538287
p = 5.769397633173366e+125
q = 3.3454380972575686e+119
gamma = 3.639041388553725e-05
Lifestyle
----------------------
r = 0.15404201163538178
alpha = 3.596814333049493
a = 7.008205975534631
b = 11.765664876059486
p = 2.4228375137721496
q = 2.7792198716482064
gamma = 83.226443006274
Nest
----------------------
r = 0.13043786848149166
alpha = 1.5168593891537494
a = 6.24180287665729
b = 8.384595811936713
p = 5.437162210655444
q = 3.183747466632423
gamma = 307.3106321927787
Nest-Canada
----------------------
r = 0.9320315618078716
alpha = 187.6748601926152
a = 4.9689145957934585e-17
b = 1.4542098190160407e-11
p = 1.7100905405430378e+77
q = 1.2494512650212776e+72
gamma = 0.002038622217972977
Nest-USA
----------------------
r = 0.15766051260003858
alpha = 0.8551128738058902
a = 16.997925357991125
b = 37.87458821391318
p = 2.2391550285363344
q = 7.373606061330563
gamma = 3291.3309614290924
Notebooks & Journals
----------------------
r = 0.5664972766923593
alpha = 66.01347305708448
a = 4.619180319274446e-09
b = 3.3508438812566643
p = 12.261404666185609
q = 0.8622063891044838
gamma = 4.729693892027056
Office
----------------------
r = 0.16503592303757686
alpha = 2.0132095927399236
a = 8.77528359846142
b = 15.484533903163326
p = 1.531891564455388
q = 2.373456005944883
gamma = 198.5441038187032
Waze
----------------------
r = 0.12111468757819649
alpha = 6.677725335151996
a = 3.9257610096632476
b = 4.171950945336615
p = 4.7373814059561745
q = 3.847741307619433
gamma = 25.86433099442864
/tmp/ipykernel_226/806044944.py:23: FutureWarning: The behavior of DataFrame concatenation with empty or all-NA entries is deprecated. In a future version, this will no longer exclude empty or all-NA columns when determining the result dtypes. To retain the old behavior, exclude the relevant entries before the concat operation.
  df = pd.concat([df, grouped], ignore_index=True)
df['CLV'] = df['predicted_purchases'] * df['expected_average_profit']
table = df.pivot(index='고객ID',columns='제품카테고리', values='CLV').fillna(0)
groups = pd.qcut(table.sum(axis=1), q=10, labels=np.arange(10)+1).rename('Group')

⑤ 결과 해석

(t7.sum(axis=1) / groups.value_counts()).plot.bar()
plt.suptitle('분위별 고객당 평균 Customer Lifetime Value')
plt.title('10분위 \$2,926 1분위는 \$92 로 크게 차이남',fontsize=9, color='gray')
Text(0.5, 1.0, '10분위 \\$2,926 1분위는 \\$92 로 크게 차이남')

t7 = table.join(groups).groupby('Group', observed=False).sum()
cmap = "viridis" 
plt.figure(figsize=(10, 5))
sns.heatmap(t7, cmap=cmap)
plt.suptitle('분위/카테고리별 추정 고객평생가치 합산')
plt.title('모든 분위에 걸쳐 Nest 고객평생가치 기여가 큼',fontsize=9, color='gray')
# plt.show()
plt.savefig('plot.png', bbox_inches="tight")

t8 = t7.apply(lambda col: col / t7.sum(axis=1), axis=0)
other_cols = table.sum(axis=0).sort_values()[:10].index.values
main_cols = [col for col in t8.columns.values if col not in other_cols]
t8['Others'] = t8[other_cols].sum(axis=1)
t8 = t8[np.append(main_cols, 'Others')]
t8.plot.bar(stacked=True)

plt.legend(title="제품카테고리", bbox_to_anchor = (1,1))
plt.yticks([])
plt.suptitle('분위별 카테고리들의 CLV 차지 비중')
plt.title('분위가 높아질수록 Nest, Nest-USA가 차지하는 비중이 커짐',fontsize=9, color='gray')
Text(0.5, 1.0, '분위가 높아질수록 Nest, Nest-USA가 차지하는 비중이 커짐')

참고문헌

  • Fader, P. S., Hardie, B. G., & Lee, K. L. (2005). “Counting your customers” the easy way: An alternative to the Pareto/NBD model. Marketing science, 24(2), 275-284.

  • Fader, P. S., & Hardie, B. G. (2013). The Gamma-Gamma model of monetary value. February, 2, 1-9.

  • Heldt, R., Silveira, C. S., & Luce, F. B. (2021). Predicting customer value per product: From RFM to RFM/P. Journal of Business Research, 127, 444-453.

  • Ben Alex Keen. (2018). BG-NBD Model for Customer Base Analysis in Python. Ben Alex Keen Blog. https://benalexkeen.com/bg-nbd-model-for-customer-base-analysis-in-python/