studyLAB

Dacon 대회 EDA공부 - 토스 NEXT ML CHALLENGE : 광고 클릭 예측(CTR) 모델 개발 본문

codeLAB

Dacon 대회 EDA공부 - 토스 NEXT ML CHALLENGE : 광고 클릭 예측(CTR) 모델 개발

studyLAB 2025. 9. 25. 14:44

이번 포스팅에서는 Dacon 경진대회로 열린 "토스 NEXT ML CHALLENGE : 광고 클릭 예측(CTR) 모델 개발"의 공유 코드를 분석하고, 결과에 대한 EDA를 정리 공부해보겠습니다. 직접 코드를 돌려보며 확인하면 좋겠지만 컴퓨팅 환경 상 공유 분석 결과를 보고 공부하기로 하였습니다. 좋은 분석과 코드 및 결과를 공유하고 포스팅을 허락해주신 CandleMaster님께 감사인사 드립니다😘

 

배경

우선 배경에 대해서 간단히 이해를 해 보겠습니다. 

"토스는 ML 기술을 기반으로 디스플레이 광고의 성과를 극대화하기 위해, 다양한 광고 지면과 사용자 접점에서 광고의 노출부터 전환까지 퍼널 전반을 최적화할 수 있는 정교한 예측 모델과 의사결정 알고리즘을 개발하고 있습니다.

광고 ML팀의 가장 중요한 목표 중 하나는 유저가 어떤 소재를 클릭할 것인지 그 확률을 매우 빠르고 정확하게 예측하는 것입니다."

 

"토스 앱 내 광고 지면에서의 외부 광고 노출 및 클릭 데이터를 기반으로, 사용자가 특정 광고를 클릭할 확률을 예측하는 모델을 개발합니다."

 

정리해보자면 토스에서는 광고 지면의 광고를 클릭할 확률을 예측하여, 광고의 성능을 극대화 하는 것을 목표로 함을 알 수 있습니다. 이를 예측하기 위해서 ML기술을 접목하고자 하는 것입니다. 

 

공유 코드 및 결과

print("🎯 광고 클릭 예측 데이터 분석 시작!")
print("=" * 60)

# ========================================
# 📊 1. 데이터 로드 및 기본 정보
# ========================================

# 데이터 로드
df = pd.read_csv('train_sample_2000000.csv')

print("📋 데이터 기본 정보:")
print(f"   총 샘플 수: {len(df):,}")
print(f"   총 컬럼 수: {len(df.columns)}")
print(f"   메모리 사용량: {df.memory_usage(deep=True).sum() / 1024**2:.2f} MB")

# 결측값 확인
missing_info = df.isnull().sum()
missing_info = missing_info[missing_info > 0].sort_values(ascending=False)
if len(missing_info) > 0:
    print(f"\n⚠️  결측값이 있는 컬럼: {len(missing_info)}개")
    print(missing_info.head(10))
else:
    print("\n✅ 결측값 없음")

print(f"\n📊 데이터 타입 분포:")
print(df.dtypes.value_counts())
print(df.info())
display(df.describe())

 

제공된 데이터 자체 용량이 train : 8.18GB, test : 9.40GB 로 매우 큰 나머지 2000000개 데이터를 추출하여 'train_sample_2000000.csv'파일을 불러온 것을 확인 가능합니다. 

🎯 광고 클릭 예측 데이터 분석 시작!
============================================================
📋 데이터 기본 정보:
   총 샘플 수: 2,000,000
   총 컬럼 수: 119
   메모리 사용량: 5674.60 MB

⚠️  결측값이 있는 컬럼: 96개
feat_e_3     202688
feat_a_8       3550
feat_a_15      3550
feat_a_17      3550
feat_a_1       3550
feat_a_2       3550
feat_a_3       3550
feat_a_4       3550
feat_a_5       3550
feat_a_6       3550
dtype: int64

📊 데이터 타입 분포:
float64    114
int64        4
object       1
Name: count, dtype: int64
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 2000000 entries, 0 to 1999999
Columns: 119 entries, gender to clicked
dtypes: float64(114), int64(4), object(1)
memory usage: 1.8+ GB
None

 

컬럼은 기존 119개 열을 그대로 추출되었습니다. 이 중 결측값이 있는 컬럼의 수가 96개나 존재합니다.

