내일배움캠프

데이터 분석 트랙 94일차 25.06.23. [TIL]

jjaio8986 2025. 6. 23. 21:21
  • 최종 프로젝트 머신 러닝

# 이탈 분석을 위한 머신러닝(현재 팀 내 개인 별로 진행)

 0. 시계열 분석을 통한 이탈 간격 확인

  0.1. 전체 고객군의 이탈 간격

전체 고객, 기간의 리텐션

 # 해석 : 첫 구매후 재방문까지 걸리는 시간의 대부분이 30일을 기점으로 이루어진다.

 

  0.2. Segment별 이탈 통계 및 이탈 비율

Segment count mean median min max
Churn Risk 938 58.511 25.0 3 658
Growth Potential 535 15.297 7.0 1 259
Active Low Value 719 3.818 2.0 1 76
Top Value 308 2.029 2.0 1 5

 

Recency_Bin 0-7d 8-14d 15-30d 31-60d 61-90d 91-180d 180d+
Segment              
Active Low Value 0 0 0.076 1.0 1.0 NaN NaN
Churn Risk 0 0 0.02 1.0 1.0 1.0 1.0
Growth Potential 0 0 0.04 1.0 1.0 1.0 1.0
Top Value 0 NaN NaN NaN NaN NaN NaN

 

# 최종적으로 "이탈 기간"은 마지막 구매일로부터 30일 이상 경과시 이탈로 지정!

분석 요약 (Segment + Recnecy + 이탈율)
 - Top Value 고객은 대부분 최근 1주 내 구매한다.
 - Churn Risk는 평균 Recency가 58일 중앙값도 25일로 이탈 정의 기준(30일) 이상에 가깝다.

Recency가 **30일 이상(31~60일)**부터는 **이탈률이 100%**로 급증

30일 이하에서는 이탈 비율이 낮지만, 구간마다 소폭 존재

Top Value는 거의 이탈 없음 → 장기 관리를 위한 우수고객

(30일 이상 고객은 이탈 고객으로 선정![전체, Segment 전체에 적용!])

 

# 추가 전체 고객 잔존율 확인

# 대부분 80%의 유지율을 보이고 있다!
# 19년 4월 이후 구매 고객은 총 Active Low Value = 1명, Churn Risk = 7명, Growth Potential = 1명으로 총 9명 이다.
이들은 19년 1~4월 첫 구매 고객이 아니다.[기존이 아닌 신규로 구분되거나 이상치로 봐야하는 고객들!]
 
만일 "전체 기간"으로 이탈 예측 머신러닝을 학습시 이들은 머신러닝 분석 대상에서 제외!
 

 

1. 1차 머신러닝 XGBoost

# 이탈 예측 시작
분석 대상 : 전체 고객
기준일 : 최근 3개월(20년 12월 11일 기준 90일 이전)
이탈 기준 : 기준일로부터 30일 이상 재구매 기록이 없는 고객 == 이탈
예측 목적 : 이탈 여부 예측 (현재 서비스를 유지한 상황에서 온라인으로 전환시 이탈을 방지하기 위해서)
예측 단위 : household_key
모델 결과 : "이탈 확률", 해당 고객의 "이탈 여부"(이탈:0, 유지:1)
1차 분석용 피쳐 : 총 구매 횟수, "구매 금액 총합과 평균, 최대값", 재구매 주기 변화량(증가or감소 추세),
           시간 관련 : 첫 구매 후 경과 일수, 최근 구매 시점과 기준일과의 거리(Recency 이건 제외?), 계절성 변수(월, 요일, 휴일여부)
           고객 세분화 변수 : RFM 점수, 세그먼트 라벨
           활동성 지표 : 사이트 방문 수, 앱 로그인 수(가능하다면)
파생 피쳐 : 구매 빈도 변화율(최근 3개월 구매 횟수 / 이전 3개월 구매 횟수), 구매 금액 증감율(최근 평균 구매 금액 / 이전 평균 구매 금액), 재구매율(총 재구매 고객 대비 횟수 비율)

- 피쳐 중요도 평가 및 재선정 : 새로운 피처 추가 후 모델 학습(XGBoost로!) SHAP 값이나 피처 중요도 기반 상위 피처를 재선정, 상관 관계가 너무 높은 피처 중복 제거

<코드 구현>

df_cleaned : EDA단계에서 이상치 및 결측치를 제거한 통합 데이터 프레임!

# df_cleaned(이상치 제거 데이터)에 시간 데이터 적용
df_cleaned = add_date_and_weekday(df_cleaned, day_col='DAY', date_col='REAL_DATE', weekday_col='DAY_OF_WEEK')

