- A/B 테스트란 무엇인가
- A/B 테스트를 하기 전에 분명하게 해야 할 질문
- 목표 지표 선택
- 테스트의 가설
- 테스트 디자인(power analysis)
- 샘플 사이즈, 실험기간 계산
- 통계적 테스트 (t-test, z-test, 카이제곱 검정)
- 파이썬으로 A/B 테스트 결과 분석
- 부트스트랩
- 유의성(통계적vs실용적)
- A/B테스트의 품질(신뢰도, 타당도, 효능)
- A/B 테스트에서 흔한 문제
- 윤리, 개인정보 보호 A/B 테스트
1. 목표 지표(Success Metric) 선택
- 모든 지표들이 일정하게 유지된다는 가정 하에, 해당 지표가 크게 증가하면 목표를 달성했다고 볼 수 있나(문제를 해결했나)??
- but, 하나의 목표 지표만 바라보면 안되고 나머지 지표들엔 어떤 변화가 있는지 모니터링할 필요가 있다.
- Sub Metric : 메인 지표를 보조할 수 있는 지표
- Guardrail Metric : 떨어지면 안되는 지표(서비스에 악영향을 줬는지 파악 용도)
- 보편적으로 많이 보는 A/B 테스트 지표
- CTR
- CTP
- CVR
2. 테스트의 가설
- 가설의 기본 컨셉은 KPI에 영향을 미치는 제품의 잠재적 문제를 ‘fix’할 수 있는가
- 가설의 예
- KPI가 추천 시스템의 품질 개선. 기존 추천시스템에 XGBoost Re-ranker 모델 추가시 추천의 CTR이 증가할 것. 추천 품질이 향상 될 것이다. 측정 지표 : CTR
3. A/B 테스트 디자인
- 통계적 가설
- 검증력 분석(Power Analysis)
- 모집단에 대해 일반화할 수 있는지 확인을 할 수 있도록 ‘충분한’ 양의 데이터를 수집해야함
- Power Analysis의 3가지 단계 : 검정력(power), 유의수준 결정, MDE(Minimum Detectable Effect)
- 검정력 : 귀무가설을 올바르게 기각할 확률(거짓일 때 거짓으로 판단) = 1-beta = 2종 오류를 범하지 않을 확률 ; 일반적으로 80%로 설정함
- 유의수준(Significance level) : 1종 오류의 확률로 일반적으로 0.05 사용 (비효율적인 기능에 대한 리소스 낭비를 방지하는 차원에서 유의수준을 엄격하게 정하기도 함)
- MDE : 비즈니스 관점에서 투자 가치가 있다고 판단할 수 있는 최소 효과크기는 어느정도인지
- 최소 샘플 사이즈 계산
이진 지표(Binary Metric) : 베르누이 시행을 따르기 때문에, 이항분포를 따른다고 본다
연속 지표(Continuous Metric) : 연속 지표의 평균의 샘플링은 중심극한정리에 따라 정규분포를 따른다고 본다
- 기간
- 일반적으로 앞에 고려한 최소 샘플 사이즈를 하루 방문자수로 나누어서 계산. 그러나 비즈니스 측면 고려 필요. 만약 코로나처럼 큰 external event 발생시, 실험 결과가 부정확해질 수 있음
- 너무 짧으면 → Novelty Effect : 사용자는 초기 변화에 긍정적으로 반응하곤 한다. 시간이 지남에 따라 사라질 수 있다. A/B 테스트 외적 타당성에 영향을 줄 수 있음.
- 너무 길면 → Maturation Effect : 너무 길어지면, 사용자의 반응이 다른 외부적 요인들에 의해 달라질 수 있다.
- A/B 테스트 실행
- 실험군, 대조군 사이에 무결성이 유지됬는지 보증 필요(engineering)
- 최소 샘플 크기에 도달하지 않은 상태에서 통계적 유의성을 판단하면 안됨 (early stopping)
4. 파이썬으로 A/B 테스트 결과 분석
-통계 분석방법 선택, 계산
-유의성 확인(p-value)
-오차한계 계산(실험의 외부 유효성), 신뢰구간 계산(외적 타당도, 실제 유의성)
- 분석방법에 대한 결정은 다음과 같은 요소에 따라 달라짐 :
- 목표 지표의 형태 (PDF ; 연속 확률변수의 분포가 어떻게 되는지)
- 샘플 사이즈
- 통계적 가설의 성격
- 모수적 방법 : 2 Sample T-test, 2 Sample Z-test
- 비모수적 방법 : Fisher Exact test, Chi-Squared test, Wilcoxon Rank Sum/Mann Whitney test(Skewed 샘플링 분포, 중앙값 비교)
T-test code
import numpy as np from scipy.stats import t N_con = 20 df_con = N_con - 1 # degrees of freedom of Control N_exp = 20 df_exp = N_exp - 1 # degrees of freedom of Experimental # Significance level alpha = 0.05 # data of control group with t-distribution X_con = np.random.standard_t(df_con,N_con) # data of experimental group with t-distribution X_exp = np.random.standard_t(df_exp,N_exp) # mean of control mu_con = np.mean(X_con) # mean of experimental mu_exp = np.mean(X_exp) # variance of control sigma_sqr_con = np.var(X_con) #variance of control sigma_sqr_exp = np.var(X_exp) # pooled variance pooled_variance_t_test = ((N_con-1)*sigma_sqr_con + (N_exp -1) * sigma_sqr_exp)/(N_con + N_exp-2)*(1/N_con + 1/N_exp) # Standard Error SE = np.sqrt(pooled_variance_t_test) # Test Statistics T = (mu_con-mu_exp)/SE # Critical value for two sided 2 sample t-test t_crit = t.ppf(1-alpha/2, N_con + N_exp - 2) # P-value of the two sided T-test using t-distribution and its symmetric property p_value = t.sf(T, N_con + N_exp - 2)*2 # Margin of Error margin_error = t_crit * SE # Confidence Interval CI = [(mu_con-mu_exp) - margin_error, (mu_con-mu_exp) + margin_error] print("T-score: ", T) print("T-critical: ", t_crit) print("P_value: ", p_value) print("Confidence Interval of 2 sample Z-test: ", np.round(CI,2))
Z-test(비율 비교) code
import numpy as np from scipy.stats import norm X_con = 1242 #clicks control N_con = 9886 #impressions control X_exp = 974 #clicks experimental N_exp = 10072 #impressions experimetal # Significance Level alpha = 0.05 p_con_hat = X_con / N_con p_exp_hat = X_exp / N_exp p_pooled_hat = (X_con + X_exp)/(N_con + N_exp) pooled_variance = p_pooled_hat*(1-p_pooled_hat) * (1/N_con + 1/N_exp) # Standard Error SE = np.sqrt(pooled_variance) # test statsitics Test_stat = (p_con_hat - p_exp_hat)/SE # critical value usig the standard normal distribution Z_crit = norm.ppf(1-alpha/2) # Margin of error m = SE * Z_crit # two sided test and using symmetry property of Normal distibution so we multiple with 2 p_value = norm.sf(Test_stat)*2 # Confidence Interval CI = [(p_con_hat-p_exp_hat) - SE * Z_crit, (p_con_hat-p_exp_hat) + SE * Z_crit] if np.abs(Test_stat) >= Z_crit: print("reject the null") print(p_value) print("Test Statistics stat: ", Test_stat) print("Z-critical: ", Z_crit) print("P_value: ", p_value) print("Confidence Interval of 2 sample Z-test for proportions: ", np.round(CI,2)) import matplotlib.pyplot as plt z = np.arange(-3,3, 0.1) plt.plot(z, norm.pdf(z), label = 'Standard Normal Distribution',color = 'purple',linewidth = 2.5) plt.fill_between(z[z>Z_crit], norm.pdf(z[z>Z_crit]), label = 'Right Rejection Region',color ='y' ) plt.fill_between(z[z<(-1)*Z_crit], norm.pdf(z[z<(-1)*Z_crit]), label = 'Left Rejection Region',color ='y' ) plt.title("Two Sample Z-test rejection region") plt.legend() plt.show()
Z-test(평균 비교) code
import numpy as np from scipy.stats import norm N_con = 60 N_exp = 60 # Significance Level alpha = 0.05 X_A = np.random.randint(100, size = N_con) X_B = np.random.randint(100, size = N_exp) # Calculating means of control and experimental groups mu_con = np.mean(X_A) mu_exp = np.mean(X_B) variance_con = np.var(X_A) variance_exp = np.var(X_B) # Pooled Variance pooled_variance = np.sqrt(variance_con/N_con + variance_exp/N_exp) # Test statistics T = (mu_con-mu_exp)/np.sqrt(variance_con/N_con + variance_exp/N_exp) # two sided test and using symmetry property of Normal distibution so we multiple with 2 p_value = norm.sf(T)*2 # Z-critical value Z_crit = norm.ppf(1-alpha/2) # Margin of error m = Z_crit*pooled_variance # Confidence Interval CI = [(mu_con - mu_exp) - m, (mu_con - mu_exp) + m] print("Test Statistics stat: ", T) print("Z-critical: ", Z_crit) print("P_value: ", p_value) print("Confidence Interval of 2 sample Z-test for proportions: ", np.round(CI,2)) import matplotlib.pyplot as plt z = np.arange(-3,3, 0.1) plt.plot(z, norm.pdf(z), label = 'Standard Normal Distribution',color = 'purple',linewidth = 2.5) plt.fill_between(z[z>Z_crit], norm.pdf(z[z>Z_crit]), label = 'Right Rejection Region',color ='y' ) plt.fill_between(z[z<(-1)*Z_crit], norm.pdf(z[z<(-1)*Z_crit]), label = 'Left Rejection Region',color ='y' ) plt.title("Two Sample Z-test rejection region") plt.legend() plt.show()
카이제곱 검정 code
import numpy as np from scipy.stats import chi2 O = np.array([86, 83, 5810,3920]) T = np.array([105,65,5781, 3841]) # Squared_relative_distance def calculate_D(O,T): D_sum = 0 for i in range(len(O)): D_sum += (O[i] - T[i])**2/T[i] return(D_sum) D = calculate_D(O,T) p_value = chi2.sf(D, df = 1) import matplotlib.pyplot as plt # Step 1: pick a x-axis range like in case of z-test (-3,3,0.1) d = np.arange(0,5,0.1) # Step 2: drawing the initial pdf of chi-2 with df = 1 and x-axis d range we just created plt.plot(d, chi2.pdf(d, df = 1), color = "purple") # Step 3: filling in the rejection region plt.fill_between(d[d>D], chi2.pdf(d[d>D], df = 1), color = "y") # Step 4: adding title plt.title("Two Sample Chi-2 Test rejection region") # Step 5: showing the plt graph plt.show()
- 비모수검정은 표준오차, 신뢰구간등에 대한 계산이 간단하지 않음. 두집단의 평균차를 부트스트랩 기법을 통해 신뢰구간을 검증해볼 수 있음. 이 때, 부트스트랩은 추정량의 오차 범위를 파악하기 위해 사용하는 방법임.
부트스트랩 code
import numpy as np import pandas as pd from scipy.stats import norm N = 100 X = pd.Series(np.random.binomial(300,0.7,size =N)) Y1 = np.repeat("Exp",N/2) N_exp = len(Y1) Y2 = np.repeat("Cont",N/2) N_con = len(Y2) Y = pd.Series(np.append(Y1,Y2)) data = pd.concat([X,Y],axis = 1) print(data) means_per_group = data.groupby(1, group_keys = False)[0].mean() medians_per_group = data.groupby(1)[0].median() alpha = 0.05 def Bootrapping_for_diff_means_medians(data,B): boot_mean_diff = [] boot_medians_diff = [] boot_means_con = [] boot_means_exp = [] count_num_positives_meandiff = 0 count_num_positives_mediandiff = 0 for i in range(B): boot_sample = data.sample(frac = 1, replace = True) #means of bootstrap sample for control and experimental group boot_means_per_group = boot_sample.groupby(1)[0].mean() boot_sample_mean_con = boot_means_per_group["Cont"] boot_sample_mean_exp = boot_means_per_group["Exp"] boot_means_con.append(boot_sample_mean_con) boot_means_exp.append(boot_sample_mean_exp) # calculating the difference in means per bootstrap sample diff_means = boot_sample_mean_exp - boot_sample_mean_con #counting number of times is the difference positive if diff_means > 0: count_num_positives_meandiff += 1 # medians of bootstrap sample for control and experimental group boot_medians_per_group = boot_sample.groupby(1)[0].median() # calculating the difference in medians per bootstrap sample diff_medians = boot_medians_per_group["Exp"] - boot_medians_per_group["Cont"] if diff_medians > 0: count_num_positives_mediandiff += 1 boot_mean_diff.append(diff_means) boot_medians_diff.append(diff_medians) return(boot_means_con,boot_means_exp,count_num_positives_meandiff,count_num_positives_mediandiff,boot_mean_diff) B = 10000 X_bars_con,X_bars_exp ,n_means, n_medians,boot_mean_diff = Bootrapping_for_diff_means_medians(data,B) **Z_mean = np.mean(X_bars_exp)- np.mean(X_bars_con) Z_sigma = np.sqrt((np.var(X_bars_exp)/N_exp + np.var(X_bars_con)/N_con)) CI = [Z_mean - norm.ppf(1-alpha/2)*Z_sigma, Z_mean + norm.ppf(1-alpha/2)*Z_sigma]** print("Mean of X_bar_exp - X_bar_con", Z_mean) print("Standard Error of X_bar_exp - X_bar_con", Z_sigma) print("CI of X_bar_exp - X_bar_con", CI) p_value_diff_means = n_means/B p_value_diff_medians = n_medians/B CI = np.percentile(boot_mean_diff, [2.5, 97.5]) import matplotlib.pyplot as plt counts,bins,ignored = plt.hist(boot_mean_diff,50,density = True,color = 'purple') plt.xlabel("mean difference") plt.title("Distribution of Bootstrapped samples mean difference") plt.show()
5. 통계적 유의성 vs 실제적 유의성
- 통계적 유의성만으로는 기능, 제품 출시에 대한 의사결정을 만들기에 충분하지 않음.
- 따라서 두 그룹간의 차이(성과)가 투자를 정당화할만큼 충분히 큰지 이해해야함.
- 신뢰구간의 하한과 MDE(경제적 유의성 추정치)를 비교. 예를 들어 CI = [5%, 7.5%] MDE = 3% 이면 5% > 3% 이니까 실용적인 의미가 있음.
- CI의 너비가너무 넓으면 결과의 정확도가 낮음
next step
Share article