Growth Marketing/GM4

[TIL-260420] 멋쟁이사자처럼 그로스마케팅 4기 - 데이터분석 개론 day28 | 데이터 전처리 실습

pamsyra 2026. 4. 20. 17:05
▼오늘 배운 사항들
1. 이벤트 데이터
- 데이터레이어
- ga4 데이터 pandas 연동
2. 전처리 실습 : B2B 리드 퍼널 데이터 분석
- 전처리 : 유입채널 정제
- 영업 사이클 시각화
- 유입 채널별 리드 전환율 분석

 

지난주 크롤링 배우면서, pandas 까먹었을 것 같다고 다음 강의인 머신러닝 전에 전처리 내용 다시 진행!

Part 1. 이벤트 데이터

[정의]

  • 본질 : '누가' '무엇을' 했다 기록하는 것 
  • 일반 데이터 vs 이벤트 데이터 비교시, 이벤트 데이터 자체가 더 중요

[그로스마케터에게 이벤트 데이터가 중요한 이유]

  • 핵심 업무 : 전환율 향상, 이탈률 감소, 재구매 증가
  • 이 모든 작업의 출발점이 "사용자가 실제로 어떻게 행동하는가" 파악하는 것이고
  • 그 근거가 이벤트 데이터이기 때문
업무 질문 필요한 event data
퍼널 분석 누가 어느 단계에서 전환이 안됐는지 view → cart → purchase 이벤트 흐름
리타겟팅 광고 구매 의사가 있었으나 최종 결제 단계에서 망설이는 타겟은 누구인가? cart 이벤트 발생 후 purchase 없는 사용자
개인화 추천 사용자가 현재 가장 관심을 두고 있는 카테고리나 취향은 무엇인가? 특정 카테고리 view 이벤트가 많은 사용자
이탈 분석 어떤 시점에 사용자의 흥미가 떨어졌으며, 이탈 징후를 보이는 구간은 어디인가? 마지막 이벤트 이후 접속 없는 사용자
A/B 테스트 평가 실험군과 대조군 중 어떤 UI/UX가 유저의 유의미한 행동 변화를 이끌어냈는가? 그룹별 이벤트 발생 횟수 및 전환율 비교

 

[datalayer 실제구조]

  • push : data를 올리겠다
  • 보통 gtm 태그만 설치하기도 하는데 중요한내용이나 누락방지를 위해서는 서버내용에 datalayer 설치하는게 좋다

Part 2. B2B 퍼널데이터 전처리하기

Step 1 데이터 파악

  • 결측치 확인
    → 왜이렇게 많은가? = 선택으로 되어있는 항목은 잘 안할 것
    → 선택폼일 땐 대부분 쓰지 않아서 결측치가 이만큼 많이 나오는 것
  • has_company 사업자등록여부
  • has_gtin 
  • average_stock

쩐다 엄청나다 이제까지 가입자수 매월 똑같고 결측치 없던 데이터에서 이런 값보니까 진짜 데이터 같고 좋다

그래서 삭제하는 것이 아니라 비어있는게 정상이구나 하고 넘어가기

💡결측치가 많다고 다 문제는 아니다 !

 

missing_데이터_pct로 데이터 결측퍼센티지도 확인해보기

Step 2 데이터 정제

  • 날짜변환 : pd.to_datetime()
    💡날짜변환의 목적 : 영업사이클 분석, 월별 추이 분석 등 날짜 기준으로 데이터 가공할 때 필요해서

  • 수집기간 확인 : .min().date()
    - 중괄호 2개 써서 날짜로 보여줘도 되고, 중괄호 하나만 넣고 안에 뺄셈 이용해서 days로 보이는 것도 가능
    - 스터디 과제 1 재구매일 구하던 것처럼
# 수집 기간 : 컬럼.min().date() 적은 최소숫자 컬럼.max().date() 가장 최대숫자

#mql 총 수집기간
print(f" mql 수집기간 : {mql.first_contact_date.min().date()} - {mql.first_contact_date.max().date()}")
print(f" mql 수집기간 : {mql.first_contact_date.max().date() - mql.first_contact_date.min().date()}")

print("---------------------------------")
#closed 계약 성사 총 기간
print(f" closed 계약 성사 총 기간 : {closed.won_date.min().date()} - {closed.won_date.max().date()}")
print(f" closed 계약 성사 총 기간 : {closed.won_date.max().date() - closed.won_date.min().date()}")