# 월 단위 컬럼 생성 (예: 2019-01)
df_cleaned['YEAR_MONTH'] = df_cleaned['REAL_DATE'].dt.to_period('M').astype(str)

# household_key 기준으로 RFM 세그먼트 병합
df_cleaned = df_cleaned.merge(rfm[['household_key', 'Segment']], on='household_key', how='left')
# 머신러닝 학습용 데이터 프레임
df_ml = df_cleaned.copy()
# 1. 기준 데이터 정의
# 기준일: 전체 데이터의 최대 일자
reference_date = df_ml['REAL_DATE'].max()

# 최근 3개월 데이터로 필터링
start_date = reference_date - pd.DateOffset(months=3)
df_recent = df_ml[df_ml['REAL_DATE'] >= start_date]
# 2. 고객별 마지막 구매일 계산
last_purchase = df_recent.groupby('household_key')['REAL_DATE'].max().reset_index()
last_purchase['days_since_last'] = (reference_date - last_purchase['REAL_DATE']).dt.days

# 이탈 여부 라벨링
last_purchase['churn_label'] = (last_purchase['days_since_last'] < 30).astype(int)  # 유지 = 1, 이탈 = 0
# 3. Feature Engineering  [기본값]
# 고객의 최근 3개월 구매 빈도, 구매금액, 최근 구매일
features = df_recent.groupby('household_key').agg({
    'REAL_DATE': ['min', 'max', 'count'],
    'SALES_VALUE': ['sum', 'mean'],
    'PRODUCT_ID': pd.Series.nunique
}).reset_index()

features.columns = ['household_key', 'first_date', 'last_date', 'purchase_count',
                    'total_sales', 'avg_sales', 'product_variety']

# Recency (일)
features['recency'] = (reference_date - features['last_date']).dt.days
features['tenure'] = (features['last_date'] - features['first_date']).dt.days
# 4. 라벨과 병합
df_model = features.merge(last_purchase[['household_key', 'churn_label']], on='household_key', how='left')

# 결측값 처리
df_model = df_model.fillna(0)

# X, y 분리
feature_cols = ['purchase_count', 'total_sales', 'avg_sales', 'product_variety', 'recency', 'tenure']
X = df_model[feature_cols]
y = df_model['churn_label']
# 5. 모델링 "XGBoost"
from sklearn.model_selection import train_test_split
from xgboost import XGBClassifier
from sklearn.metrics import classification_report, roc_auc_score

X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y, random_state=42, test_size=0.2)

model = XGBClassifier(use_label_encoder=False, eval_metric='logloss', random_state=42)
model.fit(X_train, y_train)

y_pred = model.predict(X_test)
y_proba = model.predict_proba(X_test)[:, 1]

print(classification_report(y_test, y_pred))
print(f"AUC: {roc_auc_score(y_test, y_proba):.4f}")
 
 <결과>
                     precision   recall    f1-score   support
0                    1.00          1.00       1.00       59
1                    1.00          1.00       1.00       408

accuracy                                       1.00      467
macro avg     1.00          1.00       1.00      467
weighted avg 1.00          1.00      1.00       467
 
AUC: 1.0000
 

 

# 결과 해석 : 과적합! 이탈 결과와 직접적으로 관련 있는 Recency를 X변수에 넣었기에 문제가 발생!


 

2. 2차 수정한 XGBoost 머신러닝

 2.1. 분석 설정

 - 분석 대상 : 전체 고객

 - 기준일 : 20년 12월 11일

 - 분석 기간 : 기준일로부터 90일 이전부터 기준일까지

 - 이탈 정의 : 마지막 거래일로부터 기준일 까지의 기간 간격이 30일 이상인 경우 이탈!

 - 예측 목적 : 이탈 여부 예측

 - 예측 단위 : household_key

 - 모델 결과 : "이탈 확률", "이탈 여부"

 - 피쳐 목록 : 'purchase_count'(최근 3개월 간 구매 횟수), 'total_sales'(최근 3개월 간 총 구매 금액). 'avg_sales'(최근 3개월 간 평균 구매 금액)
 - 추가 파생 피쳐 목록 : "총 할인금액('COUPON_DISC'+'COUPON_MATCH_DISC'+'RETAIL_DISC')", "평균 할인 금액(고객 별 'COUPON_DISC'+'COUPON_MATCH_DISC'+'RETAIL_DISC')", "평균 구매 간격", "주말 거래 비율", "요일별 구매 분포 엔트로피"

 - 머신러닝 모델 : XGBoost

 

 2.2. 코드 구현

# 2차 XGBoost
# 전체 파이프라인
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from scipy.stats import entropy
from sklearn.model_selection import train_test_split
from xgboost import XGBClassifier
from sklearn.metrics import classification_report, roc_auc_score

