본문 바로가기
[코드쉐도잉]/산업

[CRM] e-commerce

by 에디터 윤슬 2025. 1. 10.

라이브러리 호출

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
warnings.filterwarnings("ignore")

import datetime as dt
pd.set_option('display.max_columns', None)

sns.set_style('whitegrid')
palette = 'Set2'

import squarify
from operator import attrgetter
import matplotlib.colors as mcolors
from sklearn.metrics import (silhouette_score,
                             calinski_harabasz_score,
                             davies_bouldin_score)
from lifetimes import BetaGeoFitter, GammaGammaFitter
from lifetimes.plotting import plot_period_transactions
%matplotlib inline

 

데이터 체크

  • 기본 정보 체크 함수 생성
# 데이터 체크

def check_data(dataframe, head=5):
    print(" SHAPE ".center(70,'-'))
    print('Rows: {}'.format(dataframe.shape[0]))
    print('Columns: {}'.format(dataframe.shape[1]))
    print(" TYPES ".center(70,'-'))
    print(dataframe.dtypes)
    print(" HEAD ".center(70,'-'))
    print(dataframe.head(head))
    print(" TAIL ".center(70,'-'))
    print(dataframe.tail(head))
    print(" MISSING VALUES ".center(70,'-'))
    print(dataframe.isnull().sum())
    print(" DUPLICATED VALUES ".center(70,'-'))
    print(dataframe.duplicated().sum())
    print(" QUANTILES ".center(70,'-'))
    print(dataframe.quantile([0, 0.05, 0.50, 0.95, 0.99, 1]).T)
    
check_data(merged_df)

 

  • describe 히트맵 생성하는 함수
# describe 함수
def desc_stats(dataframe):
    desc_df = pd.DataFrame(index= dataframe.columns, 
                           columns= dataframe.describe().T.columns,
                           data= dataframe.describe().T)
    
    f,ax = plt.subplots(figsize=(10,
                                 desc_df.shape[0] * 0.81))
    sns.heatmap(desc_df,
                annot = True,
                cmap = "Greens",
                fmt = '.2f',
                ax = ax,
                linecolor = 'white',
                linewidths = 1.1,
                cbar = False,
                annot_kws = {"size": 12})
    plt.xticks(size = 18)
    plt.yticks(size = 14,
               rotation = 0)
    plt.title("Descriptive Statistics", size = 14)
    plt.show()
    
desc_stats(merged_df.select_dtypes(include = [float, int]))

 

RFM

  • RFM Dataframe 생성
# rfm dataframe 생성

def rfm(df):

    present_day = df['transaction_date'].max() + dt.timedelta(days = 2)  # Timestamp('2022-08-02 00:00:00')

    rfm = df.groupby('customer_id').agg({'transaction_date': lambda x: (present_day - x.max()).days,
                                        'session_id': lambda x: x.nunique(),
                                        'amount': lambda x: x.sum()})

    rfm.columns = ['recency', 'frequency', 'monetary']
    rfm = rfm.reset_index()

    return rfm

rfm_df = rfm(merged_df)
rfm_df

  • RFM Score
# rfm score 

def get_rfm_scores(dataframe) -> pd.core.frame.DataFrame:

    df_ = dataframe.copy()
    df_["recency_score"] = pd.qcut(df_["recency"], 5, labels=[5, 4, 3, 2, 1])
    df_["frequency_score"] = pd.qcut(
        df_["frequency"].rank(method="first"), 5, labels=[1, 2, 3, 4, 5]
    )
    df_["monetary_score"] = pd.qcut(df_["monetary"], 5, labels=[1, 2, 3, 4, 5])
    df_["RFM_SCORE"] = df_["recency_score"].astype(str) + df_["frequency_score"].astype(str) + \
        df_["monetary_score"].astype(str)

    return df_


rfm_df = get_rfm_scores(rfm_df)
rfm_df

  • RFM 세그먼트
# rfm segmentation