기초스터디 과제 때 썼던 min max 기억이 납니다요

 

  • 연/월/분기별로 파생컬럼 생성하기
    - 월별 리드 수 구할 때, 기간별 컬럼 만들어두면 groupby 사용해서 신속하게 활용 가능(분석할 때 자주사용하니 미리 빼놓기)
    - 날짜 만드는 명령어 dt 사용
    - dt.하고 나오는 리스트들 보면서 확인해보기
# 파생컬럼 생성 - 날짜
# 월별 리드 수 구할 때, 파생컬럼 만들어두면 groupby 사용해서 신속하게 활용 가능(분석할 때 자주사용하니 미리 빼놓기)
# 날짜 만드는 명령어 dt

mql['contact_year'] = mql['first_contact_date'].dt.year 
mql['contact_month'] = mql['first_contact_date'].dt.month
mql['contact_quarter'] = mql['first_contact_date'].dt.quarter

closed['won_year'] = closed['won_date'].dt.year
closed['won_month'] = closed['won_date'].dt.month
closed['won_quarter'] = closed['won_date'].dt.quarter

컬럼 생성 완.

더보기

.dt. 리스트

mql['first_contact_date'].dt.day_of_year #몇 번째 일자인지
mql['first_contact_date'].dt.unit 
#Pandas에서 dt.unit은 해당 날짜 데이터(Series)가 어느 정도의 시간 단위(정밀도)로 저장되어 있는지를 나타내는 속성입니다.
#출력된 **'ns'**는 **Nanoseconds(나노초, $10^{-9}$초)**를 의미

#dt.unit이 데이터의 '정밀도'를 보여주는 눈금자였다면, asfreq는 데이터의 '주기(Frequency)'를 강제로 고정하는 도구
#.dt 뒤에 붙여 쓰기보다는, 날짜가 **인덱스(Index)**로 잡혀 있을 때 df.asfreq()
mql['first_contact_date'].dt.asfreq

# <예시>
# 1. 날짜를 인덱스로 설정 (asfreq를 쓰기 위한 필수 단계)
mql_daily = mql.set_index('first_contact_date')

# 2. 일 단위('D')로 주기를 맞춤
# 데이터가 없는 날은 NaN으로 생성됨
mql_asfreq = mql_daily.asfreq('D')

# 3. 비어있는(NaN) 날의 리드 수를 0으로 채우기
mql_filled = mql_daily.asfreq('D', fill_value=0)

# 4. 혹은 직전 데이터를 그대로 가져오기 (Forward Fill)
mql_ffill = mql_daily.asfreq('D', method='ffill')

참고) 주차 설정(외국은 주차가 중요함)

.dt.isocalendar().week.astype(int)

# .dt.isocalendar() calendar날짜로 변경할건데

# .week 주차만 빼오고

# .astype(int) 숫자로 표기해줘

 


[유입채널 정제하기]

유입채널(origin)을 정제해서 표준화하는 작업 진행

 

Step 1 데이터 파악

  • 유입채널데이터 확인 및 파악 작업
# 1. 유입채널 어떻게 생긴지 확인
mql['origin']

# 2. 유입채널 결측치 확인
print(mql['origin'].isnull().sum())

# 3. 유입채널 값별 분포도 확인
mql['origin'].value_counts()

히엑 결측치 60개나 unknow...other..

Step 2. 데이터 정제

  • 고민할 사항
    null값이랑 unknown, other 다 다른거잖아...null값은 unknow, other 조차 없는 건데.. 
  정제 처리항목 내용 함수
1 결측치 처리 결측치 변경 필요 -> 채워주기 fillna
2 unknown 처리 표준화할수있도록  unknown 값을 not_identified로 변경하기 replace
3 other 처리 other, other_publicities 병합 후 이름 지정하기 map 딕셔너리
# 1. fillna ( 결측치 값 not_identified )
mql['origin'] = mql['origin'].fillna('not_identified') # mql origin에 비어있는 값은 not identified로 채워라

# 2. unknown -> not_identified
mql['origin'] = mql['origin'].replace('unknown', 'not_identified' ) # replace 앞의 값을 뒤의 값으로 변경해라 : 앞에있는 값이 원래값 -> 뒤에값으로 변경해라

# 3. 채널 맵핑 : 자잘한 채널 -> 상위 채널로 묶기
# map = 맵핑 (한 번에 여러가지를 바꿔야할 때 사용)
# 맵핑은 꼭 {} 딕셔너리 값으로 지정
# 변경내용 : other, other_publicities -> other / direct_traffic -> direct
channel_map = {
    'organic_search' : 'organic_search',
    'paid_search' : 'paid_search',
    'social' : 'social',
    'not_identified': 'not_identified',
    'direct_traffic' : 'direct',
    'email' : 'email',
    'other' : 'other',
   ' other_publicities' : 'other' }  #map에 없으면 결측치 값으로 설정됨 