# 기준일 및 분석 기간 설정
reference_date = pd.to_datetime("2020-12-11")
start_date = reference_date - pd.Timedelta(days=90)
df_recent = df_cleaned[(df_cleaned['REAL_DATE'] >= start_date) & (df_cleaned['REAL_DATE'] <= reference_date)]

# 할인 계산
df_recent.loc[:, 'total_discount'] = df_recent[['COUPON_DISC', 'COUPON_MATCH_DISC', 'RETAIL_DISC']].sum(axis=1)

# 주말 여부
df_recent.loc[:, 'is_weekend'] = df_recent['DAY_OF_WEEK'].isin(['Saturday', 'Sunday'])


# 요일별 거래 엔트로피 계산용
def calculate_entropy(series):
    counts = series.value_counts(normalize=True)
    return entropy(counts)

# 고객별 피처 집계
features = df_recent.groupby('household_key').agg({
    'REAL_DATE': ['min', 'max', 'count'],
    'SALES_VALUE': ['sum', 'mean'],
    'total_discount': ['sum', 'mean'],
    'is_weekend': 'mean',
    'DAY_OF_WEEK': lambda x: calculate_entropy(x)
}).reset_index()

# 컬럼 이름 정리
features.columns = ['household_key', 'first_date', 'last_date', 'purchase_count',
                    'total_sales', 'avg_sales', 'total_discount', 'avg_discount',
                    'weekend_ratio', 'weekday_entropy']

# 추가 파생 피처(파생 피쳐만 만듬)
features['recency'] = (reference_date - features['last_date']).dt.days
features['tenure'] = (features['last_date'] - features['first_date']).dt.days
features['Frequency'] = features['purchase_count'] / 13  # 주당 평균 (13주)
features['avg_interval'] = features['tenure'] / (features['purchase_count'] - 1).replace(0, np.nan)

# 라벨링
last_purchase = df_recent.groupby('household_key')['REAL_DATE'].max().reset_index()
last_purchase['churn_label'] = (reference_date - last_purchase['REAL_DATE']).dt.days < 30
last_purchase['churn_label'] = last_purchase['churn_label'].astype(int)  # 유지:1, 이탈:0

# 병합
df_model = features.merge(last_purchase[['household_key', 'churn_label']], on='household_key', how='left')
df_model.fillna(0, inplace=True)

# 모델링(피쳐에 반영한 거!)
feature_cols = ['purchase_count', 'total_sales', 'avg_sales',
                'total_discount', 'avg_discount', 'avg_interval', 'weekend_ratio', 'weekday_entropy']
X = df_model[feature_cols]
y = df_model['churn_label']

X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y, random_state=42, test_size=0.2)

model = XGBClassifier(eval_metric='logloss', random_state=42)
model.fit(X_train, y_train)

y_pred = model.predict(X_test)
y_proba = model.predict_proba(X_test)[:, 1]

print(classification_report(y_test, y_pred))
print(f"AUC: {roc_auc_score(y_test, y_proba):.4f}")
 
 
<결과>
                     precision   recall    f1-score   support
0                    0.64          0.59       0.61       58
1                    0.94          0.95       0.95       408

accuracy                                       0.91      466
macro avg     0.79          0.77       0.78      466
weighted avg 0.90          0.91      0.91       466
 
AUC: 0.9351

 

2차 XGBoost 결과
 - 클래스 불균형 문제 해결 : Recency와 tenure를 제거! 피쳐를 추가함.
 - 스코어 : 전체 정확도(0.94), 재현률(0.59), 예측한 이탈 고객 중 실제 이탈 비율(0.64), 모델의 분류 성능(0.9351)

 즉, 이탈 클래스의 recall이 낮다(이탈 고객을 놓치는 비율이 높다...)
 
 따라서 이탈 고객을 많이 맞추는 데 더 집중할 필요가 있다...
 (피쳐 중에서 RFM Segment도 추가해야하나?)
cf) weekday_entropy : 요일 중에서 주말에 특정 활동이 몰려있으면 엔트로피는 낮음(예측 가능), 모든 요일에 고르게 활동하면 엔트로피는 높음(불확실성이 크다.)
 개선 방법
 1. 이탈 클래스 성능 향상 : 클래스 불균형 처리(현재 이탈 고객이 상대적으로 소수!) 따라서
  - model = XGBClassifier(scale_pos_weight=weight_ratio)
  - Threshold 튜닝

 

# 이탈 예측에 가장 큰 영향을 미치는 대상
import shap

explainer = shap.Explainer(model)
shap_values = explainer(X_test)

# SHAP 요약 플롯 (막대형: 피처 중요도 순위 시각화)
shap.summary_plot(shap_values, X_test)