def get_segment(df):
    seg_map= {
        r'111|112|121|131|141|151': 'Lost customers',
        r'332|322|233|232|223|222|132|123|122|212|211': 'Hibernating customers', 
        r'155|154|144|214|215|115|114|113': 'Cannot Lose Them',
        r'255|254|245|244|253|252|243|242|235|234|225|224|153|152|145|143|142|135|134|133|125|124': 'At Risk',
        r'331|321|312|221|213|231|241|251': 'About To Sleep',
        r'535|534|443|434|343|334|325|324': 'Need Attention',
        r'525|524|523|522|521|515|514|513|425|424|413|414|415|315|314|313': 'Promising',
        r'512|511|422|421|412|411|311': 'New Customers',
        r'553|551|552|541|542|533|532|531|452|451|442|441|431|453|433|432|423|353|352|351|342|341|333|323': 'Potential Loyalist',
        r'543|444|435|355|354|345|344|335': 'Loyal',
        r'555|554|544|545|454|455|445': 'Champions'
    }
    
    df['segment'] = df['RFM_SCORE'].replace(seg_map, regex = True)
    return df

rfm_df = get_segment(rfm_df)
rfm_df

 

  • 세그먼트 맵 시각화
# segmentation Map

seg_map= {
        r'111|112|121|131|141|151': 'Lost customers',
        r'332|322|233|232|223|222|132|123|122|212|211': 'Hibernating customers', 
        r'155|154|144|214|215|115|114|113': 'Cannot Lose Them',
        r'255|254|245|244|253|252|243|242|235|234|225|224|153|152|145|143|142|135|134|133|125|124': 'At Risk',
        r'331|321|312|221|213|231|241|251': 'About To Sleep',
        r'535|534|443|434|343|334|325|324': 'Need Attention',
        r'525|524|523|522|521|515|514|513|425|424|413|414|415|315|314|313': 'Promising',
        r'512|511|422|421|412|411|311': 'New Customers',
        r'553|551|552|541|542|533|532|531|452|451|442|441|431|453|433|432|423|353|352|351|342|341|333|323': 'Potential Loyalist',
        r'543|444|435|355|354|345|344|335': 'Loyal',
        r'555|554|544|545|454|455|445': 'Champions'
    }

segments = rfm_df["segment"].value_counts().sort_values(ascending=False)
fig = plt.gcf()
ax = fig.add_subplot()
fig.set_size_inches(16, 10)
squarify.plot(
    sizes=segments,
    label=[label for label in seg_map.values()],
    color=[
        "#AFB6B5",
        "#F0819A",
        "#926717",
        "#F0F081",
        "#81D5F0",
        "#C78BE5",
        "#748E80",
        "#FAAF3A",
        "#7B8FE4",
        "#86E8C0",
    ],
    pad=False,
    bar_kwargs={"alpha": 1},
    text_kwargs={"fontsize": 15},
)
plt.title("Customer Segmentation Map", fontsize=20)
plt.xlabel("Frequency", fontsize=18)
plt.ylabel("Recency", fontsize=18)
plt.show()

 

  • 모델 평가
# model evaluation

print(' RFM Model Evaluation '.center(70, '='))
X = rfm_df[['recency_score', 'frequency_score']]
labels = rfm_df['segment']
print(f'Number of Observations: {X.shape[0]}')
print(f'Number of Segments: {labels.nunique()}')
print(f'Silhouette Score: {round(silhouette_score(X, labels), 3)}')
print(f'Calinski Harabasz Score: {round(calinski_harabasz_score(X, labels), 3)}')
print(f'Davies Bouldin Score: {round(davies_bouldin_score(X, labels), 3)} \n{70*"="}')

 

  • 세그먼트 확인
# segment analysis

rfm_df[['recency','monetary','frequency','segment']]\
.groupby('segment')\
.agg({'mean','std','max','min'})

 

RFM 시각화

plt.figure(figsize = (18, 8))
ax = sns.countplot(data = rfm_df,
                   x = 'segment',
                   palette = palette)