mql['origin_clean'] = mql['origin'].map(channel_map).fillna('other')   
# map{}안에 없는 referral , display 값은 아래 명령에서 other 로 fillna 하겠다 
# (맵핑에 없는 값은 결측치 값으로 변환된다음에 fillna로 지정)

결측치 처리 후 0 / unknown 처리 후 결측치+unknown값으로 not_identified 확인 가능

 

1. 결측치 처리 : fillna
2. 결측치 처리 확인 : isnull().sum()
3. unknown 값 처리 : replace('','')
4. unknown 값 처리 확인 : .value_counts()
5. 채널 맵핑 처리 : map{}
6. 채널 맵핑 처리 확인 : .value_counts()

채널 맵핑까지 완료

Step 3. 데이터 가공

  • 데이터 병합 : 데이터.merge
  • 계약 성사 데이터 필터링 : 데이터['컬럼명'].notna()

[데이터 병합]

- mql과 closed를 합쳐서 8000개 잠재고객 중 계약 성사여부 확인하기

- merge : mql_id 기준으로 left join으로 붙이는 함수로, 8000개 행 유지하면서 오른쪽으로 계약성사된 데이터만 값이 붙는 것

# 퍼널 통합 테이블 >> MQL 테이블 + Closed 테이블
funnel = mql.merge(closed, on='mql_id', how='left')        # mql에 합쳐줘(첫번째 값을)

 

[계약 성사 건 추출]

1) 계약 성사 여부 확인

- 계약이 성사된 것과 성사되지 않는 것을 분류하기

- 기준은 계약날짜를 확인하면 계약이 성사된 것을 알 수 있음
- notna() : 결측치가 없다면 True, 있다면 False

# 계약이 성사된 건만 있는 컬럼 생성 (계약날짜를 확인하면 계약이 성사된 것이겠죠)
# notna() : 결측치가 없다면 True, 있다면 False
funnel['is_won'] = funnel['won_date'].notna()   #won_date의 결측치가 없다면 true, 실제 결측치라면 false 값

2) 계약 성사된 리드만 필터링

- 생성된 파생컬럼에서  계약성사된 것만 추출

- 추출한 데이터를 copy해서 새로운 변수에 담아서 데이터 활용

# 계약 성사된 건만 필터링
won = funnel[funnel['is_won'] == True].copy() #funnel의 is_won이 true만 모아서 필터링해서 새로운걸로 copy하고 won 변수에 넣어줘
won

3) 계약 성사 까지 기간 분석

- 계약 성사까지의 최장, 최단, 평균, 중앙값 추출

- 집계함수 4개나 쓸거면 agg로 묶으면 안되나?? =_=..groupby agg 

# 계약 성사까지 얼마나 걸리는 지 알고 싶다!
won['sales_cycle_days'] = (won['won_date'] - won['first_contact_date']).dt.days

print(f"최장 계약 기간 : {won['sales_cycle_days'].max()}일")
print(f"최단 계약 기간 : {won['sales_cycle_days'].min()}일")            #오류발생 오류발생! 음수라니!
print(f"평균 계약 기간 : {won['sales_cycle_days'].mean():.0f}일")
print(f"중앙값 계약 기간 : { won['sales_cycle_days'].median():.0f}일")

음수라니

더보기

※ 응용 : 집계함수 groupby, agg써서 계약일 기간 분석하기

# 여러 집계 함수를 리스트 형태로 전달
stats = won['sales_cycle_days'].agg(['max', 'min', 'mean', 'median']).round(0)
print(stats)

# 채널별로도 비교 가능
# 유입 경로(origin)별로 세일즈 사이클 통계 내기
origin_stats = won.groupby('origin')['sales_cycle_days'].agg(['max', 'min', 'mean', 'median']).round(0)

print(origin_stats)

💥 💥 3-2) 데이터 오류 삭제 💥 💥

계약 성사일 최소값이 -2가 나왔기 때문에 확인 후 오류값은 삭제하도록 합니다.

1. 필터링 확인 : 0보다 작은 값이 있는지 변수에 담아서 확인

2. 삭제 : 0보다 크거나 같은 값으로 조건 설정

3. min()값 재확인

