From 174e41c5b7861ba69893823f1cf00eb46e3f6fea Mon Sep 17 00:00:00 2001 From: Gnill82 Date: Sat, 30 Aug 2025 21:21:35 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=ED=85=90=EC=85=98=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B5=9C?= =?UTF-8?q?=EC=B4=88=20=EC=BB=A4=EB=B0=8B=20Add=20retention=20analysis=20c?= =?UTF-8?q?onfiguration=20file=20-=20Introduced=20`retention=5Fanalysis=5F?= =?UTF-8?q?config.json`=20to=20define=20settings=20for=20retention=20analy?= =?UTF-8?q?sis.=20-=20Configured=20analysis=20settings=20including=20corre?= =?UTF-8?q?lation=20threshold,=20top=20features,=20and=20minimum=20sample?= =?UTF-8?q?=20size.=20-=20Specified=20retention=20groups=20for=20analysis.?= =?UTF-8?q?=20-=20Listed=20columns=20to=20be=20excluded=20from=20the=20ana?= =?UTF-8?q?lysis.=20-=20Defined=20key=20metrics=20for=20day=200=20analysis?= =?UTF-8?q?.=20-=20Set=20output=20preferences=20for=20saving=20results=20i?= =?UTF-8?q?n=20various=20formats.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 11 + retention_analysis.md | 556 +++++++++++++++ retention_analysis.py | 1212 ++++++++++++++++++++++++++++++++ retention_analysis_config.json | 43 ++ 4 files changed, 1822 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 retention_analysis.md create mode 100644 retention_analysis.py create mode 100644 retention_analysis_config.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..3c11155 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(python:*)", + "Bash(pip install:*)", + "Read(C:\\Users/**)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/retention_analysis.md b/retention_analysis.md new file mode 100644 index 0000000..cf23b62 --- /dev/null +++ b/retention_analysis.md @@ -0,0 +1,556 @@ +# Retention Analysis Script Documentation + +## 개요 + +`retention_analysis.py`는 던전 스토커즈 게임의 신규 유저 리텐션 분석을 위한 고도화된 Python 분석 도구입니다. 이 스크립트는 통계적 방법론을 기반으로 신규 유저의 행동 패턴을 심층 분석하여 리텐션에 영향을 미치는 핵심 지표를 식별하고, 특히 D0(첫날) 이탈 문제에 대한 실행 가능한 인사이트를 제공합니다. + +## 프로젝트 배경 및 필요성 + +현재 던전 스토커즈는 **D0 이탈률이 65.9%**로 매우 높아 서비스 지속성에 심각한 위협이 되고 있습니다. 모바일 게임 업계 평균 D0 이탈률이 25-35%인 점을 고려하면, 이는 즉각적인 개선이 필요한 critical 수준입니다. + +이 스크립트는 데이터 기반의 과학적 접근을 통해: + +- **리텐션 영향 요인 정량화**: 어떤 지표가 유저 리텐션에 긍정적/부정적 영향을 미치는지 통계적으로 검증 +- **행동 패턴 차이 분석**: D0 이탈 유저와 잔존 유저의 게임 내 행동 패턴의 핵심 차이점 파악 +- **개선 우선순위 제시**: 게임 디자인 개선을 위한 데이터 기반의 구체적이고 실행 가능한 인사이트 제공 +- **예측 모델 구축**: 머신러닝을 활용한 이탈 위험 예측 및 조기 경보 시스템 + +## 설치 및 환경 설정 + +### 필수 패키지 설치 + +```bash +# 기본 데이터 분석 패키지 +pip install pandas numpy scipy matplotlib seaborn + +# 머신러닝 패키지 +pip install scikit-learn + +# 진행률 표시 및 유틸리티 +pip install tqdm + +# 선택적: 고급 분석을 위한 추가 패키지 +pip install plotly jupyter ipywidgets # 인터랙티브 시각화 +pip install statsmodels # 고급 통계 분석 +``` + +### 프로젝트 구조 + +``` +ds_new_user_analy/ +├── retention_analysis.py # 메인 분석 스크립트 (562 lines) +├── retention_analysis_config.json # 설정 파일 (분석 파라미터 관리) +├── retention_analysis.md # 이 문서 (사용법 및 이론) +└── analysis_results/ # 분석 결과 저장 디렉토리 + ├── correlation_analysis_YYYYMMDD_HHMMSS.csv # 상관관계 분석 결과 + ├── d0_churn_analysis_YYYYMMDD_HHMMSS.csv # D0 이탈 분석 결과 + ├── feature_importance_YYYYMMDD_HHMMSS.csv # 특성 중요도 분석 결과 + ├── insights_report_YYYYMMDD_HHMMSS.html # HTML 인사이트 리포트 + └── retention_analysis_YYYYMMDD_HHMMSS.log # 상세 실행 로그 +``` + +## 사용법 + +### 기본 실행 + +```bash +# 명령행 인자로 CSV 파일 지정 +python retention_analysis.py path/to/your/analysis_data.csv + +# 대화형 모드 (파일 경로 입력 프롬프트) +python retention_analysis.py +``` + +### 실행 예시 + +```bash +# 절대 경로 사용 (권장) +python retention_analysis.py "E:\DS_Git\ds_new_user_analy\analysis_results\ds-new_user_analy-20250830_174158.csv" + +# 상대 경로 사용 +python retention_analysis.py analysis_results/ds-new_user_analy-20250830_174158.csv +``` + +### 입력 데이터 요구사항 + +스크립트가 정상 작동하기 위해 CSV 파일에 다음 컬럼들이 필수로 포함되어야 합니다: + +**필수 컬럼:** +- `uid`: 사용자 고유 식별자 +- `retention_status`: 리텐션 상태 (Retained_d0, Retained_d1, ..., Retained_d7+) +- `create_time`: 사용자 생성 시간 (ISO 8601 형식: `2025-08-13T20:14:43+09:00`) + +**권장 컬럼 (분석 품질 향상):** +- `tutorial_complete`: 튜토리얼 완료 여부 +- `level_up_count`: 레벨업 횟수 +- `play_time_total`: 총 플레이 시간 +- `COOP_entry_count`, `Solo_entry_count`: 게임 모드별 진입 횟수 +- `death_Monster`, `death_Trap`, `death_PK`: 사망 원인별 통계 +- `item_gain_Equipment`, `gold_gain_total`: 아이템/골드 획득량 + +**데이터 형식 표준:** +- `create_time`은 ISO 8601 표준 형식을 사용하며 빈 값이 없음을 보장 +- 한국 시간대(+09:00) 정보를 포함하여 정확한 시간대 분석 지원 +- 모든 날짜/시간 계산은 한국 표준시(KST) 기준으로 처리 + +## 스크립트 작동 원리 및 아키텍처 + +### 전체 워크플로우 + +```mermaid +flowchart TD + A[CSV 파일 로드] --> B[데이터 유효성 검사] + B --> C[분석 기간 정보 추출 ISO8601] + C --> D[기본 전처리 및 인코딩] + D --> E[상관관계 분석 Spearman] + E --> F[D0 이탈자 특성 분석 t-test] + F --> G[Feature Importance Random Forest] + G --> H[HTML 인사이트 리포트 생성] + H --> I[결과 파일 저장 CSV/HTML/LOG] + + C --> J[시간대별 가입 패턴 분석] + C --> K[요일별 가입 패턴 분석] + J --> H + K --> H +``` + +### 핵심 클래스: RetentionAnalyzer + +```python +class RetentionAnalyzer: + """ + 리텐션 분석의 메인 엔진 + - 통계적 방법론 적용 + - 결과 저장 및 시각화 + - 확장 가능한 분석 프레임워크 + " + + # 핵심 메서드들 + def __init__(csv_path, config_path='retention_analysis_config.json') # 초기화 + def validate_csv() # 입력 데이터 검증 + def load_data() # 데이터 로드 및 기본 통계 + def extract_analysis_period() # 분석 기간 및 가입 패턴 추출 (ISO 8601) + def analyze_correlations() # Spearman 상관관계 분석 + def analyze_d0_churners() # D0 이탈자 vs 잔존자 비교 (t-test) + def feature_importance_analysis() # Random Forest 중요도 분석 + def generate_insights_report() # HTML 리포트 생성 (콘솔로그 제외) + def run_full_analysis() # 전체 분석 파이프라인 실행 +``` + +### 데이터 구조 및 전처리 + +**1. Retention Status 순서형 인코딩:** +```python +# 문자열 리텐션 상태를 순서형 수치로 변환 +retention_groups = ['Retained_d0', 'Retained_d1', ..., 'Retained_d7+'] +df['retention_encoded'] = df['retention_status'].map( + {status: i for i, status in enumerate(retention_groups)} +) +# 결과: Retained_d0=0, Retained_d1=1, ..., Retained_d7+=7 +``` + +이 인코딩을 통해 리텐션 단계 간의 순서 관계를 보존하면서 수치 분석이 가능해집니다. + +**2. 분석 기간 정보 추출 (create_time 기반):** +```python +# ISO 8601 형식 시간 데이터 처리 +create_times = pd.to_datetime(df['create_time'], format='ISO8601') + +# 한국 시간대 기준 분석 +korea_dates = create_times.dt.tz_convert('Asia/Seoul').dt.date +korea_hours = create_times.dt.tz_convert('Asia/Seoul').dt.hour +korea_weekdays = create_times.dt.tz_convert('Asia/Seoul').dt.dayofweek + +# 가입 패턴 분석 +daily_signups = korea_dates.value_counts().sort_index() # 일별 가입자 +hourly_signups = korea_hours.value_counts().sort_index() # 시간대별 가입자 +weekday_signups = korea_weekdays.value_counts().sort_index() # 요일별 가입자 +``` + +**3. 결측값 처리 전략:** +- **상관관계 분석**: 각 지표별로 pairwise deletion (해당 지표만 결측값 제거) +- **Feature Importance**: Zero imputation (게임 내 활동 없음으로 해석) +- **그룹 비교**: Complete case analysis (양쪽 그룹 모두 유효한 값만 사용) +- **시간 데이터**: ISO 8601 표준으로 완전한 데이터 보장 + +## 통계적 분석 방법론 + +### 1. 상관관계 분석 (Spearman Rank Correlation) + +**목적**: 각 게임 내 지표와 리텐션 단계 간의 단조 관계를 정량화 + +**왜 Spearman을 선택했는가?** +- `retention_status`가 순서형 변수 (d0 < d1 < d2 < ... < d7+) +- 비모수적 방법으로 분포 가정이 불필요 +- 이상치에 덜 민감 +- 비선형 단조 관계도 감지 가능 + +**수학적 배경:** +Spearman 상관계수 ρ는 다음과 같이 계산됩니다: + +``` +ρ = 1 - (6 × Σd²) / (n × (n² - 1)) +``` + +여기서: +- d: 각 관측값의 두 변수 순위 차이 +- n: 샘플 크기 + +**코드 구현:** +```python +from scipy.stats import spearmanr + +for col in numeric_columns: + # 결측값 제거 + valid_data = df[[col, 'retention_encoded']].dropna() + + # 최소 샘플 크기 확인 (기본값: 10) + if len(valid_data) >= min_sample_size: + corr, p_value = spearmanr( + valid_data[col], + valid_data['retention_encoded'] + ) + + # 통계적 유의성 검정 (α = 0.05) + is_significant = p_value < 0.05 +``` + +**결과 해석:** +- ρ > 0: 해당 지표 값이 높을수록 더 오래 게임 플레이 (긍정적) +- ρ < 0: 해당 지표 값이 높을수록 빨리 이탈 (부정적) +- |ρ| > 0.3: 중간 정도의 관계 +- |ρ| > 0.5: 강한 관계 + +### 2. 그룹 비교 분석 (Welch's t-test) + +**목적**: D0 이탈 그룹과 잔존 그룹 간의 평균 차이를 통계적으로 검정 + +**Welch's t-test를 선택한 이유:** +- 등분산성 가정이 불필요 (두 그룹의 분산이 달라도 됨) +- 샘플 크기가 달라도 안정적 +- 게임 데이터에서 흔히 발생하는 이분산성 문제 해결 + +**통계적 가설:** +- H₀(귀무가설): μ₁ = μ₂ (두 그룹의 평균이 같다) +- H₁(대립가설): μ₁ ≠ μ₂ (두 그룹의 평균이 다르다) + +**코드 구현:** +```python +from scipy import stats + +# D0 이탈자와 잔존자 데이터 분리 +d0_users = df[df['retention_status'] == 'Retained_d0'] +retained_users = df[df['retention_status'] != 'Retained_d0'] + +for metric in key_metrics: + d0_data = d0_users[metric].dropna() + retained_data = retained_users[metric].dropna() + + # Welch's t-test 수행 + t_stat, p_value = stats.ttest_ind( + d0_data, + retained_data, + equal_var=False # 등분산 가정 안 함 + ) + + # 실용적 차이 계산 (효과 크기) + d0_mean = d0_data.mean() + retained_mean = retained_data.mean() + diff_percentage = ((retained_mean - d0_mean) / (d0_mean + 0.0001)) * 100 +``` + +**결과 해석:** +- p-value < 0.05: 통계적으로 유의한 차이 존재 +- 차이율 100% 이상: 잔존자가 이탈자보다 2배 이상 높은 지표 +- Cohen's d 효과 크기: |d| > 0.8 (큰 효과), 0.5-0.8 (중간), 0.2-0.5 (작은 효과) + +### 3. 특성 중요도 분석 (Random Forest) + +**목적**: 다변량 환경에서 리텐션 예측에 가장 중요한 변수들을 식별 + +**Random Forest의 장점:** +- **비선형 관계 포착**: 복잡한 게임 내 상호작용 모델링 +- **변수 간 상호작용**: A와 B의 조합 효과 자동 탐지 +- **과적합 방지**: 배깅과 랜덤 서브스페이스 기법 사용 +- **안정성**: 아웃라이어에 견고함 +- **해석 가능성**: 각 변수의 기여도 정량화 + +**중요도 계산 원리:** +각 변수의 중요도는 해당 변수가 모든 트리에서 불순도 감소에 기여한 평균값입니다: + +``` +Importance(v) = (1/T) × Σ[t=1 to T] Σ[s∈Splits(v,t)] p(s) × ΔI(s) +``` + +여기서: +- T: 트리 개수 +- p(s): 분할 s에 도달하는 샘플 비율 +- ΔI(s): 분할 s에서의 불순도 감소량 + +**코드 구현:** +```python +from sklearn.ensemble import RandomForestClassifier + +# 데이터 준비 +X = df[feature_columns].fillna(0) # Zero imputation +y = df['retention_encoded'] # 타겟 변수 + +# Random Forest 모델 설정 +rf = RandomForestClassifier( + n_estimators=100, # 트리 개수 (안정성과 성능 균형) + random_state=42, # 재현 가능성 + n_jobs=-1, # 병렬 처리 + max_depth=None, # 트리 깊이 제한 없음 (자연스러운 중단) + min_samples_split=2, # 최소 분할 샘플 수 + min_samples_leaf=1 # 최소 리프 샘플 수 +) + +# 모델 학습 +rf.fit(X, y) + +# 중요도 추출 +importance_scores = rf.feature_importances_ +``` + +**결과 해석:** +- 0.1 이상: 매우 중요한 변수 (핵심 개선 타겟) +- 0.05-0.1: 중요한 변수 (보조 개선 요소) +- 0.01-0.05: 보통 중요 (모니터링 지표) +- 0.01 미만: 낮은 중요도 (우선순위 후순위) + +## 고급 분석 기법 및 확장 + +### 1. 통계적 검증 강화 + +**다중 비교 문제 해결:** +```python +from statsmodels.stats.multitest import multipletests + +# Bonferroni 또는 FDR 보정 적용 +p_values = [result['p_value'] for result in analysis_results] +rejected, p_corrected, _, _ = multipletests(p_values, method='fdr_bh') +``` + +**효과 크기 계산:** +```python +# Cohen's d 계산 +def cohens_d(group1, group2): + pooled_std = np.sqrt(((len(group1) - 1) * group1.var() + + (len(group2) - 1) * group2.var()) / + (len(group1) + len(group2) - 2)) + return (group1.mean() - group2.mean()) / pooled_std +``` + +### 2. 심층 세그멘테이션 분석 + +**클러스터링 기반 유저 타입 분류:** +```python +from sklearn.cluster import KMeans +from sklearn.preprocessing import StandardScaler + +# 특성 정규화 +scaler = StandardScaler() +scaled_features = scaler.fit_transform(df[numeric_columns]) + +# K-means 클러스터링 +kmeans = KMeans(n_clusters=5, random_state=42) +df['user_segment'] = kmeans.fit_predict(scaled_features) + +# 세그먼트별 리텐션 패턴 분석 +segment_retention = df.groupby(['user_segment', 'retention_status']).size() +``` + +### 3. 시계열 분석 및 트렌드 + +**일별/주별 리텐션 트렌드:** +```python +# 코호트 분석 +df['signup_date'] = pd.to_datetime(df['signup_date']) +df['cohort'] = df['signup_date'].dt.to_period('W') # 주간 코호트 + +cohort_retention = df.groupby('cohort')['retention_encoded'].describe() +``` + +## 설정 파일 상세 설명 + +### retention_analysis_config.json + +```json +{ + "analysis_settings": { + "correlation_threshold": 0.05, // 통계적 유의수준 (Type I 오류율) + "top_n_features": 20, // 결과 표시할 상위 중요 변수 개수 + "min_sample_size": 10, // 분석 최소 샘플 크기 (신뢰성 확보) + "effect_size_threshold": 0.1 // 실용적 유의성 최소 효과 크기 + }, + + "retention_groups": [ // 리텐션 단계 정의 및 순서 + "Retained_d0", "Retained_d1", "Retained_d2", "Retained_d3", + "Retained_d4", "Retained_d5", "Retained_d6", "Retained_d7+" + ], + + "exclude_columns": [ // 분석에서 제외할 메타데이터 컬럼 + "uid", "retention_status", "retention_encoded", + "country", "nickname", "auth_id", "create_time" + ], + + "key_metrics_for_d0_analysis": [ // D0 분석 핵심 지표 (도메인 지식 기반) + "tutorial_complete", // 온보딩 완료도 + "level_up_count", // 성장 경험 + "play_time_total", // 참여도 + "COOP_entry_count", "Solo_entry_count", // 게임 모드 선호 + "death_Monster", "death_Trap", "death_PK", // 실패 경험 + "item_gain_Equipment", "gold_gain_total" // 보상 경험 + ], + + "output_settings": { + "save_csv": true, // 원시 데이터 CSV 저장 + "save_html": true, // HTML 리포트 저장 (기본 출력) + "save_console_log": true, // 디버깅용 로그 저장 + "timestamp_format": "%Y%m%d_%H%M%S" // 파일명 타임스탬프 형식 + } +} +``` + +## 출력 결과 해석 가이드 + +### 상관관계 분석 결과 + +**긍정적 상관관계 (양의 값) 해석:** +``` +shop_sell_count: 0.428 (p < 0.001) +``` +→ 아이템 판매 활동이 활발한 유저일수록 더 오래 게임을 플레이하는 경향 +→ **액션 아이템**: 거래 시스템 개선, 마켓플레이스 UX 향상 + +**부정적 상관관계 (음의 값) 해석:** +``` +dungeon_first_result: -0.173 (p < 0.01) +``` +→ 첫 던전에서 실패한 유저일수록 빨리 이탈하는 경향 +→ **액션 아이템**: 초심자 던전 난이도 조정, 실패 시 보상 시스템 + +### Feature Importance 결과 + +**중요도 점수별 액션:** +- **0.1 이상** (매우 중요): 즉시 개선 프로젝트 착수 +- **0.05-0.1** (중요): 다음 분기 개선 계획에 포함 +- **0.01-0.05** (보통): 정기 모니터링 대상 +- **0.01 미만** (낮음): 우선순위 후순위 + +### D0 분석 결과 + +**차이율별 해석 및 액션:** +``` +tutorial_complete: + - D0 평균: 0.23, 잔존 평균: 0.89, 차이: 287% +``` +→ 튜토리얼 완료가 리텐션에 결정적 영향 +→ **즉시 액션**: 튜토리얼 완료율 개선 프로젝트 + +### 가입 패턴 분석 결과 + +**시간대별 분석:** +``` +가입 집중 시간: 20시 (1,250명) +``` +→ 저녁 시간대에 가입이 집중됨을 의미 +→ **마케팅 액션**: 해당 시간대 타겟팅 광고 집중 + +**요일별 분석:** +``` +가입 집중 요일: 토요일 (3,200명) +``` +→ 주말에 신규 가입이 활발함 +→ **운영 액션**: 주말 이벤트 및 서버 안정성 강화 + +## 문제 해결 및 최적화 + +### 일반적인 오류와 해결책 + +**1. 메모리 부족 오류** +```python +# 청크 단위 처리 +chunk_size = 50000 +for chunk in pd.read_csv(filepath, chunksize=chunk_size): + process_chunk(chunk) +``` + +**2. 인코딩 오류** +```python +# UTF-8 인코딩 명시적 지정 +df = pd.read_csv(filepath, encoding='utf-8-sig') +``` + +**3. 성능 최적화** +```python +# 데이터 타입 최적화 +df['user_level'] = df['user_level'].astype('int16') # 메모리 절약 +df['is_premium'] = df['is_premium'].astype('bool') # 불린 최적화 +``` + +### 대용량 데이터 처리 + +**샘플링 전략:** +```python +# 층화 샘플링 (각 리텐션 그룹에서 동일 비율 추출) +from sklearn.model_selection import train_test_split + +sampled_data, _ = train_test_split( + df, + test_size=0.7, # 30%만 사용 + stratify=df['retention_status'], # 층화 + random_state=42 +) +``` + +## 비즈니스 임팩트 및 ROI + +### 개선 우선순위 매트릭스 + +``` + 높은 임팩트 낮은 임팩트 +높은 구현비용 [계획 검토] [보류] +낮은 구현비용 [즉시 실행] [빠른 승리] +``` + +### A/B 테스트 설계 + +분석 결과를 바탕으로 한 개선안은 반드시 A/B 테스트를 통해 검증: + +```python +# 실험 설계 예시 +control_group = new_users.sample(frac=0.5, random_state=42) +treatment_group = new_users.drop(control_group.index) + +# 개선 효과 측정 +improvement = (treatment_retention - control_retention) / control_retention +statistical_power = calculate_power(effect_size, alpha=0.05, sample_size=len(control_group)) +``` + +## 결론 및 향후 개선 + +이 `retention_analysis.py` 스크립트는 단순한 데이터 분석을 넘어서, 게임 서비스 개선을 위한 **의사결정 지원 시스템**의 역할을 합니다. + +**핵심 가치:** +1. **과학적 접근**: 통계적으로 검증된 방법론으로 신뢰할 수 있는 인사이트 제공 +2. **실행 가능성**: 분석 결과가 구체적인 개선 액션으로 연결 +3. **확장성**: 새로운 분석 기법과 지표를 쉽게 추가 가능 +4. **자동화**: 정기적인 분석과 모니터링을 통한 지속적 개선 +5. **시간대 인사이트**: 가입 패턴 분석으로 마케팅/운영 최적화 지원 + +**2024년 8월 업데이트 내용:** +- **HTML 리포트**: 마크다운에서 전문적인 HTML 형식으로 변경 +- **분석 기간 정보**: ISO 8601 형식 `create_time` 기반 자동 추출 +- **시간대 분석**: 한국 시간 기준 시간대별/요일별 가입 패턴 분석 +- **콘솔로그 분리**: HTML 리포트에서 콘솔로그 제거, 상세 분석 데이터 중심으로 개편 +- **데이터 품질 강화**: 표준 시간 형식 지원으로 분석 정확도 향상 + +**향후 발전 방향:** +- 실시간 대시보드 연동 (가입 패턴 실시간 모니터링) +- 예측 모델 고도화 (딥러닝 활용) +- 개인화된 리텐션 전략 추천 (시간대별 타겟팅) +- 자동화된 A/B 테스트 파이프라인 +- 가입 패턴 기반 마케팅 최적화 시스템 + +정기적인 분석을 통해 게임 개선 효과를 측정하고, 새로운 문제점을 조기에 발견하여 서비스의 지속 가능한 성장에 기여할 수 있습니다. \ No newline at end of file diff --git a/retention_analysis.py b/retention_analysis.py new file mode 100644 index 0000000..73f1a6e --- /dev/null +++ b/retention_analysis.py @@ -0,0 +1,1212 @@ +#!/usr/bin/env python3 +""" +Retention Analysis Script v2.0 +던전 스토커즈 신규 유저 리텐션 분석 도구 + +사용법: python retention_analysis.py [CSV_파일_경로] +설정: retention_analysis_config.json +""" + +import pandas as pd +import numpy as np +from scipy import stats +from scipy.stats import spearmanr +import sys +import os +import json +import logging +from datetime import datetime +from sklearn.ensemble import RandomForestClassifier +from tqdm import tqdm +import warnings + +warnings.filterwarnings('ignore') + +class RetentionAnalyzer: + def __init__(self, csv_path, config_path='retention_analysis_config.json'): + """ + 초기화 + + Parameters: + ----------- + csv_path : str + 분석할 CSV 파일 경로 + config_path : str + 설정 파일 경로 + """ + self.csv_path = csv_path + self.config = self.load_config(config_path) + self.df = None + self.correlation_results = {} + self.feature_importance = {} + self.console_log = [] + + # 출력 디렉토리 설정 + self.output_dir = 'analysis_results' + os.makedirs(self.output_dir, exist_ok=True) + + # 타임스탬프 + self.timestamp = datetime.now().strftime(self.config['output_settings']['timestamp_format']) + + # 로깅 설정 + self.setup_logging() + + def setup_logging(self): + """로깅 시스템 초기화""" + log_filename = f"{self.output_dir}/retention_analysis_{self.timestamp}.log" + + # 콘솔과 파일에 동시 로깅 + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(message)s', + handlers=[ + logging.FileHandler(log_filename, encoding='utf-8'), + logging.StreamHandler(sys.stdout) + ] + ) + self.logger = logging.getLogger(__name__) + + def load_config(self, config_path): + """설정 파일 로드""" + try: + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + return config + except FileNotFoundError: + print(f"Warning: {config_path} 파일을 찾을 수 없습니다. 기본 설정을 사용합니다.") + return self.get_default_config() + except json.JSONDecodeError as e: + print(f"Error: {config_path} 파일 형식이 올바르지 않습니다: {e}") + return self.get_default_config() + + def get_default_config(self): + """기본 설정 반환""" + return { + "analysis_settings": { + "correlation_threshold": 0.05, + "top_n_features": 20, + "min_sample_size": 10 + }, + "retention_groups": [ + "Retained_d0", "Retained_d1", "Retained_d2", "Retained_d3", + "Retained_d4", "Retained_d5", "Retained_d6", "Retained_d7+" + ], + "exclude_columns": [ + "uid", "retention_status", "retention_encoded", + "country", "nickname", "auth_id" + ], + "key_metrics_for_d0_analysis": [ + "tutorial_complete", "level_up_count", "play_time_total", + "COOP_entry_count", "Solo_entry_count", + "death_Monster", "death_Trap", "death_PK", + "item_gain_Equipment", "gold_gain_total" + ], + "output_settings": { + "save_csv": True, + "save_markdown": True, + "save_console_log": True, + "timestamp_format": "%Y%m%d_%H%M%S" + } + } + + def log_message(self, stage, message, level="INFO"): + """구조화된 로그 메시지""" + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + formatted_msg = f"[{timestamp}] [{stage}] {level}: {message}" + + if level == "INFO": + self.logger.info(f"[{stage}] {message}") + elif level == "WARNING": + self.logger.warning(f"[{stage}] {message}") + elif level == "ERROR": + self.logger.error(f"[{stage}] {message}") + + self.console_log.append(formatted_msg) + + def validate_csv(self): + """CSV 파일 유효성 검사""" + self.log_message("VALIDATION", "CSV 파일 유효성 검사 시작") + + if not os.path.exists(self.csv_path): + raise FileNotFoundError(f"파일을 찾을 수 없습니다: {self.csv_path}") + + # 필수 컬럼 검사 + try: + sample_df = pd.read_csv(self.csv_path, nrows=5) + required_cols = ['uid', 'retention_status'] + + missing_cols = [col for col in required_cols if col not in sample_df.columns] + if missing_cols: + raise ValueError(f"필수 컬럼이 없습니다: {missing_cols}") + + # retention_status 값 검사 + retention_groups = set(self.config['retention_groups']) + sample_retention = set(sample_df['retention_status'].dropna().unique()) + + if not sample_retention.issubset(retention_groups): + invalid_values = sample_retention - retention_groups + self.log_message("VALIDATION", + f"예상치 못한 retention_status 값: {invalid_values}", + "WARNING") + + self.log_message("VALIDATION", "CSV 파일 유효성 검사 완료 OK") + + except Exception as e: + raise ValueError(f"CSV 파일 검증 실패: {str(e)}") + + def load_data(self): + """CSV 데이터 로드 및 기본 전처리""" + self.log_message("LOAD", "데이터 로딩 시작") + + # 파일 크기 확인 + file_size = os.path.getsize(self.csv_path) / (1024 * 1024) # MB + self.log_message("LOAD", f"파일 크기: {file_size:.1f} MB") + + # 진행률 바와 함께 데이터 로드 + with tqdm(desc="데이터 로딩 중", unit="MB") as pbar: + self.df = pd.read_csv(self.csv_path) + pbar.update(file_size) + + self.log_message("LOAD", f"데이터 로드 완료: {len(self.df):,} rows, {len(self.df.columns)} columns") + + # 분석 기간 정보 추출 (create_time 기반) + self.analysis_period_info = self.extract_analysis_period() + + # retention_status를 순서형 변수로 인코딩 + retention_groups = self.config['retention_groups'] + self.df['retention_encoded'] = self.df['retention_status'].map( + {status: i for i, status in enumerate(retention_groups)} + ) + + # 기본 정보 출력 + self.log_message("STATS", "=== 데이터 기본 정보 ===") + self.log_message("STATS", f"총 유저 수: {len(self.df):,}") + + # 분석 기간 정보 출력 + if self.analysis_period_info['valid']: + self.log_message("STATS", f"분석 기간: {self.analysis_period_info['start_date']} ~ {self.analysis_period_info['end_date']}") + self.log_message("STATS", f"분석 대상 기간: {self.analysis_period_info['total_days']}일") + self.log_message("STATS", f"일평균 신규 가입: {self.analysis_period_info['avg_daily_signups']:.1f}명") + + self.log_message("STATS", "") + self.log_message("STATS", "Retention 분포:") + + retention_dist = self.df['retention_status'].value_counts() + for status in retention_groups: + if status in retention_dist.index: + count = retention_dist[status] + pct = count / len(self.df) * 100 + self.log_message("STATS", f" {status}: {count:,} ({pct:.1f}%)") + + # D0 이탈률 강조 + d0_rate = retention_dist.get('Retained_d0', 0) / len(self.df) * 100 + self.log_message("STATS", "") + self.log_message("STATS", f"CRITICAL: D0 이탈률: {d0_rate:.1f}%", "WARNING") + + return self.df + + def extract_analysis_period(self): + """분석 기간 정보 추출 (ISO 8601 형식 create_time 처리)""" + try: + # create_time 컬럼이 있는지 확인 + if 'create_time' not in self.df.columns: + self.log_message("PERIOD", "create_time 컬럼이 없습니다. 기간 분석을 생략합니다.", "WARNING") + return {'valid': False} + + # ISO 8601 형식 (2025-08-13T20:14:43+09:00)을 datetime으로 변환 + # 빈 값이 없다고 했으므로 errors='raise'로 설정하여 문제 발견 시 즉시 알림 + create_times = pd.to_datetime(self.df['create_time'], format='ISO8601') + + # 기간 정보 계산 + start_date = create_times.min() + end_date = create_times.max() + total_days = (end_date - start_date).days + 1 + + # 일별 가입자 분포 (한국 시간 기준으로 날짜 추출) + korea_dates = create_times.dt.tz_convert('Asia/Seoul').dt.date + daily_signups = korea_dates.value_counts().sort_index() + avg_daily_signups = len(create_times) / total_days if total_days > 0 else 0 + + # 시간대별 가입 패턴 분석 (추가 인사이트) + korea_hours = create_times.dt.tz_convert('Asia/Seoul').dt.hour + hourly_signups = korea_hours.value_counts().sort_index() + peak_hour = hourly_signups.idxmax() + + # 요일별 가입 패턴 (0=월요일, 6=일요일) + korea_weekdays = create_times.dt.tz_convert('Asia/Seoul').dt.dayofweek + weekday_signups = korea_weekdays.value_counts().sort_index() + weekday_names = ['월', '화', '수', '목', '금', '토', '일'] + + self.log_message("PERIOD", f"가입 집중 시간: {peak_hour}시 ({hourly_signups[peak_hour]}명)") + + return { + 'valid': True, + 'start_date': start_date.strftime('%Y-%m-%d'), + 'end_date': end_date.strftime('%Y-%m-%d'), + 'start_datetime': start_date, + 'end_datetime': end_date, + 'total_days': total_days, + 'total_users': len(create_times), + 'avg_daily_signups': avg_daily_signups, + 'valid_ratio': 100.0, # 빈 값이 없다고 했으므로 항상 100% + 'daily_signups': daily_signups, + 'hourly_signups': hourly_signups, + 'weekday_signups': weekday_signups, + 'peak_hour': peak_hour, + 'peak_hour_count': hourly_signups[peak_hour], + 'weekday_names': weekday_names + } + + except Exception as e: + self.log_message("PERIOD", f"분석 기간 추출 중 오류: {str(e)}", "ERROR") + # ISO 형식 파싱 실패 시 일반적인 방법으로 재시도 + try: + create_times = pd.to_datetime(self.df['create_time'], errors='coerce') + valid_times = create_times.dropna() + + if len(valid_times) == 0: + return {'valid': False} + + start_date = valid_times.min() + end_date = valid_times.max() + total_days = (end_date - start_date).days + 1 + daily_signups = valid_times.dt.date.value_counts().sort_index() + avg_daily_signups = len(valid_times) / total_days if total_days > 0 else 0 + + return { + 'valid': True, + 'start_date': start_date.strftime('%Y-%m-%d'), + 'end_date': end_date.strftime('%Y-%m-%d'), + 'start_datetime': start_date, + 'end_datetime': end_date, + 'total_days': total_days, + 'total_users': len(valid_times), + 'avg_daily_signups': avg_daily_signups, + 'valid_ratio': len(valid_times) / len(self.df) * 100, + 'daily_signups': daily_signups + } + except: + return {'valid': False} + + def analyze_correlations(self): + """모든 지표와 retention_status 간의 상관관계 분석""" + self.log_message("CORRELATION", "상관관계 분석 시작") + + # 분석할 컬럼 선택 + exclude_cols = self.config['exclude_columns'] + all_numeric_cols = self.df.select_dtypes(include=[np.number]).columns.tolist() + numeric_cols = [col for col in all_numeric_cols if col not in exclude_cols] + + self.log_message("CORRELATION", f"분석 대상 지표: {len(numeric_cols)}개") + + correlations = [] + + # 진행률 바와 함께 상관관계 분석 + for col in tqdm(numeric_cols, desc="상관관계 분석 중"): + try: + # NULL 값 제거 + valid_data = self.df[[col, 'retention_encoded']].dropna() + + if len(valid_data) < self.config['analysis_settings']['min_sample_size']: + continue + + # Spearman 상관계수 계산 + corr, p_value = spearmanr(valid_data[col], valid_data['retention_encoded']) + + correlations.append({ + 'metric': col, + 'correlation': corr, + 'p_value': p_value, + 'significant': p_value < self.config['analysis_settings']['correlation_threshold'], + 'abs_correlation': abs(corr) + }) + + except Exception as e: + self.log_message("CORRELATION", f"Warning: {col} 분석 실패 - {str(e)}", "WARNING") + continue + + # 결과 정리 + self.correlation_results = pd.DataFrame(correlations) + self.correlation_results = self.correlation_results.sort_values('abs_correlation', ascending=False) + + # 상위 결과 출력 + self.log_message("CORRELATION", "") + self.log_message("CORRELATION", "[+] Retention에 긍정적 영향 (Top 15):") + + positive = self.correlation_results[ + (self.correlation_results['correlation'] > 0) & + (self.correlation_results['significant']) + ].head(15) + + for _, row in positive.iterrows(): + self.log_message("CORRELATION", f" {row['metric']}: {row['correlation']:.3f}") + + self.log_message("CORRELATION", "") + self.log_message("CORRELATION", "[-] Retention에 부정적 영향 (Top 15):") + + negative = self.correlation_results[ + (self.correlation_results['correlation'] < 0) & + (self.correlation_results['significant']) + ].head(15) + + for _, row in negative.iterrows(): + self.log_message("CORRELATION", f" {row['metric']}: {row['correlation']:.3f}") + + # 결과 저장 + if self.config['output_settings']['save_csv']: + output_file = f"{self.output_dir}/correlation_analysis_{self.timestamp}.csv" + self.correlation_results.to_csv(output_file, index=False) + self.log_message("CORRELATION", f"결과 저장: {output_file}") + + return self.correlation_results + + def analyze_d0_churners(self): + """D0 이탈 유저 특성 분석""" + self.log_message("D0_ANALYSIS", "D0 이탈 유저 특성 분석 시작") + + d0_users = self.df[self.df['retention_status'] == 'Retained_d0'] + retained_users = self.df[self.df['retention_status'] != 'Retained_d0'] + + self.log_message("D0_ANALYSIS", f"D0 이탈 유저: {len(d0_users):,}명") + self.log_message("D0_ANALYSIS", f"잔존 유저: {len(retained_users):,}명") + + # 주요 지표 비교 + key_metrics = self.config['key_metrics_for_d0_analysis'] + comparison = [] + + self.log_message("D0_ANALYSIS", "") + self.log_message("D0_ANALYSIS", "=== D0 이탈 vs 잔존 유저 주요 차이 ===") + + for metric in tqdm(key_metrics, desc="D0 분석 중"): + if metric in self.df.columns: + try: + d0_data = d0_users[metric].dropna() + retained_data = retained_users[metric].dropna() + + if len(d0_data) < 10 or len(retained_data) < 10: + continue + + d0_mean = d0_data.mean() + retained_mean = retained_data.mean() + + # t-test + t_stat, p_value = stats.ttest_ind(d0_data, retained_data, equal_var=False) + + diff_pct = ((retained_mean - d0_mean) / (d0_mean + 0.0001)) * 100 + + comparison.append({ + 'metric': metric, + 'd0_mean': d0_mean, + 'retained_mean': retained_mean, + 'difference_%': diff_pct, + 'p_value': p_value, + 'significant': p_value < 0.05 + }) + + except Exception as e: + self.log_message("D0_ANALYSIS", f"Warning: {metric} 분석 실패 - {str(e)}", "WARNING") + continue + + comparison_df = pd.DataFrame(comparison) + comparison_df = comparison_df.sort_values('difference_%', ascending=False) + + # 클래스 변수로 저장 (HTML 리포트에서 사용) + self.d0_comparison = comparison_df + + # 결과 출력 + for _, row in comparison_df.head(10).iterrows(): + if row['significant']: + self.log_message("D0_ANALYSIS", f" {row['metric']}:") + self.log_message("D0_ANALYSIS", f" D0 평균: {row['d0_mean']:.2f}") + self.log_message("D0_ANALYSIS", f" 잔존 평균: {row['retained_mean']:.2f}") + self.log_message("D0_ANALYSIS", f" 차이: {row['difference_%']:.1f}%") + + # 결과 저장 + if self.config['output_settings']['save_csv']: + output_file = f"{self.output_dir}/d0_churn_analysis_{self.timestamp}.csv" + comparison_df.to_csv(output_file, index=False) + self.log_message("D0_ANALYSIS", f"결과 저장: {output_file}") + + return comparison_df + + def feature_importance_analysis(self): + """Random Forest를 이용한 Feature Importance 분석""" + self.log_message("FEATURE_IMPORTANCE", "Feature Importance 분석 시작") + + # 데이터 준비 + exclude_cols = self.config['exclude_columns'] + numeric_cols = self.df.select_dtypes(include=[np.number]).columns.tolist() + feature_cols = [col for col in numeric_cols if col not in exclude_cols] + + X = self.df[feature_cols].fillna(0) + y = self.df['retention_encoded'] + + self.log_message("FEATURE_IMPORTANCE", f"Feature 수: {len(feature_cols)}") + self.log_message("FEATURE_IMPORTANCE", f"샘플 수: {len(X)}") + + # Random Forest 모델 학습 (진행률 표시) + with tqdm(desc="Random Forest 학습 중") as pbar: + rf = RandomForestClassifier(n_estimators=100, random_state=42, n_jobs=-1) + rf.fit(X, y) + pbar.update(1) + + # Feature Importance 추출 + importance_df = pd.DataFrame({ + 'feature': feature_cols, + 'importance': rf.feature_importances_ + }).sort_values('importance', ascending=False) + + self.log_message("FEATURE_IMPORTANCE", "") + self.log_message("FEATURE_IMPORTANCE", f"=== Top {self.config['analysis_settings']['top_n_features']} 중요 지표 ===") + + top_n = self.config['analysis_settings']['top_n_features'] + for idx, row in importance_df.head(top_n).iterrows(): + self.log_message("FEATURE_IMPORTANCE", f" {row['feature']}: {row['importance']:.4f}") + + # 결과 저장 + if self.config['output_settings']['save_csv']: + output_file = f"{self.output_dir}/feature_importance_{self.timestamp}.csv" + importance_df.to_csv(output_file, index=False) + self.log_message("FEATURE_IMPORTANCE", f"결과 저장: {output_file}") + + self.feature_importance = importance_df + return importance_df + + def generate_insights_report(self): + """HTML 형식의 종합 인사이트 리포트 생성""" + self.log_message("REPORT", "HTML 인사이트 리포트 생성 시작") + + # 기본 통계 계산 + d0_rate = len(self.df[self.df['retention_status'] == 'Retained_d0']) / len(self.df) * 100 + d1_rate = len(self.df[self.df['retention_status'] == 'Retained_d1']) / len(self.df) * 100 + d2_rate = len(self.df[self.df['retention_status'] == 'Retained_d2']) / len(self.df) * 100 + d3_rate = len(self.df[self.df['retention_status'] == 'Retained_d3']) / len(self.df) * 100 + d3_plus_rate = len(self.df[self.df['retention_encoded'] >= 3]) / len(self.df) * 100 + d7_plus_rate = len(self.df[self.df['retention_status'] == 'Retained_d7+']) / len(self.df) * 100 + + # 리텐션 분포 데이터 + retention_dist = self.df['retention_status'].value_counts().sort_index() + + # 상관관계 분석 결과 준비 (HTML 전체에서 사용하기 위해 미리 준비) + positive = pd.DataFrame() # 기본값 + negative = pd.DataFrame() # 기본값 + + if not self.correlation_results.empty: + positive = self.correlation_results[ + (self.correlation_results['correlation'] > 0) & + (self.correlation_results['significant']) + ].head(15) + + negative = self.correlation_results[ + (self.correlation_results['correlation'] < 0) & + (self.correlation_results['significant']) + ].head(15) + + # HTML 생성 + html_content = f""" + + + + + + 던전 스토커즈 - 신규 유저 리텐션 분석 리포트 + + + +
+
+

던전 스토커즈

+

신규 유저 리텐션 분석 리포트

+
분석 일시: {datetime.now().strftime('%Y년 %m월 %d일 %H:%M:%S')}
+
+ +
+ +
+

📊 분석 개요

+
+ 데이터 파일: {os.path.basename(self.csv_path)}
+ 분석 대상: {len(self.df):,}명의 신규 유저
""" + + # 분석 기간 정보 추가 + if hasattr(self, 'analysis_period_info') and self.analysis_period_info['valid']: + period_info = self.analysis_period_info + html_content += f""" + 분석 기간: {period_info['start_date']} ~ {period_info['end_date']} ({period_info['total_days']}일)
+ 일평균 신규 가입: {period_info['avg_daily_signups']:.1f}명
+ 데이터 품질: {period_info['valid_ratio']:.1f}% (유효한 가입일 데이터)
""" + + html_content += f""" + 분석 범위: D0 ~ D7+ 리텐션 분석
+ 통계 방법: Spearman 상관분석, Welch's t-test, Random Forest 특성 중요도 +
""" + + # 분석 기간 상세 정보 (별도 섹션) + if hasattr(self, 'analysis_period_info') and self.analysis_period_info['valid']: + period_info = self.analysis_period_info + + # 최근 7일, 최근 30일 가입자 수 계산 (가능한 경우) + recent_stats = "" + try: + end_datetime = period_info['end_datetime'] + recent_7days = len(self.df[pd.to_datetime(self.df['create_time'], errors='coerce') >= (end_datetime - pd.Timedelta(days=7))]) + recent_30days = len(self.df[pd.to_datetime(self.df['create_time'], errors='coerce') >= (end_datetime - pd.Timedelta(days=30))]) + + recent_stats = f""" +
+
+
{recent_7days:,}
+
최근 7일 가입
+
+
+
{recent_30days:,}
+
최근 30일 가입
+
+
+
{period_info['total_days']}
+
전체 분석 일수
+
+
+
{period_info['avg_daily_signups']:.1f}
+
일평균 신규가입
+
+
""" + except: + pass + + # 시간대/요일별 패턴 정보 추가 + pattern_info = "" + if 'peak_hour' in period_info and 'weekday_signups' in period_info: + # 가장 활발한 요일 찾기 + max_weekday_idx = period_info['weekday_signups'].idxmax() + max_weekday_name = period_info['weekday_names'][max_weekday_idx] + max_weekday_count = period_info['weekday_signups'][max_weekday_idx] + + pattern_info = f""" +
+

🕒 가입 패턴 분석

+ 가입 집중 시간대: {period_info['peak_hour']}시 ({period_info['peak_hour_count']:,}명)
+ 가입 집중 요일: {max_weekday_name}요일 ({max_weekday_count:,}명)
+ 시간대 분석: 한국 시간(KST) 기준으로 분석됨 +
""" + + html_content += f""" + +

📅 분석 기간 상세

+
+ 데이터 수집 기간: {period_info['start_date']} ~ {period_info['end_date']}
+ 전체 분석 대상: {period_info['total_users']:,}명 (ISO 8601 표준 형식)
+ 일평균 신규 가입자: {period_info['avg_daily_signups']:.1f}명
+ 분석 대상 기간: {period_info['total_days']}일간의 신규 가입자 행동 패턴
+ 데이터 품질: {period_info['valid_ratio']:.1f}% (표준 시간 형식으로 완전한 데이터) +
+ {pattern_info} + {recent_stats}""" + + html_content += f""" +
+ + +
+

🎯 전체 리텐션 현황

+
+
+
{d0_rate:.1f}%
+
D0 이탈률
+
+
+
{d1_rate:.1f}%
+
D1 유지율
+
+
+
{d3_plus_rate:.1f}%
+
D3+ 잔존율
+
+
+
{d7_plus_rate:.1f}%
+
D7+ 잔존율
+
+
+ +
+

⚠️ Critical Alert

+

D0 이탈률 {d0_rate:.1f}%는 게임 업계 평균(25-35%)을 크게 상회하는 매우 심각한 수준입니다.

+

즉시 개선 조치가 필요합니다.

+
+ +

일별 리텐션 분포

+
""" + + # 리텐션 분포 HTML 생성 + for status in self.config['retention_groups']: + if status in retention_dist.index: + count = retention_dist[status] + pct = count / len(self.df) * 100 + html_content += f""" +
+
{status.replace('Retained_', '')}
+
{count:,}
+
{pct:.1f}%
+
""" + + html_content += """ +
+
""" + + # 상관관계 분석 결과 + if not self.correlation_results.empty: + html_content += f""" + +
+

🔍 리텐션 영향 요인 분석

+ +
+ 분석 방법: Spearman 순위 상관계수 (ρ)
+ 유의수준: p < 0.05
+ 분석 지표 수: {len(self.correlation_results)}개
+ 유의한 지표 수: {len(self.correlation_results[self.correlation_results['significant']])}개 +
+ +

📈 리텐션 향상 요인 (긍정적 상관관계)

+ + + + + + + + + + + """ + + for idx, (_, row) in enumerate(positive.iterrows(), 1): + interpretation = "강한 긍정적 관계" if row['correlation'] > 0.3 else "중간 긍정적 관계" if row['correlation'] > 0.1 else "약한 긍정적 관계" + html_content += f""" + + + + + + + """ + + html_content += """ + +
순위지표명상관계수 (ρ)유의확률 (p)해석
{idx}{row['metric']}+{row['correlation']:.3f}{'< 0.001' if row['p_value'] < 0.001 else f'{row["p_value"]:.3f}'}{interpretation}
""" + + if len(negative) > 0: + html_content += """ +

📉 리텐션 저해 요인 (부정적 상관관계)

+ + + + + + + + + + + """ + + for idx, (_, row) in enumerate(negative.iterrows(), 1): + interpretation = "강한 부정적 관계" if abs(row['correlation']) > 0.3 else "중간 부정적 관계" if abs(row['correlation']) > 0.1 else "약한 부정적 관계" + html_content += f""" + + + + + + + """ + + html_content += """ + +
순위지표명상관계수 (ρ)유의확률 (p)해석
{idx}{row['metric']}{row['correlation']:.3f}{'< 0.001' if row['p_value'] < 0.001 else f'{row["p_value"]:.3f}'}{interpretation}
""" + + html_content += """ +
""" + + # D0 분석 결과가 있다면 추가 + if hasattr(self, 'd0_comparison') and not self.d0_comparison.empty: + html_content += f""" + +
+

🎯 D0 이탈 vs 잔존 유저 비교 분석

+ +
+ 분석 대상:
+ D0 이탈 유저: {len(self.df[self.df['retention_status'] == 'Retained_d0']):,}명
+ 잔존 유저: {len(self.df[self.df['retention_status'] != 'Retained_d0']):,}명 +
+ +

핵심 지표별 차이 분석

+ + + + + + + + + + + + """ + + significant_diffs = self.d0_comparison[self.d0_comparison['significant']].head(15) + for _, row in significant_diffs.iterrows(): + if row['difference_%'] > 100: + interpretation = "매우 큰 차이 (즉시 개선 필요)" + elif row['difference_%'] > 50: + interpretation = "큰 차이 (우선 개선)" + elif row['difference_%'] > 20: + interpretation = "중간 차이 (개선 고려)" + else: + interpretation = "작은 차이" + + diff_class = "positive" if row['difference_%'] > 0 else "negative" + + html_content += f""" + + + + + + + + """ + + html_content += """ + +
지표명D0 이탈자 평균잔존자 평균차이율유의확률 (p)해석
{row['metric']}{row['d0_mean']:.2f}{row['retained_mean']:.2f}{row['difference_%']:+.1f}%{'< 0.001' if row['p_value'] < 0.001 else f'{row["p_value"]:.3f}'}{interpretation}
+
""" + + # Feature Importance 결과 + if hasattr(self, 'feature_importance') and not self.feature_importance.empty: + top_features = self.feature_importance.head(20) + html_content += f""" + +
+

🎖️ 리텐션 예측 중요도 분석

+ +
+ 분석 방법: Random Forest Classifier
+ 모델 설정: 100개 트리, 교차 검증
+ 분석 특성 수: {len(self.feature_importance)}개 +
+ +

Top 20 중요 특성

+ + + + + + + + + + + """ + + for idx, (_, row) in enumerate(top_features.iterrows(), 1): + importance = row['importance'] + if importance >= 0.1: + grade = "매우 높음" + priority = "즉시 액션" + grade_class = "critical" + elif importance >= 0.05: + grade = "높음" + priority = "우선 개선" + grade_class = "warning" + elif importance >= 0.01: + grade = "보통" + priority = "모니터링" + grade_class = "good" + else: + grade = "낮음" + priority = "후순위" + grade_class = "" + + html_content += f""" + + + + + + + """ + + html_content += """ + +
순위지표명중요도 점수중요도 등급액션 우선순위
{idx}{row['feature']}{importance:.4f}{grade}{priority}
+
""" + + # 개선 권고사항 + html_content += f""" + +
+

🚀 개선 권고사항

+ +
+

🔥 즉시 실행 (Critical)

+
    +
  • D0 이탈률 {d0_rate:.1f}% - 업계 평균의 2배 수준으로 즉시 개선 필요
  • """ + + if not self.correlation_results.empty and len(positive) > 0: + top_positive = positive.iloc[0] + html_content += f""" +
  • {top_positive['metric']} 지표 강화 - 최고 긍정 요인 (상관계수: {top_positive['correlation']:.3f})
  • """ + + html_content += """ +
  • 신규 유저 온보딩 프로세스 전면 재검토
  • +
  • 첫날 경험 최적화 프로젝트 착수
  • +
+
+ +
+

⚡ 우선 개선 (High Priority)

+
    """ + + if not self.correlation_results.empty: + for _, row in positive.head(3).iterrows(): + html_content += f""" +
  • {row['metric']} 개선 - 리텐션 향상 효과 기대 (ρ = {row['correlation']:.3f})
  • """ + + html_content += """ +
  • D1-D3 전환율 개선 프로그램 도입
  • +
  • 이탈 위험 유저 조기 탐지 시스템 구축
  • +
+
+ +
+

📊 지속 모니터링 (Monitoring)

+
    """ + + if len(negative) > 0: + for _, row in negative.head(3).iterrows(): + html_content += f""" +
  • {row['metric']} 모니터링 - 부정적 영향 최소화 (ρ = {row['correlation']:.3f})
  • """ + + html_content += f""" +
  • 주간 리텐션 대시보드 구축
  • +
  • A/B 테스트를 통한 개선 효과 검증
  • +
  • 세그먼트별 리텐션 패턴 심층 분석
  • +
+
+
+ + +
+

📝 종합 결론

+ +
+

현재 상황 진단

+

던전 스토커즈의 D0 이탈률 {d0_rate:.1f}%는 게임 업계 평균(25-35%)을 크게 상회하는 + Critical 수준입니다. 이는 서비스 지속성에 직접적인 위협이 되는 상황으로, + 즉시 종합적인 개선 조치가 필요합니다.

+
+ +
+

핵심 개선 전략

+
    +
  1. 첫날 경험 최적화: 온보딩, 튜토리얼, 초기 보상 시스템 전면 재설계
  2. +
  3. 데이터 기반 개선: 긍정적 상관관계 지표들의 초기 경험 강화
  4. +
  5. 위험 요소 제거: 부정적 영향 지표들의 영향도 최소화
  6. +
  7. 지속적 모니터링: 실시간 리텐션 추적 및 조기 경보 시스템
  8. +
+
+ +
+

기대 효과

+

본 분석 결과를 바탕으로 한 개선 조치 시행 시, D0 이탈률을 업계 평균 수준인 30% 이하로 + 개선할 수 있을 것으로 예상됩니다. 이는 월간 신규 유저 중 약 35% 추가 확보를 + 의미하며, 장기적으로 서비스 성장에 크게 기여할 것입니다.

+
+
+
+ + +
+ +""" + + # HTML 리포트 저장 + output_file = f"{self.output_dir}/insights_report_{self.timestamp}.html" + with open(output_file, 'w', encoding='utf-8') as f: + f.write(html_content) + self.log_message("REPORT", f"HTML 리포트 저장: {output_file}") + + # 콘솔에 요약만 출력 + self.log_message("REPORT", "") + self.log_message("REPORT", "="*60) + self.log_message("REPORT", "RETENTION 분석 완료 - 주요 결과") + self.log_message("REPORT", "="*60) + self.log_message("REPORT", f"D0 이탈률: {d0_rate:.1f}% (매우 심각)") + self.log_message("REPORT", f"D7+ 잔존률: {d7_plus_rate:.1f}%") + + if not self.correlation_results.empty: + positive = self.correlation_results[ + (self.correlation_results['correlation'] > 0) & + (self.correlation_results['significant']) + ].head(1) + if len(positive) > 0: + top_positive = positive.iloc[0] + self.log_message("REPORT", f"최고 긍정 지표: {top_positive['metric']} ({top_positive['correlation']:.3f})") + + self.log_message("REPORT", "="*60) + + return html_content + + def run_full_analysis(self): + """전체 분석 실행""" + self.log_message("MAIN", "Retention 분석 시작") + + try: + # 1. CSV 유효성 검사 + self.validate_csv() + + # 2. 데이터 로드 + self.load_data() + + # 3. 상관관계 분석 + self.analyze_correlations() + + # 4. D0 이탈 분석 + self.analyze_d0_churners() + + # 5. Feature Importance + self.feature_importance_analysis() + + # 6. 인사이트 리포트 + self.generate_insights_report() + + self.log_message("MAIN", "") + self.log_message("MAIN", "SUCCESS: 모든 분석 완료!") + self.log_message("MAIN", f"결과 파일들이 {self.output_dir}/ 디렉토리에 저장되었습니다.") + + except Exception as e: + self.log_message("MAIN", f"분석 중 오류 발생: {str(e)}", "ERROR") + raise + + +def get_csv_path(): + """사용자로부터 CSV 파일 경로 입력받기""" + if len(sys.argv) > 1: + return sys.argv[1] + + print("="*60) + print("Retention Analysis Tool") + print("="*60) + print() + + while True: + csv_path = input(">> 분석할 CSV 파일 경로를 입력하세요: ").strip() + + if not csv_path: + print("ERROR: 경로를 입력해주세요.") + continue + + # 따옴표 제거 + csv_path = csv_path.strip('"\'') + + if not os.path.exists(csv_path): + print(f"ERROR: 파일을 찾을 수 없습니다: {csv_path}") + continue + + if not csv_path.lower().endswith('.csv'): + print("ERROR: CSV 파일만 지원됩니다.") + continue + + return csv_path + + +def main(): + """메인 실행 함수""" + try: + csv_path = get_csv_path() + + print(f"\n=== 분석 시작: {os.path.basename(csv_path)} ===") + print("="*60) + + analyzer = RetentionAnalyzer(csv_path) + analyzer.run_full_analysis() + + except KeyboardInterrupt: + print("\n\nWARNING: 사용자에 의해 중단되었습니다.") + except Exception as e: + print(f"\n\nERROR: 오류 발생: {str(e)}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/retention_analysis_config.json b/retention_analysis_config.json new file mode 100644 index 0000000..5a31a9c --- /dev/null +++ b/retention_analysis_config.json @@ -0,0 +1,43 @@ +{ + "analysis_settings": { + "correlation_threshold": 0.05, + "top_n_features": 20, + "min_sample_size": 10 + }, + "retention_groups": [ + "Retained_d0", + "Retained_d1", + "Retained_d2", + "Retained_d3", + "Retained_d4", + "Retained_d5", + "Retained_d6", + "Retained_d7+" + ], + "exclude_columns": [ + "uid", + "retention_status", + "retention_encoded", + "country", + "nickname", + "auth_id" + ], + "key_metrics_for_d0_analysis": [ + "tutorial_complete", + "level_up_count", + "play_time_total", + "COOP_entry_count", + "Solo_entry_count", + "death_Monster", + "death_Trap", + "death_PK", + "item_gain_Equipment", + "gold_gain_total" + ], + "output_settings": { + "save_csv": true, + "save_markdown": true, + "save_console_log": true, + "timestamp_format": "%Y%m%d_%H%M%S" + } +} \ No newline at end of file