import numpy as np
import pandas as pd
import matplotlib.font_manager as fm
고객평생가치 기반 세분화 분석
이커머스 고객 세분화 분석 아이디어 경진대회 개요
[주제] 이커머스 환경에서 발생한 데이터를 통해 고객 세분화 기법을 사용하여 솔루션 제시
[설명] 다양한 고객 세분화 기법(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)’ 상황으로, 고객이탈이 명확하게 표현되지 않음 → 확률적 모델링
데이터 전처리
라이브러리 호출 및 한글 출력 관련 설정
= "/kaggle/input/nanumsquare"
fontpaths = fm.findSystemFonts(fontpaths = fontpaths, fontext='ttf')
font_list for font_file in font_list:
fm.fontManager.addfont(font_file)=False) fm._load_fontmanager(try_read_cache
<matplotlib.font_manager.FontManager at 0x7a781db01330>
import matplotlib.pyplot as plt
'font', family='NanumSquare_ac') plt.rc(
import seaborn as sns
="NanumSquare_ac") sns.set_theme(font
데이터 불러오기
= pd.read_csv('/kaggle/input/marketing/Customer_info.csv')
customer = pd.read_csv('/kaggle/input/marketing/Discount_info.csv')
discount = pd.read_csv('/kaggle/input/marketing/Marketing_info.csv')
marketing = pd.read_csv('/kaggle/input/marketing/Onlinesales_info.csv')
onlinesales = pd.read_csv('/kaggle/input/marketing/Tax_info.csv') tax
= {
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.월.replace(month_dict) discount[
/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)
'날짜'] = pd.to_datetime(marketing['날짜'], format='%Y-%m-%d') marketing[
'거래날짜'] = pd.to_datetime(onlinesales['거래날짜'], format='%Y-%m-%d') onlinesales[
# 연, 월, 일 추출
'연'] = onlinesales['거래날짜'].dt.year
onlinesales['월'] = onlinesales['거래날짜'].dt.month
onlinesales['일'] = onlinesales['거래날짜'].dt.day onlinesales[
고객의 실구매금액
- 세전가격 = (평균비용 * 수량) * (1-할인율)
- 세후가격(실구매금액) = 세전가격*(1+세율)
= (onlinesales
sales ='고객ID', how='left')
.merge(customer, on='제품카테고리', how='left')
.merge(tax, on='거래날짜', right_on='날짜', how='left')
.merge(marketing, left_on=['월','제품카테고리'], how='left')
.merge(discount, on
)
'세전가격'] = np.where(sales.쿠폰상태 == 'Used',
sales[* sales.수량) * (1-(1/100 * sales.할인율)) + sales.배송료,
(sales.평균금액 * sales.수량) + sales.배송료
(sales.평균금액
)'세후가격'] = sales.세전가격*(1+sales.GST) sales[
① 세분화 방식 선택
카테고리별 판매금액 및 판매수량의 비중
= sales.loc[:, ['제품카테고리', '수량', '세후가격']].groupby('제품카테고리').sum()
t3 = pd.concat([t3, t3 / t3.sum()], axis=1)
t3 = ['판매수량', '판매금액', '판매수량_비율', '판매금액_비율']
t3.columns =True) t3.reset_index(inplace
import squarify
= t3.제품카테고리 + '\n(' + round(t3.판매금액_비율*100, 1).astype(str) + ')'
label =label, ec = 'black',
squarify.plot(t3.판매금액_비율, label= sns.color_palette("Spectral", len(t3)))
color "off")
plt.axis('카테고리별 판매금액 비중')
plt.suptitle('고가 제품인 Nest-USA가 전체 판매금액의 50%를 차지',fontsize=9, color='gray')
plt.title( plt.show()
import squarify
= t3.제품카테고리 + '\n(' + round(t3.판매수량_비율*100, 1).astype(str) + ')'
label =label, ec = 'black',
squarify.plot(t3.판매수량_비율, label= sns.color_palette("Spectral", len(t3)))
color "off")
plt.axis('카테고리별 판매수량 비중')
plt.suptitle('Office, Lifestyle, Apparel 등 가격이 낮은 카테고리의 판매수량 비중이 높음',fontsize=9, color='gray')
plt.title( plt.show()
② 고객별 RFM/P(Recency, Frequency, Monetary value per Product category) 계산
= (sales
Recency '고객ID', '제품카테고리'])['거래날짜']
.groupby([max()
.lambda x: pd.to_datetime('2019-12-31') - x)
.transform('Recency')
.rename(
.to_frame()
.reset_index() )
= (sales
Duration '고객ID', '제품카테고리'])['거래날짜']
.groupby([min()
.lambda x: pd.to_datetime('2019-12-31') - x)
.transform('Duration')
.rename(
.to_frame()
.reset_index() )
= (sales
Frequency '고객ID', '제품카테고리', '거래날짜']]
.loc[:, ['고객ID', '제품카테고리', '거래날짜'])
.groupby([1)
.head('고객ID', '제품카테고리'])
.groupby([
.count()'거래날짜']
.loc[:,
.to_frame()'거래날짜':'Frequency'}, axis=1)
.rename({
.reset_index()= lambda x: x.Frequency - 1)
.assign(Frequency )
= (sales
Monetary '고객ID','제품카테고리', '세후가격']]
.loc[:,['고객ID', '제품카테고리'])
.groupby([sum()
.'세후가격':'Monetary'}, axis=1)
.rename({
.reset_index() )
= 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[
③ 고객별 RFM/P 결과 해석
import seaborn as sns
# 카테고리별 박스플롯
='Monetary', y='제품카테고리', orient='h', data=RFM)
sns.boxplot(x
'Monetary Value')
plt.xlabel('')
plt.ylabel('카테고리별 Monetary Value의 박스 그림')
plt.suptitle('Nest 계열의 Monetary value \$200 을 상회하고 다른 카테고리는 \$200을 넘지 않음',fontsize=9, color='gray')
plt.title( plt.show()
import seaborn as sns
# 카테고리별 박스플롯
='Frequency', y='제품카테고리', orient='h', data=RFM)
sns.boxplot(x
'카테고리별 Frequency의 박스 그림')
plt.suptitle('Nest 계열과 다른 카테고리 간에 거의 차이 없으며 반복구매는 대부분 0~1회 발생함',fontsize=9, color='gray')
plt.title('')
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
= params
r, alpha, a, b
= gammaln(r+x) - gammaln(r) + r*np.log(alpha + 1e-6)
ln_A_1 = (gammaln(a+b) + gammaln(b+x) - gammaln(b) - gammaln(a+b+x))
ln_A_2 = -(r+x) * np.log(alpha+T+1e-6)
ln_A_3 = x.copy()
ln_A_4 > 0] = (
ln_A_4[ln_A_4 + 1e-6) -
np.log(a+ ln_A_4[ln_A_4 > 0] - 1 + 1e-6) -
np.log(b +ln_A_4[ln_A_4 > 0]) * np.log(alpha+t_x+ 1e-6)
(r
)
= np.where(x>0, 1, 0)
delta
= ln_A_1 + ln_A_2 + np.log(np.exp(ln_A_3) + delta * np.exp(ln_A_4) + 1e-6)
log_likelihood
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
'''
= 1 / grouped['Duration'].max()
scale = grouped['Recency'] * scale
scaled_recency = grouped['Duration'] * scale
scaled_T
= np.ones(4)
current_init_params
= minimize(
output
_func_caller,='Nelder-Mead',
method=0.0001,
tol=current_init_params,
x0=([grouped['Frequency'], scaled_recency, scaled_T], negative_log_likelihood),
args={'maxiter':2000}
options
)
= output.x[0]
r = output.x[1]
alpha = output.x[2]
a = output.x[3]
b
/= scale
alpha
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
--
고객별 예상 구매 횟수
'''
= (a+b+x-1) / (a-1)
first_term = r+x
hyp2f1_a = b+x
hyp2f1_b = a + b + x - 1
hyp2f1_c = t / (alpha + T + t)
hyp2f1_z = hyp2f1(hyp2f1_a, hyp2f1_b, hyp2f1_c, hyp2f1_z)
hyp_term = (1 - ((alpha+T) / (alpha+T+t))**(r+x) * hyp_term)
second_term = np.where(x > 0, 1, 0)
delta = 1 + delta * (a/(b+x-1)) * ((alpha+T) / (alpha+t_x))**(r+x)
denominator
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
= params
p, q, gamma
= 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))
log_likelihood
return -log_likelihood.sum()
def fit_ggf(grouped):
= np.ones(3)
current_init_params
= minimize(
output
_func_caller,='Nelder-Mead',
method=0.0001,
tol=current_init_params,
x0=([grouped['Frequency'], grouped['Monetary']], negative_log_likelihood_ggf),
args={'maxiter':2000}
options
)
= output.x[0]
p = output.x[1]
q = output.x[2]
gamma
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:
= [np.mean(invgamma.rvs(q, scale=p*gamma, size=100)) for i in range(100)]
z = np.mean(z)
expectation_z = (q-1)/(p*x+q-1)
weight
elif q == 1:
= 0
expectation_z = 0
weight
else:
= p*gamma/(q-1)
expectation_z = (q-1)/(p*x+q-1)
weight
return weight*expectation_z + (1-weight)*z_bar
고객평생가치 계산
= pd.DataFrame(columns=['고객ID', '제품카테고리', 'Recency', 'Frequency',
df 'Duration', 'Monetary','predicted_purchases', 'expected_average_profit'])
= 52
t
for category, grouped in RFM.groupby('제품카테고리'):
if (category == 'More Bags') | (category == 'Android'):
continue
print(category)
print('----------------------')
= fit_bgf(grouped)
r, alpha, a, b 'predicted_purchases'] = calculate_conditional_expectation(t,
grouped['Frequency'],
grouped['Recency'],
grouped['Duration'])
grouped[
= fit_ggf(grouped)
p, q, gamma
'expected_average_profit'] = calculate_conditional_expectation_ggf(grouped['Frequency'], grouped['Monetary'])
grouped[
= pd.concat([df, grouped], ignore_index=True) df
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)
'CLV'] = df['predicted_purchases'] * df['expected_average_profit'] df[
= df.pivot(index='고객ID',columns='제품카테고리', values='CLV').fillna(0) table
= pd.qcut(table.sum(axis=1), q=10, labels=np.arange(10)+1).rename('Group') groups
⑤ 결과 해석
sum(axis=1) / groups.value_counts()).plot.bar()
(t7.'분위별 고객당 평균 Customer Lifetime Value')
plt.suptitle('10분위 \$2,926 1분위는 \$92 로 크게 차이남',fontsize=9, color='gray') plt.title(
Text(0.5, 1.0, '10분위 \\$2,926 1분위는 \\$92 로 크게 차이남')
= table.join(groups).groupby('Group', observed=False).sum()
t7 = "viridis"
cmap =(10, 5))
plt.figure(figsize=cmap)
sns.heatmap(t7, cmap'분위/카테고리별 추정 고객평생가치 합산')
plt.suptitle('모든 분위에 걸쳐 Nest 고객평생가치 기여가 큼',fontsize=9, color='gray')
plt.title(# plt.show()
'plot.png', bbox_inches="tight") plt.savefig(
= t7.apply(lambda col: col / t7.sum(axis=1), axis=0) t8
= table.sum(axis=0).sort_values()[:10].index.values other_cols
= [col for col in t8.columns.values if col not in other_cols] main_cols
'Others'] = t8[other_cols].sum(axis=1)
t8[= t8[np.append(main_cols, 'Others')] t8
=True)
t8.plot.bar(stacked
="제품카테고리", bbox_to_anchor = (1,1))
plt.legend(title
plt.yticks([])'분위별 카테고리들의 CLV 차지 비중')
plt.suptitle('분위가 높아질수록 Nest, Nest-USA가 차지하는 비중이 커짐',fontsize=9, color='gray') plt.title(
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/