# 계약 성사일이 음수인 데이터 오류 삭제
# -1. 필터링하기
negative_cycle = won[ won['sales_cycle_days'] < 0] 
negative_cycle

# 계약 성사일이 음수인 데이터 오류 삭제
# -2. won에 >= 0 으로 설정
won = won[won['sales_cycle_days'] >= 0]

이 자식이..!
min값 다시 확인해보니 0으로 나옵니다 끝.

Step 4. 데이터 시각화

[영업 사이클 데이터 시각화]

  • 도화지 생성 : fig, ax
  • axvline : 세로축 선 생성 함수
    - 표에서 중앙값이 어느쯤인지 알고 싶어서 추가
    - axvline의 label과 ax.legend()는 세트로 axvline에서 지정한 label은 ax.lengend()로 출력됨
  • bins로 그래프의 빈도 구간을 좁게 설정해서 좀 더 가시적으로 출력하기
# 영업 사이클 분포

# -1. 도화지 생성
fig, ax = plt.subplots(figsize=(10,5) )

# -2. hist 차트 만들 것 - won['sales_cycle_days']
ax.hist( won['sales_cycle_days'] , bins=50)                                     # bins로 구간나눠서 가시적으로
ax.axvline( won['sales_cycle_days'].median(), color='red', linestyle='--', label=f"중앙값 : {won['sales_cycle_days'].median():.0f}" )   # 세로축에 중앙값 넣고 싶음

ax.set_title("계약 성사까지 걸린 기간(일수)")
ax.set_xlabel("일수")
ax.set_ylabel("건수")
ax.legend()

plt.show()

bins 추가해서 구간 좁혀주고, axvline 추가해서 중앙값 표시하고, 중앙값 라벨까지 출력하니 좀 더 그럴듯해집니다

 

Step 5. 데이터 분석 (마케팅 지표 집계)

[ 유입채널별 리드 전환율 분석 ]

1) 총 리드수, 성사된 리드수 추출

- 채널별 전환율 집계를 위해 전체 몇 명의 잠재고객(leads)가 있는지, 그 중 성사된 건은 몇 건인지 확인하기

- 추출 항목 : 총 리드수, 성사된 리드수 

- 사용 함수 : group by, agg 

- .reset_index()로 라벨 정리

# 1. 채널별 전환율 집계
# 1-1. 총 리드수와 그 중 실제 성사된 리드수만 추출하기

channel_funnel = funnel.groupby('origin_clean').agg(    # 위에서 채널 맵핑해줬더 origin_clean 쓰기
    total_leads = ('mql_id', 'count'),                  # 총 리드수 중에
    won_leads = ('is_won', 'sum'))                      # 실제 성사된 수는 몇 개인지

channel_funnel = channel_funnel.reset_index()           # 라벨정리

2) 전환율 파생컬럼 생성

- 전환율 공식 대입 = (성사된 리드수( won_leads ) / 총 리드수 ( total_leads) ) * 100 

# 전환율 파생 컬럼 옆에다가 만들기
channel_funnel['conversion_rate'] = ((channel_funnel['won_leads'] / channel_funnel['total_leads']) * 100).round(2)
channel_funnel

 

3) 채널별 평균 영업 사이클 파생컬럼 생성

- 2가지 방법 : agg / 바로 컬럼명만 꺼내는 방법

- 컬럼에서 바로 꺼낼 땐, rename(columns={ : })으로 이름 지정하기

# 1. won.groupby : 성공한 테이블인 won에서, 

# 2. ('origin_clean') : 채널인 origin_clean 기준으로,

# 3. avg_cycle_days = : 평균 영업 사이클 이름 지정하고

# 4. ('sales_cycle_days', 'mean') : sales_cycle_days의 중앙값mean을 넣어라

# 채널별 평균 영업 사이클 추가
# 성공한 테이블 won / sales_cycle_days / mean() / avg_cycle_days / cycle_by_channel 테이블 이름
# won : 성사된 채널로 묶기
# 영업 사이클 .mean 넣기 
# 테이블명 : cycle_by_channel
cycle_by_channel = won.groupby('origin_clean').agg(
    avg_cycle_days = ('sales_cycle_days', 'mean')
).round(0)

cycle_by_channel = cycle_by_channel.reset_index()
cycle_by_channel

# 강사님 해설 : agg로 해도 되는데 1개만 있으니까 이런식의 코드로 빠르게 확인 가능
#cycle_by_channel = won.groupby('origin_clean')['sales_cycle_days'].mean().round(0).reset_index().rename(columns={'sales_cycle_days' : 'avg_cycle_days'})