total = len(rfm_df.segment)
for patch in ax.patches:
    percentage = '{:.1f}%'.format(100 * patch.get_height()/total)
    x = patch.get_x() + patch.get_width() / 2 - 0.17
    y = patch.get_y() + patch.get_height() * 1.005
    ax.annotate(percentage, (x, y), size = 14)
plt.title('Number of Customers by Segments', size = 16)
plt.xlabel('Segment', size = 14)
plt.ylabel('Count', size = 14)
plt.xticks(size = 10)
plt.yticks(size = 10)
plt.show()

 

plt.figure(figsize=(18, 8))
sns.scatterplot(
    data=rfm_df, x="recency", y="frequency", hue="segment", palette=palette, s=60
)
plt.title("Recency & Frequency by Segments", size=16)
plt.xlabel("Recency", size=12)
plt.ylabel("Frequency", size=12)
plt.xticks(size=10)
plt.yticks(size=10)
plt.legend(loc="best", fontsize=12, title="Segments", title_fontsize=14)
plt.show()

 

fig, axes = plt.subplots(1, 3, figsize=(18, 8))
fig.suptitle("RFM Segment Analysis", size=14)
feature_list = ["recency", "monetary", "frequency"]
for idx, col in enumerate(feature_list):
    sns.boxplot(
        ax=axes[idx], data=rfm_df, x="segment", y=feature_list[idx], palette=palette
    )
    axes[idx].set_xticklabels(axes[idx].get_xticklabels(), rotation=60)
    if idx == 1:
        axes[idx].set_ylim([0, 400])
    if idx == 2:
        axes[idx].set_ylim([0, 30])
plt.tight_layout()
plt.show()

 

fig, axes = plt.subplots(3, 1, figsize=(16, 12))
fig.suptitle('RFM Segment Analysis', size = 14)
feature_list = ['recency', 'monetary', 'frequency']
for idx, col in enumerate(feature_list):
    sns.histplot(ax = axes[idx], data = rfm_df,
                 hue = 'segment', x = feature_list[idx],
                 palette= palette)
    if idx == 1:
        axes[idx].set_xlim([0, 400])
    if idx == 2:
        axes[idx].set_xlim([0, 30])
plt.tight_layout()
plt.show()

코호트 분석

def CohortAnalysis(dataframe):
    
    yearago = dataframe['transaction_date'].max() - pd.DateOffset(years = 1)
    mask = dataframe['transaction_date'] >= yearago
    dataframe = dataframe[mask]

    data = dataframe.copy()
    data = data[["customer_id", "session_id", "transaction_date"]].drop_duplicates()
    data["order_month"] = data["transaction_date"].dt.to_period("M")
    data["cohort"] = (
        data.groupby("customer_id")["transaction_date"].transform("min").dt.to_period("M")
    )
    cohort_data = (
        data.groupby(["cohort", "order_month"])
        .agg(n_customers=("customer_id", "nunique"))
        .reset_index(drop=False)
    )
    cohort_data["period_number"] = (cohort_data.order_month - cohort_data.cohort).apply(
        attrgetter("n")
    )
    cohort_pivot = cohort_data.pivot_table(
        index="cohort", columns="period_number", values="n_customers"
    )
    cohort_size = cohort_pivot.iloc[:, 0]
    retention_matrix = cohort_pivot.divide(cohort_size, axis=0)
    with sns.axes_style("white"):
        fig, ax = plt.subplots(
            1, 2, figsize=(12, 8), sharey=True, gridspec_kw={"width_ratios": [1, 11]}
        )
        sns.heatmap(
            retention_matrix,
            mask=retention_matrix.isnull(),
            annot=True,
            cbar=False,
            fmt=".0%",
            cmap="coolwarm",
            ax=ax[1],
        )
        ax[1].set_title("Monthly Cohorts: User Retention", fontsize=14)
        ax[1].set(xlabel="# of periods", ylabel="")
        white_cmap = mcolors.ListedColormap(["white"])
        sns.heatmap(
            pd.DataFrame(cohort_size).rename(columns={0: "cohort_size"}),
            annot=True,
            cbar=False,
            fmt="g",
            cmap=white_cmap,
            ax=ax[0],
        )
        fig.tight_layout()
    