특히 feat_a_(1,2,3,4,5,6,8,15,17)컬럼에서는 동일하게  3550개씩 결측치가 존재했습니다. feat_a~컬럼은 정확히 동일한 개수의 결측치를 가지는 것을 보아 우연한 결측이 아닌 서로의 상관성 하에 함께 결측되었을 가능성이 있습니다. 

이 결측의 정보가 모델링에 중요한 정보로 작용 가능할 수 있다 생각듭니다.

feat_e_3에서는 202688개로 다른 feat_a열에 비해 월등히 높은 결측치수가 존재함을 알 수 있습니다. 이는 2,000,000개 rows중 10%의 높은 비율입니다. 이 변수 또한 기억하면 좋겠네요.

 

데이터 타입으로는 114개의 실수, 4개의 정수, 1개의 명목형 변수가 존재합니다. 

 

 

# ========================================
# 🎯 2. 타겟 변수 (clicked) 분석
# ========================================

print("\n" + "="*60)
print("🎯 타겟 변수 (clicked) 분석")
print("="*60)

# 클릭률 계산
click_rate = df['clicked'].mean()
total_clicks = df['clicked'].sum()
total_samples = len(df)

print(f"📈 전체 클릭률: {click_rate:.4f} ({click_rate*100:.2f}%)")
print(f"🔢 총 클릭 수: {total_clicks:,} / {total_samples:,}")
print(f"⚖️  클래스 불균형 비율: {(1-click_rate)/click_rate:.1f}:1 (Non-click:Click)")

# 타겟 분포 시각화
fig, axes = plt.subplots(1, 3, figsize=(15, 5))

# 클릭 분포 (카운트)
df['clicked'].value_counts().plot(kind='bar', ax=axes[0], color=['lightcoral', 'lightblue'])
axes[0].set_title('클릭 분포 (카운트)', fontsize=12, fontweight='bold')
axes[0].set_xlabel('클릭 여부')
axes[0].set_ylabel('샘플 수')
axes[0].tick_params(axis='x', rotation=0)

# 클릭 분포 (비율) - 파이차트
sizes = df['clicked'].value_counts()
labels = ['Non-Click', 'Click']
colors = ['lightcoral', 'lightblue']
axes[1].pie(sizes, labels=labels, colors=colors, autopct='%1.2f%%', startangle=90)
axes[1].set_title('클릭 분포 (비율)', fontsize=12, fontweight='bold')

# 클릭률 시각화 (게이지 형태)
axes[2].barh(['클릭률'], [click_rate], color='steelblue', alpha=0.7)
axes[2].set_xlim(0, 1)
axes[2].set_title(f'전체 클릭률: {click_rate:.4f}', fontsize=12, fontweight='bold')
axes[2].set_xlabel('클릭률')
for i, v in enumerate([click_rate]):
    axes[2].text(v + 0.01, i, f'{v:.4f}', va='center')

plt.tight_layout()
plt.show()

 

위 코드에서는 클릭률에 대한 계산 및 시각화, 클릭 분포에 대한 시각화를 수행하였습니다.

 

🎯 타겟 변수 (clicked) 분석

============================================================

📈 전체 클릭률: 0.0192 (1.92%)

🔢 총 클릭 수: 38,314 / 2,000,000

⚖️ 클래스 불균형 비율: 51.2:1 (Non-click:Click)

 

 

분석 결과를 보면 전체 클릭률이 1.92%로 매우 낮음을 알 수 있습니다.

저도 사실 광고를 되게 불편해 하는 편이라 앱 내 광고에 눈길이 잘 안가거나, 있더라도 실수로 누르는게 아니고선 최대한 클릭이 안되게 하려 합니다. (광고주 측에서는 이러한 사람의 생각을 고려한 눈길을 사로잡는 창의적인 광고를 시도해야겠네요. 혹은 새로운 포맷의 광고 지면을 개발하거나..?) 이러한 앱 사용자들의 특성이 반영된 결과가 아닐까 생각이 듭니다.

 

