Add retention analysis configuration file - Introduced `retention_analysis_config.json` to define settings for retention analysis. - Configured analysis settings including correlation threshold, top features, and minimum sample size. - Specified retention groups for analysis. - Listed columns to be excluded from the analysis. - Defined key metrics for day 0 analysis. - Set output preferences for saving results in various formats.
1212 lines
51 KiB
Python
1212 lines
51 KiB
Python
#!/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"""
|
||
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>던전 스토커즈 - 신규 유저 리텐션 분석 리포트</title>
|
||
<style>
|
||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||
body {{
|
||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||
line-height: 1.6;
|
||
color: #333;
|
||
background: #f5f7fa;
|
||
padding: 20px;
|
||
}}
|
||
.container {{
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
background: white;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||
overflow: hidden;
|
||
}}
|
||
.header {{
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
padding: 30px;
|
||
text-align: center;
|
||
}}
|
||
.header h1 {{ font-size: 2.5em; margin-bottom: 10px; font-weight: 300; }}
|
||
.header p {{ font-size: 1.2em; opacity: 0.9; }}
|
||
.content {{ padding: 40px; }}
|
||
.section {{ margin-bottom: 40px; }}
|
||
.section h2 {{
|
||
color: #2c3e50;
|
||
border-bottom: 3px solid #3498db;
|
||
padding-bottom: 10px;
|
||
margin-bottom: 20px;
|
||
font-size: 1.8em;
|
||
}}
|
||
.section h3 {{
|
||
color: #34495e;
|
||
margin-bottom: 15px;
|
||
font-size: 1.4em;
|
||
background: #ecf0f1;
|
||
padding: 10px 15px;
|
||
border-radius: 8px;
|
||
}}
|
||
.stats-grid {{
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||
gap: 20px;
|
||
margin-bottom: 30px;
|
||
}}
|
||
.stat-card {{
|
||
background: white;
|
||
border-radius: 10px;
|
||
padding: 25px;
|
||
text-align: center;
|
||
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||
border: 2px solid #ecf0f1;
|
||
transition: transform 0.3s ease;
|
||
}}
|
||
.stat-card:hover {{ transform: translateY(-5px); }}
|
||
.stat-value {{
|
||
font-size: 2.5em;
|
||
font-weight: bold;
|
||
margin-bottom: 10px;
|
||
}}
|
||
.stat-label {{
|
||
color: #7f8c8d;
|
||
font-size: 1.1em;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}}
|
||
.critical {{ color: #e74c3c; }}
|
||
.good {{ color: #27ae60; }}
|
||
.warning {{ color: #f39c12; }}
|
||
.table {{
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin-top: 20px;
|
||
background: white;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||
}}
|
||
.table th {{
|
||
background: #34495e;
|
||
color: white;
|
||
padding: 15px;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
}}
|
||
.table td {{
|
||
padding: 12px 15px;
|
||
border-bottom: 1px solid #ecf0f1;
|
||
}}
|
||
.table tr:hover {{ background: #f8f9fa; }}
|
||
.table tr:nth-child(even) {{ background: #fafbfc; }}
|
||
.positive {{ color: #27ae60; font-weight: bold; }}
|
||
.negative {{ color: #e74c3c; font-weight: bold; }}
|
||
.metric-name {{
|
||
font-weight: bold;
|
||
color: #2c3e50;
|
||
font-family: 'Courier New', monospace;
|
||
background: #ecf0f1;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
}}
|
||
.correlation-value {{ font-size: 1.1em; font-weight: bold; }}
|
||
.p-value {{ color: #7f8c8d; font-size: 0.9em; }}
|
||
.retention-distribution {{
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||
gap: 15px;
|
||
margin: 20px 0;
|
||
}}
|
||
.retention-item {{
|
||
text-align: center;
|
||
padding: 15px;
|
||
background: white;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||
border: 2px solid #ecf0f1;
|
||
}}
|
||
.retention-label {{
|
||
font-size: 0.9em;
|
||
color: #7f8c8d;
|
||
margin-bottom: 5px;
|
||
}}
|
||
.retention-count {{
|
||
font-size: 1.4em;
|
||
font-weight: bold;
|
||
color: #2c3e50;
|
||
}}
|
||
.retention-percent {{
|
||
font-size: 0.9em;
|
||
color: #3498db;
|
||
font-weight: bold;
|
||
}}
|
||
.info-box {{
|
||
background: #e8f6f3;
|
||
border-left: 5px solid #1abc9c;
|
||
padding: 20px;
|
||
margin: 20px 0;
|
||
border-radius: 5px;
|
||
}}
|
||
.warning-box {{
|
||
background: #fdf2e9;
|
||
border-left: 5px solid #e67e22;
|
||
padding: 20px;
|
||
margin: 20px 0;
|
||
border-radius: 5px;
|
||
}}
|
||
.critical-box {{
|
||
background: #fadbd8;
|
||
border-left: 5px solid #e74c3c;
|
||
padding: 20px;
|
||
margin: 20px 0;
|
||
border-radius: 5px;
|
||
}}
|
||
.footer {{
|
||
background: #34495e;
|
||
color: white;
|
||
padding: 20px;
|
||
text-align: center;
|
||
}}
|
||
.analysis-timestamp {{ color: #95a5a6; }}
|
||
ul {{ margin-left: 20px; }}
|
||
li {{ margin-bottom: 8px; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<header class="header">
|
||
<h1>던전 스토커즈</h1>
|
||
<p>신규 유저 리텐션 분석 리포트</p>
|
||
<div class="analysis-timestamp">분석 일시: {datetime.now().strftime('%Y년 %m월 %d일 %H:%M:%S')}</div>
|
||
</header>
|
||
|
||
<div class="content">
|
||
<!-- 실행 정보 -->
|
||
<section class="section">
|
||
<h2>📊 분석 개요</h2>
|
||
<div class="info-box">
|
||
<strong>데이터 파일:</strong> {os.path.basename(self.csv_path)}<br>
|
||
<strong>분석 대상:</strong> {len(self.df):,}명의 신규 유저<br>"""
|
||
|
||
# 분석 기간 정보 추가
|
||
if hasattr(self, 'analysis_period_info') and self.analysis_period_info['valid']:
|
||
period_info = self.analysis_period_info
|
||
html_content += f"""
|
||
<strong>분석 기간:</strong> {period_info['start_date']} ~ {period_info['end_date']} ({period_info['total_days']}일)<br>
|
||
<strong>일평균 신규 가입:</strong> {period_info['avg_daily_signups']:.1f}명<br>
|
||
<strong>데이터 품질:</strong> {period_info['valid_ratio']:.1f}% (유효한 가입일 데이터)<br>"""
|
||
|
||
html_content += f"""
|
||
<strong>분석 범위:</strong> D0 ~ D7+ 리텐션 분석<br>
|
||
<strong>통계 방법:</strong> Spearman 상관분석, Welch's t-test, Random Forest 특성 중요도
|
||
</div>"""
|
||
|
||
# 분석 기간 상세 정보 (별도 섹션)
|
||
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"""
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-value good">{recent_7days:,}</div>
|
||
<div class="stat-label">최근 7일 가입</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value good">{recent_30days:,}</div>
|
||
<div class="stat-label">최근 30일 가입</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value warning">{period_info['total_days']}</div>
|
||
<div class="stat-label">전체 분석 일수</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value good">{period_info['avg_daily_signups']:.1f}</div>
|
||
<div class="stat-label">일평균 신규가입</div>
|
||
</div>
|
||
</div>"""
|
||
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"""
|
||
<div class="info-box">
|
||
<h4>🕒 가입 패턴 분석</h4>
|
||
<strong>가입 집중 시간대:</strong> {period_info['peak_hour']}시 ({period_info['peak_hour_count']:,}명)<br>
|
||
<strong>가입 집중 요일:</strong> {max_weekday_name}요일 ({max_weekday_count:,}명)<br>
|
||
<strong>시간대 분석:</strong> 한국 시간(KST) 기준으로 분석됨
|
||
</div>"""
|
||
|
||
html_content += f"""
|
||
|
||
<h3>📅 분석 기간 상세</h3>
|
||
<div class="warning-box">
|
||
<strong>데이터 수집 기간:</strong> {period_info['start_date']} ~ {period_info['end_date']}<br>
|
||
<strong>전체 분석 대상:</strong> {period_info['total_users']:,}명 (ISO 8601 표준 형식)<br>
|
||
<strong>일평균 신규 가입자:</strong> {period_info['avg_daily_signups']:.1f}명<br>
|
||
<strong>분석 대상 기간:</strong> {period_info['total_days']}일간의 신규 가입자 행동 패턴<br>
|
||
<strong>데이터 품질:</strong> {period_info['valid_ratio']:.1f}% (표준 시간 형식으로 완전한 데이터)
|
||
</div>
|
||
{pattern_info}
|
||
{recent_stats}"""
|
||
|
||
html_content += f"""
|
||
</section>
|
||
|
||
<!-- 전체 리텐션 현황 -->
|
||
<section class="section">
|
||
<h2>🎯 전체 리텐션 현황</h2>
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-value critical">{d0_rate:.1f}%</div>
|
||
<div class="stat-label">D0 이탈률</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value warning">{d1_rate:.1f}%</div>
|
||
<div class="stat-label">D1 유지율</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value good">{d3_plus_rate:.1f}%</div>
|
||
<div class="stat-label">D3+ 잔존율</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value good">{d7_plus_rate:.1f}%</div>
|
||
<div class="stat-label">D7+ 잔존율</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="critical-box">
|
||
<h3>⚠️ Critical Alert</h3>
|
||
<p><strong>D0 이탈률 {d0_rate:.1f}%</strong>는 게임 업계 평균(25-35%)을 크게 상회하는 매우 심각한 수준입니다.</p>
|
||
<p>즉시 개선 조치가 필요합니다.</p>
|
||
</div>
|
||
|
||
<h3>일별 리텐션 분포</h3>
|
||
<div class="retention-distribution">"""
|
||
|
||
# 리텐션 분포 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"""
|
||
<div class="retention-item">
|
||
<div class="retention-label">{status.replace('Retained_', '')}</div>
|
||
<div class="retention-count">{count:,}</div>
|
||
<div class="retention-percent">{pct:.1f}%</div>
|
||
</div>"""
|
||
|
||
html_content += """
|
||
</div>
|
||
</section>"""
|
||
|
||
# 상관관계 분석 결과
|
||
if not self.correlation_results.empty:
|
||
html_content += f"""
|
||
<!-- 상관관계 분석 -->
|
||
<section class="section">
|
||
<h2>🔍 리텐션 영향 요인 분석</h2>
|
||
|
||
<div class="info-box">
|
||
<strong>분석 방법:</strong> Spearman 순위 상관계수 (ρ)<br>
|
||
<strong>유의수준:</strong> p < 0.05<br>
|
||
<strong>분석 지표 수:</strong> {len(self.correlation_results)}개<br>
|
||
<strong>유의한 지표 수:</strong> {len(self.correlation_results[self.correlation_results['significant']])}개
|
||
</div>
|
||
|
||
<h3>📈 리텐션 향상 요인 (긍정적 상관관계)</h3>
|
||
<table class="table">
|
||
<thead>
|
||
<tr>
|
||
<th>순위</th>
|
||
<th>지표명</th>
|
||
<th>상관계수 (ρ)</th>
|
||
<th>유의확률 (p)</th>
|
||
<th>해석</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>"""
|
||
|
||
for idx, (_, row) in enumerate(positive.iterrows(), 1):
|
||
interpretation = "강한 긍정적 관계" if row['correlation'] > 0.3 else "중간 긍정적 관계" if row['correlation'] > 0.1 else "약한 긍정적 관계"
|
||
html_content += f"""
|
||
<tr>
|
||
<td><strong>{idx}</strong></td>
|
||
<td><span class="metric-name">{row['metric']}</span></td>
|
||
<td><span class="correlation-value positive">+{row['correlation']:.3f}</span></td>
|
||
<td><span class="p-value">{'< 0.001' if row['p_value'] < 0.001 else f'{row["p_value"]:.3f}'}</span></td>
|
||
<td>{interpretation}</td>
|
||
</tr>"""
|
||
|
||
html_content += """
|
||
</tbody>
|
||
</table>"""
|
||
|
||
if len(negative) > 0:
|
||
html_content += """
|
||
<h3>📉 리텐션 저해 요인 (부정적 상관관계)</h3>
|
||
<table class="table">
|
||
<thead>
|
||
<tr>
|
||
<th>순위</th>
|
||
<th>지표명</th>
|
||
<th>상관계수 (ρ)</th>
|
||
<th>유의확률 (p)</th>
|
||
<th>해석</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>"""
|
||
|
||
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"""
|
||
<tr>
|
||
<td><strong>{idx}</strong></td>
|
||
<td><span class="metric-name">{row['metric']}</span></td>
|
||
<td><span class="correlation-value negative">{row['correlation']:.3f}</span></td>
|
||
<td><span class="p-value">{'< 0.001' if row['p_value'] < 0.001 else f'{row["p_value"]:.3f}'}</span></td>
|
||
<td>{interpretation}</td>
|
||
</tr>"""
|
||
|
||
html_content += """
|
||
</tbody>
|
||
</table>"""
|
||
|
||
html_content += """
|
||
</section>"""
|
||
|
||
# D0 분석 결과가 있다면 추가
|
||
if hasattr(self, 'd0_comparison') and not self.d0_comparison.empty:
|
||
html_content += f"""
|
||
<!-- D0 이탈 분석 -->
|
||
<section class="section">
|
||
<h2>🎯 D0 이탈 vs 잔존 유저 비교 분석</h2>
|
||
|
||
<div class="warning-box">
|
||
<strong>분석 대상:</strong><br>
|
||
D0 이탈 유저: {len(self.df[self.df['retention_status'] == 'Retained_d0']):,}명<br>
|
||
잔존 유저: {len(self.df[self.df['retention_status'] != 'Retained_d0']):,}명
|
||
</div>
|
||
|
||
<h3>핵심 지표별 차이 분석</h3>
|
||
<table class="table">
|
||
<thead>
|
||
<tr>
|
||
<th>지표명</th>
|
||
<th>D0 이탈자 평균</th>
|
||
<th>잔존자 평균</th>
|
||
<th>차이율</th>
|
||
<th>유의확률 (p)</th>
|
||
<th>해석</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>"""
|
||
|
||
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"""
|
||
<tr>
|
||
<td><span class="metric-name">{row['metric']}</span></td>
|
||
<td>{row['d0_mean']:.2f}</td>
|
||
<td>{row['retained_mean']:.2f}</td>
|
||
<td><span class="correlation-value {diff_class}">{row['difference_%']:+.1f}%</span></td>
|
||
<td><span class="p-value">{'< 0.001' if row['p_value'] < 0.001 else f'{row["p_value"]:.3f}'}</span></td>
|
||
<td>{interpretation}</td>
|
||
</tr>"""
|
||
|
||
html_content += """
|
||
</tbody>
|
||
</table>
|
||
</section>"""
|
||
|
||
# Feature Importance 결과
|
||
if hasattr(self, 'feature_importance') and not self.feature_importance.empty:
|
||
top_features = self.feature_importance.head(20)
|
||
html_content += f"""
|
||
<!-- Feature Importance -->
|
||
<section class="section">
|
||
<h2>🎖️ 리텐션 예측 중요도 분석</h2>
|
||
|
||
<div class="info-box">
|
||
<strong>분석 방법:</strong> Random Forest Classifier<br>
|
||
<strong>모델 설정:</strong> 100개 트리, 교차 검증<br>
|
||
<strong>분석 특성 수:</strong> {len(self.feature_importance)}개
|
||
</div>
|
||
|
||
<h3>Top 20 중요 특성</h3>
|
||
<table class="table">
|
||
<thead>
|
||
<tr>
|
||
<th>순위</th>
|
||
<th>지표명</th>
|
||
<th>중요도 점수</th>
|
||
<th>중요도 등급</th>
|
||
<th>액션 우선순위</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>"""
|
||
|
||
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"""
|
||
<tr>
|
||
<td><strong>{idx}</strong></td>
|
||
<td><span class="metric-name">{row['feature']}</span></td>
|
||
<td><span class="correlation-value">{importance:.4f}</span></td>
|
||
<td><span class="{grade_class}">{grade}</span></td>
|
||
<td>{priority}</td>
|
||
</tr>"""
|
||
|
||
html_content += """
|
||
</tbody>
|
||
</table>
|
||
</section>"""
|
||
|
||
# 개선 권고사항
|
||
html_content += f"""
|
||
<!-- 개선 권고사항 -->
|
||
<section class="section">
|
||
<h2>🚀 개선 권고사항</h2>
|
||
|
||
<div class="critical-box">
|
||
<h3>🔥 즉시 실행 (Critical)</h3>
|
||
<ul>
|
||
<li><strong>D0 이탈률 {d0_rate:.1f}%</strong> - 업계 평균의 2배 수준으로 즉시 개선 필요</li>"""
|
||
|
||
if not self.correlation_results.empty and len(positive) > 0:
|
||
top_positive = positive.iloc[0]
|
||
html_content += f"""
|
||
<li><strong>{top_positive['metric']}</strong> 지표 강화 - 최고 긍정 요인 (상관계수: {top_positive['correlation']:.3f})</li>"""
|
||
|
||
html_content += """
|
||
<li>신규 유저 온보딩 프로세스 전면 재검토</li>
|
||
<li>첫날 경험 최적화 프로젝트 착수</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="warning-box">
|
||
<h3>⚡ 우선 개선 (High Priority)</h3>
|
||
<ul>"""
|
||
|
||
if not self.correlation_results.empty:
|
||
for _, row in positive.head(3).iterrows():
|
||
html_content += f"""
|
||
<li><span class="metric-name">{row['metric']}</span> 개선 - 리텐션 향상 효과 기대 (ρ = {row['correlation']:.3f})</li>"""
|
||
|
||
html_content += """
|
||
<li>D1-D3 전환율 개선 프로그램 도입</li>
|
||
<li>이탈 위험 유저 조기 탐지 시스템 구축</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="info-box">
|
||
<h3>📊 지속 모니터링 (Monitoring)</h3>
|
||
<ul>"""
|
||
|
||
if len(negative) > 0:
|
||
for _, row in negative.head(3).iterrows():
|
||
html_content += f"""
|
||
<li><span class="metric-name">{row['metric']}</span> 모니터링 - 부정적 영향 최소화 (ρ = {row['correlation']:.3f})</li>"""
|
||
|
||
html_content += f"""
|
||
<li>주간 리텐션 대시보드 구축</li>
|
||
<li>A/B 테스트를 통한 개선 효과 검증</li>
|
||
<li>세그먼트별 리텐션 패턴 심층 분석</li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 종합 결론 -->
|
||
<section class="section">
|
||
<h2>📝 종합 결론</h2>
|
||
|
||
<div class="critical-box">
|
||
<h3>현재 상황 진단</h3>
|
||
<p>던전 스토커즈의 <strong>D0 이탈률 {d0_rate:.1f}%</strong>는 게임 업계 평균(25-35%)을 크게 상회하는
|
||
<strong>Critical 수준</strong>입니다. 이는 서비스 지속성에 직접적인 위협이 되는 상황으로,
|
||
즉시 종합적인 개선 조치가 필요합니다.</p>
|
||
</div>
|
||
|
||
<div class="info-box">
|
||
<h3>핵심 개선 전략</h3>
|
||
<ol>
|
||
<li><strong>첫날 경험 최적화</strong>: 온보딩, 튜토리얼, 초기 보상 시스템 전면 재설계</li>
|
||
<li><strong>데이터 기반 개선</strong>: 긍정적 상관관계 지표들의 초기 경험 강화</li>
|
||
<li><strong>위험 요소 제거</strong>: 부정적 영향 지표들의 영향도 최소화</li>
|
||
<li><strong>지속적 모니터링</strong>: 실시간 리텐션 추적 및 조기 경보 시스템</li>
|
||
</ol>
|
||
</div>
|
||
|
||
<div class="warning-box">
|
||
<h3>기대 효과</h3>
|
||
<p>본 분석 결과를 바탕으로 한 개선 조치 시행 시, <strong>D0 이탈률을 업계 평균 수준인 30% 이하로
|
||
개선</strong>할 수 있을 것으로 예상됩니다. 이는 <strong>월간 신규 유저 중 약 35% 추가 확보</strong>를
|
||
의미하며, 장기적으로 서비스 성장에 크게 기여할 것입니다.</p>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<footer class="footer">
|
||
<p>던전 스토커즈 리텐션 분석 리포트 | 생성일시: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||
<p>데이터 기반 의사결정으로 더 나은 게임 경험을 만들어가겠습니다.</p>
|
||
</footer>
|
||
</div>
|
||
</body>
|
||
</html>"""
|
||
|
||
# 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() |