02. A/B ํ ์คํ (Experiments)
1. ๊ฐ์ ๋ฐ ์๋๋ฆฌ์ค
์ํฉ: ๋ง์ผํ ํ์ด ์๋ก์ด ๋๋ฉ ํ์ด์ง(B์)๋ฅผ ๋ง๋ค์์ต๋๋ค. โ๊ธฐ์กด ํ์ด์ง(A์)๋ณด๋ค ์ ํ์จ์ด 2% ์ฌ๋์ด์!โ๋ผ๊ณ ๊ธฐ๋ปํฉ๋๋ค.
ํ์ง๋ง ๋น์ ์ ์นจ์ฐฉํ๊ฒ ๋ฌป์ต๋๋ค.
โํ๋ณธ ์๋ ๋ช ๋ช ์ด์๋์? ๊ทธ 2% ์์น์ด ์ฐ์ฐ์ผ ํ๋ฅ (P-value)์ ์ผ๋ง์ธ๊ฐ์?โ
A/B ํ ์คํธ๋ ๋น์ฆ๋์ค ์์ฌ๊ฒฐ์ ์ ๊ฝ์ ๋๋ค. ์ด๋ฒ ์ฑํฐ์์๋ **๋น์จ ๊ฒ์ (Proportions Z-test)**๊ณผ **ํ๋ณธ ํฌ๊ธฐ ๊ณ์ฐ(Power Analysis)**์ ๋ฐฐ์๋๋ค.
2. ๋ฐ์ดํฐ ์ค๋น
A/B ํ ์คํธ ๋ก๊ทธ ๋ฐ์ดํฐ๊ฐ ์์ผ๋ฏ๋ก, ๊ธฐ์กด ๋ฐ์ดํฐ์์ ์ฑ๋ณ์ A/B ๊ทธ๋ฃน์ด๋ผ๊ณ ๊ฐ์ ํ๊ณ ์๋ฎฌ๋ ์ด์ ํด๋ด ๋๋ค.
- Group A: ๋จ์ฑ (Male)
- Group B: ์ฌ์ฑ (Female)
- Conversion: ๊ตฌ๋งค ์ฌ๋ถ (์ฃผ๋ฌธ ์ด๋ ฅ์ด ์์ผ๋ฉด 1, ์์ผ๋ฉด 0)
BigQuery (SQL)
from statsmodels.stats.proportion import proportions_ztest
import numpy as np
# ... BigQuery client setup3. ๋น์จ ๊ฒ์ (Proportions Z-test)
์ ํ์จ(Conversion Rate)๊ณผ ๊ฐ์ ๋น์จ์ ๋น๊ตํ ๋ ์ฌ์ฉํฉ๋๋ค.
โ ๋ฌธ์ 1: ๊ทธ๋ฃน ๊ฐ ์ ํ์จ ๋น๊ต
Q. ๋จ์ฑ๊ณผ ์ฌ์ฑ์ ๊ตฌ๋งค ์ ํ์จ(์ ์ฒด ๊ฐ์ ์ ์ค ๊ตฌ๋งค์ ๋น์จ)์ ๊ตฌํ๊ณ , ๋ ๋น์จ์ ์ฐจ์ด๊ฐ ์ ์๋ฏธํ์ง ๊ฒ์ ํ์ธ์.
BigQuery + Python
Hint: COUNT(DISTINCT user_id)๋ก ์ ์ฒด ๋ชจ์๋ฅผ ๊ตฌํ๊ณ , LEFT JOIN orders๋ก ๊ตฌ๋งค์๋ฅผ ์
๋๋ค.
์ ๋ต ์ฝ๋ ๋ณด๊ธฐ
# 1. ๋ฐ์ดํฐ ์ง๊ณ
query = """
SELECT
u.gender,
COUNT(DISTINCT u.user_id) as total_users,
COUNT(DISTINCT o.user_id) as purchasers
FROM `your-project-id.retail_analytics_us.src_users` u
LEFT JOIN `your-project-id.retail_analytics_us.src_orders` o ON u.user_id = o.user_id
GROUP BY u.gender
"""
df = client.query(query).to_dataframe().set_index('gender')
# 2. ํต๊ณ๋ ์ถ์ถ (์ฑ๊ณต ํ์, ์ํ ํ์)
count = df['purchasers'].values # [๋จ์ฑ๊ตฌ๋งค์์, ์ฌ์ฑ๊ตฌ๋งค์์]
nobs = df['total_users'].values # [๋จ์ฑ์ ์ฒด, ์ฌ์ฑ์ ์ฒด]
# 3. Z-test
z_stat, p_val = proportions_ztest(count, nobs)
print(f"๋จ์ฑ ์ ํ์จ: {count[0]/nobs[0]:.4f}")
print(f"์ฌ์ฑ ์ ํ์จ: {count[1]/nobs[1]:.4f}")
print(f"P-value: {p_val:.4f}")
if p_val < 0.05:
print("โ
์ ํ์จ ์ฐจ์ด๊ฐ ์ ์๋ฏธํฉ๋๋ค.")Error: name 'client' is not defined
4. ํ๋ณธ ํฌ๊ธฐ ๊ณ์ฐ (Sample Size & Power)
ํ ์คํธ๋ฅผ ํ๊ธฐ ์ ์ ๊ฐ์ฅ ๋จผ์ ํด์ผ ํ ์ง๋ฌธ์ ๋๋ค.
โ๋ช ๋ช ํํ ์คํํด์ผ ๋ฏฟ์ ๋งํ ๊ฒฐ๊ณผ๊ฐ ๋์ค๋์?โ
๋๋ฌด ์ ์ผ๋ฉด ํจ๊ณผ๊ฐ ์์ด๋ ๋ชป ์ฐพ๊ณ (False Negative), ๋๋ฌด ๋ง์ผ๋ฉด ๋ ๋ญ๋น์ ๋๋ค.
โ ๋ฌธ์ 2: ํ์ํ ํ๋ณธ ์ ๊ณ์ฐ
Q. ํ์ฌ ์ ํ์จ์ด 10%๋ผ๊ณ ํ ๋, ์ด๋ฅผ 11%๋ก ๊ฐ์ (1%p ์์น)ํ๋ ๊ฒ์ ๊ฐ์งํ๋ ค๋ฉด ๊ทธ๋ฃน๋น ๋ช ๋ช ์ด ํ์ํ๊ฐ์? (์ ์์์ค , ๊ฒ์ ๋ ฅ Power=0.8 ๊ธฐ์ค)
Python (Common)
Hint: statsmodels.stats.power.NormalIndPower๋ฅผ ์ฌ์ฉํ๊ฑฐ๋ proportion_effectsize๋ฅผ ๊ตฌํด์ผ ํฉ๋๋ค.
์ ๋ต ์ฝ๋ ๋ณด๊ธฐ
import statsmodels.stats.api as sms
from statsmodels.stats.proportion import proportion_effectsize
# 1. ํจ๊ณผ ํฌ๊ธฐ(Effect Size) ๊ณ์ฐ
p1 = 0.10 # ๊ธฐ์กด
p2 = 0.11 # ๋ชฉํ
effect_size = proportion_effectsize(p1, p2)
# 2. ํ๋ณธ ์ ๊ณ์ฐ
required_n = sms.NormalIndPower().solve_power(
effect_size=effect_size,
power=0.8,
alpha=0.05,
ratio=1
)
print(f"ํ์ํ ๊ทธ๋ฃน๋น ํ๋ณธ ํฌ๊ธฐ: {int(np.ceil(required_n))}๋ช
")
print(f"์ด ํ์ ํ๋ณธ ํฌ๊ธฐ: {int(np.ceil(required_n)) * 2}๋ช
")ํ์ํ ๊ทธ๋ฃน๋น ํ๋ณธ ํฌ๊ธฐ: 14745๋ช ์ด ํ์ ํ๋ณธ ํฌ๊ธฐ: 29490๋ช
๐ก ํ๋ผ๋ฏธํฐ ์ค๋ช
- Alpha (): 1์ข ์ค๋ฅ ํ๋ฅ (๋ณดํต 0.05). โํจ๊ณผ ์๋๋ฐ ์๋ค๊ณ ํ ํ๋ฅ โ
- Power (): ๊ฒ์ ๋ ฅ (๋ณดํต 0.8). โํจ๊ณผ๊ฐ ์์ ๋ ์ง์ง ์ฐพ์ ํ๋ฅ โ
- Effect Size: ๊ฐ์งํ๊ณ ์ ํ๋ ์ฐจ์ด์ ํฌ๊ธฐ (ํด์๋ก ์ ์ ํ๋ณธ์ผ๋ก๋ ์ฐพ์)
5. ์คํ ์ค๊ณ ์ ์ฃผ์์ฌํญ (Common Pitfalls)
์ฝ๋ฉ๋งํผ ์ค์ํ ๊ฒ์ด ์ค๊ณ์ ๋๋ค.
-
Peeking Problem (์ฟ๋ณด๊ธฐ):
- ์คํ ๋์ค์ โ์ด? P-value 0.04๋ค์, ๋ฉ์ถฅ์๋ค!โ๋ผ๊ณ ํ๋ฉด ์ ๋ฉ๋๋ค.
- ์ฌ์ ์ ์ ํ ํ๋ณธ ์(N)๋ฅผ ์ฑ์ธ ๋๊น์ง ๊ธฐ๋ค๋ ค์ผ ํฉ๋๋ค.
-
SRM (Sample Ratio Mismatch):
- 50:50์ผ๋ก ๋๋ด๋๋ฐ, ๊ฒฐ๊ณผ๊ฐ 1000๋ช vs 950๋ช ์ด๋ค?
- ํธ๋ํฝ ํ ๋น ์์คํ ์ ๋ฒ๊ทธ๊ฐ ์๊ฑฐ๋, ํน์ ๊ทธ๋ฃน์์ ๋ฐ์ดํฐ ๋๋ฝ์ด ๋ฐ์ํ ๊ฒ์ ๋๋ค.
- ํ ์คํธ ๊ฒฐ๊ณผ ๋ฌดํจ!
๐ก ์์ฝ
- ๋น์จ ๊ฒ์ : ํด๋ฆญ๋ฅ , ์ ํ์จ ๋ฑ 0/1 ๋ฐ์ดํฐ ๋น๊ต
- Power Analysis: ์คํ ์์ ์ ํ์ ๋จ๊ณ (โ๋ช ๋ช ํ์ํด?โ)
- A/B ํ ์คํธ๋ ๊ณผํ์ ๋๋ค: ๊ฐ(Feeling)์ด ์๋๋ผ ๋ฐ์ดํฐ๋ก ์์ฌ๊ฒฐ์ ํ์ธ์.
๋ค์ ์ฑํฐ์์๋ ์๊ด๊ด๊ณ์ ํ๊ท๋ถ์์ ํตํด ๋ณ์ ๊ฐ์ ์จ์ ๊ด๊ณ๋ฅผ ์ฐพ์๋ด ๋๋ค.