시각화 결과를 좌측부터 확인해보면
1. value_counts().plot(kind='bar'~. 즉 barplot으로 1.92%를 시각화 하였고, 

2. axes[1].pie ~ . 즉 piechart로 시각화를 한 결과입니다.

3. axes[2].barh~ 로 수평막대그래프입니다. 

세 시각화 모두 클릭률이 매우 낮음을 한 눈에 파악 가능하도록 좋은 시각화 결과를 보여준다고 볼 수 있습니다.

 

 

# ========================================
# ⏰ 3. 시간 패턴 분석
# ========================================

print("\n" + "="*60)
print("⏰ 시간 패턴 분석")
print("="*60)

# 시간대별 활동 패턴
hourly_stats = df.groupby('hour').agg({
    'clicked': ['count', 'sum', 'mean']
}).round(4)
hourly_stats.columns = ['총_샘플수', '클릭수', '클릭률']

print("📊 시간대별 통계 (상위 5개):")
print(hourly_stats.sort_values('클릭률', ascending=False).head())

# 요일별 활동 패턴
df['day_of_week'] -= 1
daily_stats = df.groupby('day_of_week').agg({
    'clicked': ['count', 'sum', 'mean']
}).round(4)
daily_stats.columns = ['총_샘플수', '클릭수', '클릭률']

print(f"\n📊 요일별 통계:")
day_names = ['월', '화', '수', '목', '금', '토', '일']
daily_stats.index = [day_names[i] if i < len(day_names) else f'Day_{i}' for i in daily_stats.index]
print(daily_stats)

# 시간 패턴 시각화
fig, axes = plt.subplots(2, 3, figsize=(18, 12))

# 1. 시간대별 샘플 수
hourly_stats['총_샘플수'].plot(kind='bar', ax=axes[0,0], color='skyblue', alpha=0.8)
axes[0,0].set_title('시간대별 총 샘플 수', fontsize=12, fontweight='bold')
axes[0,0].set_xlabel('시간')
axes[0,0].set_ylabel('샘플 수')
axes[0,0].tick_params(axis='x', rotation=45)

# 2. 시간대별 클릭률
hourly_stats['클릭률'].plot(kind='line', ax=axes[0,1], color='red', marker='o', linewidth=2)
axes[0,1].set_title('시간대별 클릭률 추이', fontsize=12, fontweight='bold')
axes[0,1].set_xlabel('시간')
axes[0,1].set_ylabel('클릭률')
axes[0,1].grid(True, alpha=0.3)

# 3. 요일별 샘플 수
daily_stats['총_샘플수'].plot(kind='bar', ax=axes[0,2], color='lightgreen', alpha=0.8)
axes[0,2].set_title('요일별 총 샘플 수', fontsize=12, fontweight='bold')
axes[0,2].set_xlabel('요일')
axes[0,2].set_ylabel('샘플 수')
axes[0,2].tick_params(axis='x', rotation=45)

# 4. 요일별 클릭률
daily_stats['클릭률'].plot(kind='line', ax=axes[1,0], color='purple', marker='s', linewidth=2)
axes[1,0].set_title('요일별 클릭률 추이', fontsize=12, fontweight='bold')
axes[1,0].set_xlabel('요일')
axes[1,0].set_ylabel('클릭률')
axes[1,0].grid(True, alpha=0.3)

# 5. 시간 x 요일 히트맵 (클릭률)
pivot_table = df.groupby(['day_of_week', 'hour'])['clicked'].mean().unstack()
im = axes[1,1].imshow(pivot_table.values, cmap='YlOrRd', aspect='auto')
axes[1,1].set_title('시간 x 요일 클릭률 히트맵', fontsize=12, fontweight='bold')
axes[1,1].set_xlabel('시간')
axes[1,1].set_ylabel('요일')
axes[1,1].set_yticks(range(len(pivot_table.index)))
axes[1,1].set_yticklabels([day_names[i] if i < len(day_names) else f'Day_{i}' for i in pivot_table.index])
plt.colorbar(im, ax=axes[1,1], label='클릭률')

# 6. 피크 시간대 분석
peak_hours = hourly_stats.nlargest(5, '클릭률')
peak_hours.index.name = '시간'
peak_hours['클릭률'].plot(kind='bar', ax=axes[1,2], color='orange', alpha=0.8)
axes[1,2].set_title('클릭률 TOP 5 시간대', fontsize=12, fontweight='bold')
axes[1,2].set_xlabel('시간')
axes[1,2].set_ylabel('클릭률')
axes[1,2].tick_params(axis='x', rotation=45)

plt.tight_layout()
plt.show()

# 시간 인사이트 출력
print(f"\n🕐 시간 패턴 인사이트:")
peak_hour = hourly_stats['클릭률'].idxmax()
low_hour = hourly_stats['클릭률'].idxmin()
print(f"   최고 클릭률 시간: {peak_hour}시 ({hourly_stats.loc[peak_hour, '클릭률']:.4f})")
print(f"   최저 클릭률 시간: {low_hour}시 ({hourly_stats.loc[low_hour, '클릭률']:.4f})")

peak_day = daily_stats['클릭률'].idxmax()
low_day = daily_stats['클릭률'].idxmin()
print(f"   최고 클릭률 요일: {peak_day} ({daily_stats.loc[peak_day, '클릭률']:.4f})")
print(f"   최저 클릭률 요일: {low_day} ({daily_stats.loc[low_day, '클릭률']:.4f})")

 

위는 시간 패턴 분석을 진행한 코드입니다. 

df.groupby('hour')를 통해서 hour 컬럼을 기준으로 groupby를 한 것을 확인 가능하고, agg에서 clicked에 대해 count(총_샘플수), sum('클릭수'), mean('클릭률')을 계산하여 시간대별 패턴을 파악하고자 하였습니다. 결과로는 .head()를 사용하여 상위 5개만 출력한 것을 확인 가능합니다. 

 

다음으로 df.groupby('day_of_week')를 통해 day_of_week 컬럼을 기준으로 groupby를 수행하여 요일별 활동 패턴을 파악하고자 하였습니다. 

 

============================================================
⏰ 시간 패턴 분석
============================================================
📊 시간대별 통계 (상위 5개):
      총_샘플수   클릭수     클릭률
hour                     
3     14737   343  0.0233
4     20678   471  0.0228
2     18421   405  0.0220
16    78739  1662  0.0211
15    77989  1638  0.0210

📊 요일별 통계:
    총_샘플수   클릭수     클릭률
월  284998  4868  0.0171
화  285963  6000  0.0210
수  285864  5643  0.0197
목  285716  5650  0.0198
금  286164  5568  0.0195
토  285873  5609  0.0196
일  285422  4976  0.0174

🕐 시간 패턴 인사이트:
   최고 클릭률 시간: 3시 (0.0233)
   최저 클릭률 시간: 7시 (0.0175)
   최고 클릭률 요일: 화 (0.0210)
   최저 클릭률 요일: 월 (0.0171)

 

시간대별 통계에서는 클릭률을 기준으로 정렬된 것을 확인 가능하고, 3 - 4 - 2 - 16 - 15 순을 확인 가능합니다. 아래 plot을 확인해 보면 새벽 시간대인 2,3,4시에 총 샘플수가 가장 적은 것을 확인 가능합니다. 보통 기대하기로는 오후 시간에 가장 많을것으로 기대하게 되지만, 분석 결과 가장 활동량이 적은 새벽 2~4시에 클릭률이 가장 높은 것을 확인하였습니다. 

 

이는 낮 시간대에 접속 수는 많지만 보통은 뚜렷한 앱 사용 목적을 가지고 저처럼 광고를 기피하여 필요한 목적만 달성하고자 하는 이용자수가 많거나, 새벽 시간대에 졸린 상황에서 잘못 클릭으로 이어지는 경우가 있을수도 있다고 생각되어집니다.

결과적으로  2~4시는 다른 시간대에 비해 샘플이 매우 적은 편이지만, 높은 클릭률을 보였기에 이 또한 기억하고 분석하면 좋을 듯 합니다.

 

요일별 총 샘플수는 거의 균일하게 추출되었습니다. 요일별 비율이 거의 동일한 train에서(추측입니다)  train_sample_2000000.csv를 아마 요일별로 비율을 동일하게 추출하지 않았을까 생각이 드는 부분입니다.

요일 분석에서는 모두 비슷한 수의 샘플이 존재하지만 유독 화요일에서 높은 클릭률(2.1%)을 보입니다.

화요일에 광고 클릭률이 좋다는 점 또한 주목할 만합니다.

 

시간과 요일을 결합한 히트맵을 확인해보면 위의 두 패턴을 시각적으로 뚜렷하게 파악 가능합니다. 높은 클릭률은 평일에 집중되어있고 특히 오전 2~4시가 가장 강하며, 화, 수, 금요일에 가장 짙은 적색이 보입니다. 

 

한 가지 생각되는 점으로 평일 12~14시도 다른 시간대에 비해 살짝 높은 클릭률을 보입니다. 주기를 생각해보면 2~4시, 12~14시쯤으로 10시간? 혹은 12시간까지도 주기가 존재할 수 있음을 생각 가능합니다. 

 

 

 

# ========================================
# 👥 4. 사용자 세그먼트 분석
# ========================================

print("\n" + "="*60)
print("👥 사용자 세그먼트 분석")
print("="*60)

# 성별 분석
gender_stats = df.groupby('gender').agg({
    'clicked': ['count', 'sum', 'mean']
}).round(4)
gender_stats.columns = ['총_샘플수', '클릭수', '클릭률']

print("📊 성별 통계:")
print(gender_stats)

# 연령그룹 분석
age_stats = df.groupby('age_group').agg({
    'clicked': ['count', 'sum', 'mean']
}).round(4)
age_stats.columns = ['총_샘플수', '클릭수', '클릭률']

print(f"\n📊 연령그룹 통계:")
print(age_stats.sort_values('클릭률', ascending=False))

# 성별 x 연령그룹 교차 분석
cross_analysis = df.groupby(['gender', 'age_group']).agg({
    'clicked': ['count', 'sum', 'mean']
}).round(4)
cross_analysis.columns = ['총_샘플수', '클릭수', '클릭률']

print(f"\n📊 성별 x 연령그룹 교차 분석 (클릭률 상위 10개):")
print(cross_analysis.sort_values('클릭률', ascending=False).head(10))

# 사용자 세그먼트 시각화
fig, axes = plt.subplots(2, 3, figsize=(18, 12))

# 1. 성별 분포
gender_counts = df['gender'].value_counts()
axes[0,0].pie(gender_counts.values, labels=[f'Gender {i}' for i in gender_counts.index], 
              autopct='%1.1f%%', startangle=90)
axes[0,0].set_title('성별 분포', fontsize=12, fontweight='bold')

# 2. 연령그룹 분포
age_counts = df['age_group'].value_counts().sort_index()
age_counts.plot(kind='bar', ax=axes[0,1], color='lightcoral', alpha=0.8)
axes[0,1].set_title('연령그룹 분포', fontsize=12, fontweight='bold')
axes[0,1].set_xlabel('연령그룹')
axes[0,1].set_ylabel('샘플 수')
axes[0,1].tick_params(axis='x', rotation=45)

# 3. 성별 클릭률 비교
gender_stats['클릭률'].plot(kind='bar', ax=axes[0,2], color='steelblue', alpha=0.8)
axes[0,2].set_title('성별 클릭률 비교', fontsize=12, fontweight='bold')
axes[0,2].set_xlabel('성별')
axes[0,2].set_ylabel('클릭률')
axes[0,2].tick_params(axis='x', rotation=0)

# 4. 연령그룹 클릭률 비교
age_stats['클릭률'].plot(kind='bar', ax=axes[1,0], color='green', alpha=0.8)
axes[1,0].set_title('연령그룹별 클릭률 비교', fontsize=12, fontweight='bold')
axes[1,0].set_xlabel('연령그룹')
axes[1,0].set_ylabel('클릭률')
axes[1,0].tick_params(axis='x', rotation=45)

# 5. 성별 x 연령그룹 히트맵 (클릭률)
pivot_cross = df.groupby(['gender', 'age_group'])['clicked'].mean().unstack(fill_value=0)
im2 = axes[1,1].imshow(pivot_cross.values, cmap='Blues', aspect='auto')
axes[1,1].set_title('성별 x 연령그룹 클릭률 히트맵', fontsize=12, fontweight='bold')
axes[1,1].set_xlabel('연령그룹')
axes[1,1].set_ylabel('성별')
axes[1,1].set_yticks(range(len(pivot_cross.index)))
axes[1,1].set_yticklabels([f'Gender {i}' for i in pivot_cross.index])
axes[1,1].set_xticks(range(len(pivot_cross.columns)))
axes[1,1].set_xticklabels(pivot_cross.columns)
plt.colorbar(im2, ax=axes[1,1], label='클릭률')

# 6. 세그먼트별 샘플 수 비교
cross_samples = df.groupby(['gender', 'age_group']).size().sort_values(ascending=False).head(10)
cross_samples.plot(kind='barh', ax=axes[1,2], color='purple', alpha=0.7)
axes[1,2].set_title('세그먼트별 샘플 수 TOP 10', fontsize=12, fontweight='bold')
axes[1,2].set_xlabel('샘플 수')

plt.tight_layout()
plt.show()

# 세그먼트 인사이트 출력
print(f"\n👥 세그먼트 인사이트:")
best_gender = gender_stats['클릭률'].idxmax()
best_age = age_stats['클릭률'].idxmax()
best_segment = cross_analysis['클릭률'].idxmax()

print(f"   최고 클릭률 성별: Gender {best_gender} ({gender_stats.loc[best_gender, '클릭률']:.4f})")
print(f"   최고 클릭률 연령: Age {best_age} ({age_stats.loc[best_age, '클릭률']:.4f})")
print(f"   최고 클릭률 세그먼트: Gender {best_segment[0]}, Age {best_segment[1]} ({cross_analysis.loc[best_segment, '클릭률']:.4f})")

 

다음으로 사용자 정보에 대한 분석 코드입니다. 

사용자 정보로 gender, age_group 컬럼을 사용하였습니다. 

성별과 연령의 분포 및 클릭률에 대한 인사이트를 확인 가능합니다.

 

============================================================
👥 사용자 세그먼트 분석
============================================================
📊 성별 통계:
          총_샘플수    클릭수     클릭률
gender                        
1.0     1253705  24484  0.0195
2.0      743017  13764  0.0185

📊 연령그룹 통계:
            총_샘플수    클릭수     클릭률
age_group                       
1.0          5375    384  0.0714
8.0        494892  11224  0.0227
2.0         66944   1501  0.0224
3.0         89496   1799  0.0201
4.0        103501   1966  0.0190
5.0        110997   1955  0.0176
7.0        691770  12127  0.0175
6.0        433747   7292  0.0168

📊 성별 x 연령그룹 교차 분석 (클릭률 상위 10개):
                   총_샘플수   클릭수     클릭률
gender age_group                      
1.0    1.0          3274   247  0.0754
2.0    1.0          2101   137  0.0652
       2.0         28213   719  0.0255
1.0    8.0        333697  7673  0.0230
2.0    3.0         38847   870  0.0224
       8.0        161195  3551  0.0220
1.0    2.0         38731   782  0.0202
2.0    4.0         43246   872  0.0202
1.0    3.0         50649   929  0.0183
       4.0         60255  1094  0.0182
       
       
👥 세그먼트 인사이트:
   최고 클릭률 성별: Gender 1.0 (0.0195)
   최고 클릭률 연령: Age 1.0 (0.0714)
   최고 클릭률 세그먼트: Gender 1.0, Age 1.0 (0.0754)

 

 

사용자에 대한 상세 의미는 공개되지 않았습니다. 연령 그룹과 성별은 단순 그룹으로써 파악을 해야합니다. 

성별 통계에서는 gender 1.0이 62.8%로 gender 2.0의 거의 2배에 가까운 분포를 보입니다. 그러나 클릭률에서는 차이가 0.1%로  유의미 하다 보기 어렵습니다.

다음으로 연령 그룹에서 도드라지는 특징으로 age_ group 1.0 이 압도적으로 높은 클릭률 7%를 보였습니다. 그러나 총 샘플수에서 앞선 새벽 시간대(2~4시)와 비슷하게 가장 작은 수임을 바탕으로 시간대별 통계와 비슷한 패턴임을 확인 가능합니다.

age_group 1.0과 gender 1.0, 2.0을 함께 확인하면 age_group 1.0 & gender 1.0에서 여태 보인 클릭률 중 가장 높은 7.54%를 보입니다. age_group 1.0 & gender 2.0에서는 6.52%로 이 또한 매우 높음을 알 수 있습니다. 

 

'codeLAB' 카테고리의 다른 글

python - 얕은 복사, 깊은 복사  (0) 2026.01.18