CohortAnalysis(merged_df)

  • 코호트와 주문 월 생성
data["order_month"] = data["transaction_date"].dt.to_period("M")
data["cohort"] = (
    data.groupby("customer_id")["transaction_date"].transform("min").dt.to_period("M")
)

	•	`order_month`: 각 거래가 발생한 월.
	•	`cohort`: 각 고객이 처음 거래한 월(가입 또는 첫 구매 시점).
  • 코호트 데이터 집계
cohort_data = (
    data.groupby(["cohort", "order_month"])
    .agg(n_customers=("customer_id", "nunique"))
    .reset_index(drop=False)
)

•	각 코호트(`cohort`)와 주문 월(`order_month`)에 대해 고유 고객 수(`n_customers`)를 집계합니다.

 

  • 유지율 계산
cohort_pivot = cohort_data.pivot_table(
    index="cohort", columns="period_number", values="n_customers"
)
cohort_size = cohort_pivot.iloc[:, 0]
retention_matrix = cohort_pivot.divide(cohort_size, axis=0)

	•	`period_number`: 각 코호트가 시작된 이후 몇 번째 기간인지 나타냅니다.
	•	`retention_matrix`: 각 코호트의 유지율을 계산합니다.

 

  • 히트맵 시각화
sns.heatmap(
    retention_matrix,
    mask=retention_matrix.isnull(),
    annot=True,
    cbar=False,
    fmt=".0%",
    cmap="coolwarm",
    ax=ax[1],
)

	•	유지율 데이터를 히트맵으로 시각화하여 각 코호트의 유지율 변화를 보여줍니다.

 

CLV(Customer Lifetime Value)

  • CLV 변수 생성
present_day = merged_df['transaction_date'].max() + dt.timedelta(days = 2) 

cltv_df = merged_df.groupby("customer_id").agg(
    {
        "transaction_date": [
            lambda x: (x.max() - x.min()).days,
            lambda x: (present_day - x.min()).days,
        ],
        "session_id": "nunique",
        "amount": "sum",
    }
)

cltv_df.columns = cltv_df.columns.droplevel(0)
cltv_df.columns = ["recency", "T", "frequency", "monetary"]
cltv_df.head()


>>> lambda x: (x.max() - x.min()).days
	•	각 고객의 거래 날짜 중 가장 최근 날짜(`x.max()`)와 가장 오래된 날짜(`x.min()`)의 차이를 계산합니다.
	•	이는 고객의 활동 기간(Recency)을 나타냅니다.
    
>>> lambda x: (present_day - x.min()).days
	•	현재 날짜(`present_day`)와 고객의 첫 거래 날짜(`x.min()`) 간의 차이를 계산합니다.
	•	이는 고객이 처음 거래한 이후 경과한 시간(T)을 나타냅니다.
    
>>> "session_id": "nunique"
	•	고유 세션 수를 계산하여 고객이 몇 번의 세션에서 활동했는지 나타냅니다.
	•	이는 고객의 **활동 빈도(Frequency)**를 나타냅니다.
    
>>> "amount": "sum"
	•	각 고객이 지출한 총 금액을 계산합니다.
	•	이는 고객의 **지출 금액(Monetary)**을 나타냅니다.
    
>>> cltv_df.columns = cltv_df.columns.droplevel(0)
cltv_df.columns = ["recency", "T", "frequency", "monetary"]
	•	`groupby`와 `agg`를 사용하면 다중 인덱스가 생성되므로, 이를 단순화하기 위해 첫 번째 레벨을 제거합니다.
	•	각 컬럼에 의미 있는 이름을 부여합니다:
	•	`recency`: 고객의 최근 활동 기간 (최신 거래와 최초 거래 간 차이).
	•	`T`: 고객이 처음 거래한 이후 경과한 시간.
	•	`frequency`: 고유 세션 수.
	•	`monetary`: 총 지출 금액.

 

  • AOV 계산  //  recency & tenure(활동 기간) 단위 변경  //  frequency 필터링