1개만 구하는 건 빠르게 컬럼명만 빼도 괜찮네

4) 1,2번의 채널퍼널과 3번의 채널별 영업 사이클 병합

- merge 사용해서 병합하고 on=, how= 잊지말기

# 데이터 병합 : chnnel_funnel = channel_funnel + cycle_by_channel left join merge
# on= 기준
# how= left 잊지말기
channel_funnel = channel_funnel.merge(cycle_by_channel, on='origin_clean', how='left')
# 전환율 기준으로 정렬하기
channel_funnel = channel_funnel.sort_values('conversion_rate', ascending=False)
channel_funnel
💡4번 테이블로 확인 가능한 내용
- organic search는 total leads는 많은데 전환이 낮다 = 광고태워서 리드 많이 가져왔는데 전환율이 낮다는 것
  심지어 사이클도 길다. 이는, 질나쁜 리드들이 들어오는 것 같으므로 예산을 줄이는 게 나을 것 같다
- direct 채널은 mql리드의 수는 적은데 전환율이 높으면 질이 좋은 애들이니 조금 예산 높여서 진행해도 될듯 하다

merge할 때 on이랑 how 까먹어서 재실행했더니 컬럼 두개 생겨버

5) 채널별 전환율 시각화

- 리드의 질이 낮은 채널 분석하기 = MQL 수가 높은데 전환율은 낮은 채널

- 시각화 주제 : MQL 수, 전환율

# 채널별 전환율 시각화 / MQL 수, 전환율
fig, axes = plt.subplots(1, 2, figsize =(14,5))

# 채널별 전환율 : y열 전환율 (가로막대는 y축 써주고, x축 써야함)
axes[0].barh( channel_funnel['origin_clean'], channel_funnel['conversion_rate'], color='coral')
axes[0].set_title("채널별 리드 전환율 %")

# MQL 수
axes[1].barh( channel_funnel['origin_clean'], channel_funnel['total_leads'])
axes[1].set_title("채널별 MQL 수")

plt.show()

 

[ SDR 담당자별 성과 분석 ]

담당자 그룹별로 집계해서 평균 영업성사일도 확인해보고 우수영업사원도 파악해서 영업전략 수립하기

 

- sdr_id = 실제 영업사원들의 ID
- 담당자가 얼마나 많은 mql만나서 성사시켰는지 :  mql_id, count 
- 담당자별 평균 영업 사이클 : sales_cycle_days, mean 

#sdr_id = 실제 영업사원들의 ID
won.info()

# SDR 성과분석
# 담당자가 얼마나 많은 mql만나서 성사시켰는지 / 영업사이클일수 평균
# = mql_id, count / sales_cycle_days, mean 
sdr_perf = won.groupby('sdr_id').agg(
    won_count = ('mql_id', 'count'),
    avg_cycle = ('sales_cycle_days', 'mean'),
    median_cycle = ('sales_cycle_days', 'median')
).round(0).reset_index()

#숫자 정리
sdr_perf = sdr_perf.sort_values('won_count', ascending=False)

13번분 평균 영업사이클인 14일보다도 중앙값이 적고, 140명이나 성사시키고 아주 잘하시네요

 

💡최종 전처리 결과 요약 !
순서 작업 내용 핵심 메서드
01 두 파일 기본 정보 및 결측치 구조 파악 .isnull().sum(), .describe()
02 날짜 컬럼 변환 및 파생 컬럼 생성 pd.to_datetime(), .dt.quarter
03 유입 채널(origin) 정제 및 표준화 .fillna(), .replace(), .map()
04 두 파일 LEFT JOIN 퍼널 통합 .merge(how='left'), notna()
05 영업 사이클 기간 계산 .dt.days, 음수 오류 탐지
06 채널별 전환율 분석 .groupby().agg(), 이중 축 시각화
07 업종 결측치 처리 및 분포 분석 .fillna(), value_counts()
08 리드 행동 프로파일 분석 조건 필터링, .notna()
11 SDR 담당자별 성과 분석 .groupby().agg(), 산점도

 

더보기

야간 스터디 과제하고나니까 본 수업의 이해도가 조금 올라간 것 같다.

똑같은 내용을 3번 보면 이해하기 시작한다는데

음 반복학습이 답이구나...

 

오늘 내용이 엄청 많은 느낌인데 막상 한 게 그렇게나 많은 것 같진 않고 (= 과제할 때랑 똑같네!)