[코드쉐도잉]/산업

[CRM] e-commerce

에디터 윤슬 2025. 1. 10. 14:18

라이브러리 호출

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 모델이 예측한 각 거래 빈도의 고객 수.
해석 포인트
	•	파란 막대와 검은 선이 잘 일치하면, 모델이 실제 데이터를 잘 설명하고 있다는 것을 의미합니다.
	•	불일치가 크다면, 모델의 성능이 부족하거나 데이터의 특성을 제대로 반영하지 못했음을 나타냅니다.