#Average Order Value
cltv_df["monetary"] = cltv_df["monetary"] / cltv_df["frequency"]

#Recency & Tenure
cltv_df["recency"] = cltv_df["recency"] / 7
cltv_df["T"] = cltv_df["T"] / 7

#Frequency
cltv_df = cltv_df[(cltv_df['frequency'] > 1)]


>>> cltv_df["monetary"] = cltv_df["monetary"] / cltv_df["frequency"]
	•	의미:
	•	`monetary`(총 지출 금액)를 `frequency`(구매 빈도)로 나누어 **평균 주문 금액(Average Order Value, AOV)**를 계산합니다.
	•	AOV는 고객이 한 번 구매할 때 평균적으로 얼마나 지출했는지를 나타냅니다.
	•	예시:
	•	고객이 총 5번 구매했고, 총 지출 금액이 5000이라면: 5000/5 = 1000
	•	따라서 `monetary` 컬럼은 이제 AOV를 나타냅니다.
    
>>> cltv_df["recency"] = cltv_df["recency"] / 7
cltv_df["T"] = cltv_df["T"] / 7
•	의미:
	•	`recency`와 `T` 값을 주 단위로 변환합니다.
	•	`recency`: 고객의 마지막 거래가 얼마나 최근에 이루어졌는지(일 단위 → 주 단위).
	•	`T`: 고객이 처음 거래한 이후 경과한 시간(일 단위 → 주 단위).
	•	예시:
	•	만약 `recency`가 14일이었다면: 14/7 = 2
	•	이 변환은 데이터를 더 직관적으로 해석할 수 있도록 도와줍니다.
    
>>> cltv_df = cltv_df[(cltv_df['frequency'] > 1)]

•	의미:
	•	구매 빈도(`frequency`)가 1보다 큰 고객만 필터링합니다.
	•	즉, 한 번 이상 구매한 고객만 분석에 포함됩니다.
	•	목적:
	•	CLTV 분석은 일반적으로 반복 구매를 기반으로 하기 때문에, 한 번만 구매한 고객은 제외하여 분석의 정확성을 높입니다.
	•	결과:
	•	구매 빈도가 1인 고객은 제외되며, 반복 구매를 한 고객만 남게 됩니다.

 

BG/NBD 모델

  • 모델 설정
BGF = BetaGeoFitter(penalizer_coef=0.001)  # avoid overfitting

BGF.fit(cltv_df["frequency"], cltv_df["recency"], cltv_df["T"])


Beta-Geometric/NBD (BG/NBD) 모델을 사용하여 고객 생애 가치(Customer Lifetime Value, CLTV)를 예측하기 위한 모델링 작업을 수행합니다. 
이 모델은 `lifetimes` 라이브러리의 `BetaGeoFitter` 클래스를 활용하여 고객의 구매 행동 데이터를 학습합니다.

(a) `BetaGeoFitter` 초기화
	•	`BetaGeoFitter(penalizer_coef=0.001)`:
	•	`BetaGeoFitter`는 BG/NBD 모델을 구현한 클래스입니다.
	•	`penalizer_coef=0.001`: 과적합(overfitting)을 방지하기 위해 정규화(regularization) 항을 추가합니다.
	•	값이 작을수록 과적합 방지 효과가 적고, 값이 클수록 더 강하게 정규화를 적용합니다.
(b) 데이터 학습
	•	`BGF.fit(cltv_df"frequency", cltv_df"recency", cltv_df"T")`:
	•	BG/NBD 모델에 고객 데이터를 학습시킵니다.
	•	입력 변수:
	1.	`frequency`: 고객의 반복 구매 횟수.
	2.	`recency`: 고객의 마지막 거래가 첫 거래 이후 얼마나 지났는지 (주 단위).
	3.	`T`: 고객의 첫 거래 이후 현재까지 경과한 시간 (주 단위).
