# 이탈 분석을 위한 머신러닝(현재 팀 내 개인 별로 진행)
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 )