모델의 역할
	•	BG/NBD 모델은 고객의 구매 행동을 기반으로 다음을 예측합니다:
	1.	특정 기간 동안 고객이 추가로 구매할 확률.
	2.	고객의 생애 동안 예상 구매 횟수.

 

  • Top 10 향후 1주 동안 고객 예상 구매 거래 수
# Top 10 Expected Number of Transaction (1 Week)

BGF.conditional_expected_number_of_purchases_up_to_time(
    1, cltv_df["frequency"], cltv_df["recency"], cltv_df["T"]
).sort_values(ascending=False).head(10).to_frame(
    "Expected Number of Transactions"
).reset_index()


1. 함수 설명
	•	`BGF.conditional_expected_number_of_purchases_up_to_time()`:
	•	BG/NBD 모델에서 제공하는 함수로, 특정 기간 동안 고객이 예상 구매할 거래 수를 계산합니다.
	•	입력값:
	•	`1`: 예측 기간 (향후 1주).
	•	`cltv_df"frequency"`: 각 고객의 구매 빈도.
	•	`cltv_df"recency"`: 고객의 마지막 거래가 첫 거래 이후 얼마나 지났는지 (주 단위).
	•	`cltv_df"T"`: 고객의 첫 거래 이후 현재까지 경과한 시간 (주 단위).
	•	출력값:
	•	각 고객의 예상 거래 횟수로 구성된 Pandas Series.
    
2. 정렬 및 상위 10명 추출
>>> .sort_values(ascending=False).head(10)

3. 데이터프레임 변환
>>> .to_frame("Expected Number of Transactions").reset_index()

 

  • Top 10 향후 1달 동안 고객 예상 구매 거래 수
# Top 10 Expected Number of Transaction (1 month)

BGF.conditional_expected_number_of_purchases_up_to_time(
    4, cltv_df["frequency"], cltv_df["recency"], cltv_df["T"]
).sort_values(ascending=False).head(10).to_frame(
    "Expected Number of Transactions"
).reset_index()

 

  • 반복 거래 빈도 분석 시각화
from lifetimes.plotting import plot_period_transactions
import matplotlib.pyplot as plt

# Plot the actual and predicted transactions for the specified period
plot_period_transactions(BGF, max_frequency=7)
plt.show()

1. `plot_period_transactions` 함수
	•	`plot_period_transactions(BGF, max_frequency=7)`:
	•	이 함수는 BG/NBD 모델이 예측한 거래 빈도와 실제 데이터에서 관측된 거래 빈도를 비교하는 히스토그램을 생성합니다.
	•	입력값:
	•	`BGF`: 학습된 Beta-Geometric/NBD 모델 객체.
	•	`max_frequency=7`: 플롯에 표시할 최대 거래 빈도. 여기서는 최대 7번의 반복 거래를 대상으로 분석합니다.
	•	출력:
	•	히스토그램:
	•	x축: 고객의 반복 거래 횟수(빈도).
	•	y축: 실제 데이터와 모델이 예측한 고객 수.
2. `plt.show()`
	•	Matplotlib의 플롯을 화면에 표시합니다.
결과 해석
	•	이 플롯은 모델의 성능을 평가하는 데 사용됩니다. 주요 요소는 다음과 같습니다:
	1.	파란 막대: 실제 데이터에서 관측된 각 거래 빈도의 고객 수.
	2.	검은 선: BG/NBD 모델이 예측한 각 거래 빈도의 고객 수.
해석 포인트
	•	파란 막대와 검은 선이 잘 일치하면, 모델이 실제 데이터를 잘 설명하고 있다는 것을 의미합니다.
	•	불일치가 크다면, 모델의 성능이 부족하거나 데이터의 특성을 제대로 반영하지 못했음을 나타냅니다.