이미지 로딩 중...

A/B 테스트 통계 분석 시스템 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 16. · 5 Views

A/B 테스트 통계 분석 시스템 완벽 가이드

데이터 기반 의사결정의 핵심인 A/B 테스트를 Polars와 Python으로 구축하는 방법을 배웁니다. 통계적 유의성 검정부터 실시간 모니터링까지 실무에서 바로 활용할 수 있는 완전한 분석 시스템을 만들어봅니다.


목차

  1. A/B 테스트 데이터 수집 및 전처리
  2. 통계적 유의성 검정 (T-test와 Chi-square)
  3. 신뢰구간 계산 및 시각화
  4. 베이지안 A/B 테스트 분석
  5. Sequential Testing과 조기 종료
  6. 세그먼트별 이질적 효과 분석 (HTE)
  7. 실시간 대시보드 구축
  8. 검정력 분석 및 샘플 크기 계산
  9. 다중 검정 보정 (Multiple Testing Correction)
  10. 생존 분석 및 시간 종속 지표 (Time-to-Event)

1. A/B 테스트 데이터 수집 및 전처리

시작하며

여러분이 새로운 기능을 출시하고 사용자 반응을 측정할 때 이런 상황을 겪어본 적 있나요? 수백만 건의 사용자 이벤트 로그가 쌓여있지만, 어떤 그룹이 실험군이고 어떤 지표를 봐야 할지 막막한 상황.

이런 문제는 실제 데이터 분석 현장에서 자주 발생합니다. 원시 데이터는 중복, 결측치, 이상치로 가득하고, 데이터 형식도 제각각입니다.

이를 제대로 정제하지 않으면 잘못된 결론을 내릴 수 있습니다. 바로 이럴 때 필요한 것이 Polars를 활용한 고성능 데이터 전처리입니다.

Pandas보다 10배 이상 빠른 속도로 대용량 A/B 테스트 데이터를 효율적으로 정제할 수 있습니다.

개요

간단히 말해서, 이 개념은 A/B 테스트 원시 데이터를 분석 가능한 형태로 변환하는 과정입니다. 실무에서 A/B 테스트 데이터는 여러 소스에서 수집되며, 타임스탬프 불일치, 사용자 중복 참여, 실험군 할당 오류 등 다양한 문제를 포함합니다.

예를 들어, 같은 사용자가 A와 B 그룹 모두에 노출되었다면 이는 데이터 오염을 의미하며 분석 결과를 왜곡시킵니다. 기존에는 Pandas로 순차적으로 데이터를 처리했다면, 이제는 Polars의 lazy evaluation과 병렬 처리로 10배 빠르게 처리할 수 있습니다.

핵심 특징은 (1) 지연 평가를 통한 쿼리 최적화, (2) 멀티코어 병렬 처리, (3) Apache Arrow 기반 메모리 효율성입니다. 이러한 특징들이 대용량 실험 데이터를 실시간으로 처리할 수 있게 해줍니다.

코드 예제

import polars as pl
from datetime import datetime, timedelta

# A/B 테스트 원시 데이터 로드 및 전처리
def preprocess_ab_test_data(data_path: str) -> pl.DataFrame:
    # Lazy 모드로 데이터 스캔 - 메모리 효율적
    df = pl.scan_csv(data_path)

    # 전처리 파이프라인 구성
    cleaned_df = (
        df.filter(
            # 결측치 제거
            pl.col("user_id").is_not_null() &
            pl.col("variant").is_in(["A", "B"]) &
            # 이상치 제거 - 전환율 0~1 범위
            pl.col("conversion").is_between(0, 1)
        )
        .with_columns([
            # 타임스탬프 파싱
            pl.col("timestamp").str.strptime(pl.Datetime, "%Y-%m-%d %H:%M:%S"),
            # 수치형 변환
            pl.col("revenue").cast(pl.Float64).fill_null(0)
        ])
        # 사용자별 첫 번째 노출만 유지 (중복 제거)
        .sort("timestamp")
        .unique(subset=["user_id"], keep="first")
    ).collect()  # 실행

    return cleaned_df

설명

이것이 하는 일: A/B 테스트 원시 로그 데이터를 읽어들여 통계 분석에 적합한 깨끗한 데이터셋으로 변환합니다. 첫 번째로, scan_csv로 데이터를 lazy 모드로 로드합니다.

이렇게 하면 전체 데이터를 메모리에 올리지 않고 쿼리 플랜만 구성하므로, 수십 GB의 대용량 파일도 효율적으로 처리할 수 있습니다. Polars는 이후 최적화된 실행 계획을 자동으로 생성합니다.

그 다음으로, filter 체인이 실행되면서 품질이 낮은 데이터를 제거합니다. user_id가 없거나, variant가 A 또는 B가 아니거나, conversion 값이 비정상적인 레코드를 걸러냅니다.

이 과정에서 Polars는 조건들을 하나의 최적화된 연산으로 병합하여 처리 속도를 극대화합니다. 세 번째 단계로, with_columns에서 데이터 타입을 통일하고 변환합니다.

문자열로 저장된 타임스탬프를 datetime 객체로 파싱하고, revenue를 float로 변환하면서 null 값은 0으로 채웁니다. 이렇게 해야 후속 통계 계산에서 오류가 발생하지 않습니다.

마지막으로, 같은 사용자가 여러 번 노출된 경우 가장 첫 번째 이벤트만 유지합니다. 이는 A/B 테스트의 핵심 가정인 "각 사용자는 하나의 그룹에만 속한다"를 지키기 위함입니다.

collect()를 호출하면 지금까지 구성한 모든 연산이 최적화되어 실제로 실행됩니다. 여러분이 이 코드를 사용하면 수백만 건의 이벤트 로그를 수 초 내에 정제할 수 있고, 데이터 품질 문제로 인한 분석 오류를 사전에 방지할 수 있으며, 후속 통계 분석의 신뢰성을 크게 향상시킬 수 있습니다.

실전 팁

💡 scan_csv 대신 read_csv를 쓰면 즉시 메모리에 로드되므로, 10GB 이상 데이터는 반드시 lazy 모드를 사용하세요. 메모리 부족 에러를 방지할 수 있습니다.

💡 unique() 사용 시 반드시 sort()로 먼저 정렬하세요. 그래야 "첫 번째" 또는 "마지막" 이벤트를 일관되게 선택할 수 있습니다. 정렬 없이 사용하면 무작위 레코드가 선택됩니다.

💡 실험 시작 전후 7일간의 데이터를 함께 로드하여 계절성 효과를 비교하세요. A/B 테스트는 외부 요인(요일, 이벤트 등)에 영향받으므로 베이스라인 데이터가 필수입니다.

💡 전처리 후 describe() 메서드로 기초 통계량을 확인하세요. 평균, 표준편차, 분위수를 보고 이상치가 남아있는지 검증할 수 있습니다.

💡 실무에서는 전처리 로직을 별도 함수로 분리하고 unit test를 작성하세요. 전처리 버그는 분석 결과 전체를 무효화시킬 수 있습니다.


2. 통계적 유의성 검정 (T-test와 Chi-square)

시작하며

여러분이 A/B 테스트를 실행하고 A 그룹의 전환율이 5.2%, B 그룹이 5.8%로 나왔을 때 이런 의문이 든 적 있나요? "이 0.6%p 차이가 진짜 의미 있는 차이일까, 아니면 우연일까?" 이런 고민은 모든 데이터 분석가가 겪는 핵심 문제입니다.

단순히 숫자만 비교해서는 안 되며, 통계적으로 신뢰할 수 있는지 검증해야 합니다. 그렇지 않으면 우연한 변동을 실제 효과로 착각하여 잘못된 의사결정을 내릴 수 있습니다.

바로 이럴 때 필요한 것이 가설 검정입니다. T-test와 Chi-square 검정으로 관찰된 차이가 통계적으로 유의한지 수학적으로 증명할 수 있습니다.

개요

간단히 말해서, 이 개념은 A/B 테스트 결과가 우연이 아닌 실제 효과인지 확률적으로 판단하는 통계 기법입니다. 실무에서는 두 그룹 간 차이를 관찰했을 때, 그것이 5% 미만의 확률로만 발생할 수 있는 일인지 계산합니다.

이 확률이 0.05(5%) 미만이면 "통계적으로 유의하다"고 결론 내립니다. 예를 들어, 평균 구매 금액을 비교할 때는 T-test를, 전환 여부(성공/실패)를 비교할 때는 Chi-square 검정을 사용합니다.

기존에는 엑셀이나 계산기로 수동으로 p-value를 계산했다면, 이제는 scipy와 Polars를 결합하여 수백 개의 실험을 자동으로 검정할 수 있습니다. 핵심 특징은 (1) p-value를 통한 객관적 판단 기준, (2) 지표 유형에 따른 적절한 검정 방법 선택, (3) 1종 오류(false positive)와 2종 오류(false negative) 간 균형입니다.

이러한 특징들이 데이터 기반 의사결정의 신뢰성을 보장합니다.

코드 예제

import polars as pl
from scipy import stats
import numpy as np

def run_statistical_tests(df: pl.DataFrame, metric: str) -> dict:
    # 그룹별 데이터 분리
    group_a = df.filter(pl.col("variant") == "A")[metric].to_numpy()
    group_b = df.filter(pl.col("variant") == "B")[metric].to_numpy()

    # 연속형 지표: T-test (예: 평균 구매 금액)
    if metric in ["revenue", "session_duration"]:
        t_stat, p_value = stats.ttest_ind(group_a, group_b)
        test_type = "t-test"

    # 이항 지표: Chi-square (예: 전환율)
    elif metric in ["conversion", "click"]:
        # 분할표 생성
        contingency_table = np.array([
            [np.sum(group_a), len(group_a) - np.sum(group_a)],
            [np.sum(group_b), len(group_b) - np.sum(group_b)]
        ])
        chi2_stat, p_value, dof, expected = stats.chi2_contingency(contingency_table)
        test_type = "chi-square"

    # 효과 크기 계산 (Cohen's d)
    effect_size = (np.mean(group_b) - np.mean(group_a)) / np.std(group_a)

    return {
        "test_type": test_type,
        "p_value": p_value,
        "is_significant": p_value < 0.05,
        "effect_size": effect_size,
        "group_a_mean": np.mean(group_a),
        "group_b_mean": np.mean(group_b)
    }

설명

이것이 하는 일: 두 그룹 간 관찰된 차이가 우연이 아닌 진짜 효과일 확률을 계산하여, 신뢰할 수 있는 결론을 내릴 수 있게 해줍니다. 첫 번째로, Polars DataFrame에서 variant별로 데이터를 필터링하여 numpy 배열로 변환합니다.

이렇게 하는 이유는 scipy의 통계 함수들이 numpy 배열을 입력으로 받기 때문입니다. to_numpy() 메서드는 Polars의 Apache Arrow 메모리를 zero-copy로 변환하여 매우 빠릅니다.

그 다음으로, 지표의 특성에 따라 적절한 검정 방법을 선택합니다. 연속형 지표(revenue, session_duration)는 정규분포를 가정하므로 T-test를 사용하고, 이항 지표(conversion, click)는 범주형 데이터이므로 Chi-square 검정을 사용합니다.

잘못된 검정 방법을 쓰면 p-value가 왜곡됩니다. 세 번째 단계에서 T-test는 두 그룹의 평균 차이를 표준오차로 나눈 t-통계량을 계산하고, Chi-square는 관찰 빈도와 기대 빈도의 차이를 제곱합으로 계산합니다.

두 방법 모두 최종적으로 p-value를 산출하는데, 이는 "귀무가설(차이가 없다)이 참일 때 이런 결과가 나올 확률"을 의미합니다. 마지막으로, Cohen's d를 계산하여 효과 크기를 측정합니다.

p-value만으로는 부족한 이유는, 샘플 크기가 크면 작은 차이도 유의하게 나올 수 있기 때문입니다. 효과 크기가 0.2 미만이면 작은 효과, 0.5는 중간, 0.8 이상은 큰 효과로 해석합니다.

여러분이 이 코드를 사용하면 주관적 판단 없이 객관적으로 실험 성공 여부를 결정할 수 있고, 잘못된 의사결정으로 인한 비즈니스 손실을 방지할 수 있으며, 경영진에게 통계적 근거를 제시하여 설득력을 높일 수 있습니다.

실전 팁

💡 p-value가 0.05에 근접(예: 0.049 또는 0.051)하면 샘플 크기를 늘려 재검정하세요. 경계값에서는 결론이 불안정할 수 있습니다.

💡 A/B 테스트 전에 power analysis로 필요한 샘플 크기를 계산하세요. 너무 적은 샘플로 실험하면 실제 효과가 있어도 유의하지 않게 나옵니다(2종 오류).

💡 다중 검정 문제를 주의하세요. 10개 지표를 동시에 테스트하면 우연히 1개는 유의하게 나올 수 있습니다. Bonferroni 보정으로 유의수준을 0.05/10=0.005로 조정하세요.

💡 T-test 전에 등분산성을 Levene's test로 확인하고, 위배되면 Welch's t-test를 사용하세요. stats.ttest_ind(equal_var=False)로 간단히 적용 가능합니다.

💡 실무에서는 단측 검정보다 양측 검정을 기본으로 사용하세요. "B가 A보다 좋을지 나쁠지" 모르는 상황이 대부분이므로, 양방향을 모두 고려해야 안전합니다.


3. 신뢰구간 계산 및 시각화

시작하며

여러분이 "B 그룹의 전환율이 5.8%입니다"라고 보고할 때 이런 질문을 받은 적 있나요? "그게 정확히 5.8%인가요, 아니면 오차 범위가 있나요?" 이런 질문은 매우 중요한 지적입니다.

표본에서 계산한 평균이나 비율은 모집단의 진짜 값이 아니라 추정치일 뿐입니다. 따라서 불확실성의 범위를 함께 제시해야 신뢰할 수 있는 분석이 됩니다.

바로 이럴 때 필요한 것이 신뢰구간(Confidence Interval)입니다. "95% 확률로 진짜 값이 5.3%~6.3% 사이에 있다"처럼 불확실성을 정량화할 수 있습니다.

개요

간단히 말해서, 이 개념은 표본 통계량 주변에 진짜 모수가 있을 가능성이 높은 범위를 계산하는 방법입니다. 실무에서는 점 추정치(단일 숫자) 대신 구간 추정치를 제시함으로써 분석의 불확실성을 투명하게 공유합니다.

95% 신뢰구간은 "동일한 실험을 100번 반복하면 95번은 이 구간에 진짜 값이 포함된다"는 의미입니다. 예를 들어, 신뢰구간이 넓으면 더 많은 데이터가 필요하다는 신호이고, 좁으면 추정이 정확하다는 뜻입니다.

기존에는 평균±표준오차를 수동으로 계산했다면, 이제는 bootstrap이나 t-분포를 활용하여 어떤 지표에도 적용 가능한 자동화된 신뢰구간을 구할 수 있습니다. 핵심 특징은 (1) 불확실성의 정량화, (2) 95%라는 표준화된 신뢰 수준, (3) 시각화를 통한 직관적 이해입니다.

이러한 특징들이 의사결정자들에게 리스크를 명확히 전달할 수 있게 합니다.

코드 예제

import polars as pl
import numpy as np
from scipy import stats
import matplotlib.pyplot as plt

def calculate_confidence_interval(df: pl.DataFrame, metric: str, confidence=0.95):
    results = []

    for variant in ["A", "B"]:
        data = df.filter(pl.col("variant") == variant)[metric].to_numpy()

        # 평균과 표준오차 계산
        mean = np.mean(data)
        sem = stats.sem(data)  # Standard Error of Mean

        # t-분포 기반 신뢰구간 (소표본에도 적합)
        ci = stats.t.interval(
            confidence=confidence,
            df=len(data)-1,  # 자유도
            loc=mean,
            scale=sem
        )

        results.append({
            "variant": variant,
            "mean": mean,
            "ci_lower": ci[0],
            "ci_upper": ci[1],
            "margin_of_error": ci[1] - mean
        })

    return pl.DataFrame(results)

# 시각화 함수
def plot_confidence_intervals(ci_df: pl.DataFrame):
    fig, ax = plt.subplots(figsize=(10, 6))

    variants = ci_df["variant"].to_list()
    means = ci_df["mean"].to_list()
    ci_lower = ci_df["ci_lower"].to_list()
    ci_upper = ci_df["ci_upper"].to_list()

    # 에러바로 신뢰구간 표시
    ax.errorbar(variants, means,
                yerr=[np.array(means)-np.array(ci_lower),
                      np.array(ci_upper)-np.array(means)],
                fmt='o', markersize=10, capsize=5, capthick=2)

    ax.set_ylabel("Metric Value")
    ax.set_title("95% Confidence Intervals by Variant")
    plt.grid(alpha=0.3)
    return fig

설명

이것이 하는 일: 표본 데이터에서 계산한 평균 주변에, 모집단의 진짜 평균이 95% 확률로 존재하는 범위를 계산하고 시각화합니다. 첫 번째로, variant별로 데이터를 분리하고 평균과 표준오차(SEM)를 계산합니다.

표준오차는 표준편차를 √n으로 나눈 값인데, 이는 "표본 평균이 모평균으로부터 얼마나 떨어져 있을지"의 추정치입니다. 샘플 크기가 클수록 표준오차는 작아져 더 정확한 추정이 가능합니다.

그 다음으로, t-분포의 interval 메서드를 사용하여 신뢰구간을 계산합니다. 정규분포 대신 t-분포를 사용하는 이유는 표본 크기가 작을 때 더 보수적인(넓은) 신뢰구간을 제공하여 안전하기 때문입니다.

자유도는 n-1로 설정하며, 이는 표본에서 하나의 자유도를 평균 계산에 사용했기 때문입니다. 세 번째 단계로, 계산된 신뢰구간을 Polars DataFrame으로 구조화하여 반환합니다.

margin_of_error를 함께 저장하면 "±X%" 형태로 보고서에 쉽게 표기할 수 있습니다. 이 값이 크면 더 많은 샘플이 필요하다는 의미입니다.

마지막으로, matplotlib의 errorbar로 신뢰구간을 시각화합니다. 두 그룹의 신뢰구간이 겹치지 않으면 통계적으로 유의한 차이가 있다고 직관적으로 판단할 수 있습니다.

경영진이나 비통계 전문가에게 설명할 때 이러한 시각화가 매우 효과적입니다. 여러분이 이 코드를 사용하면 단순히 "A가 B보다 좋다"가 아니라 "A는 5.2%(±0.3%), B는 5.8%(±0.4%)로 유의한 차이가 있다"처럼 정밀하게 보고할 수 있고, 의사결정의 리스크를 정량적으로 평가할 수 있으며, 시각 자료로 이해관계자들을 효과적으로 설득할 수 있습니다.

실전 팁

💡 신뢰구간이 너무 넓으면 샘플 크기를 늘리거나 실험 기간을 연장하세요. margin_of_error가 평균의 20% 이상이면 신뢰할 수 없는 추정치입니다.

💡 비율 지표(전환율 등)는 t-분포 대신 Wilson score interval을 사용하세요. 특히 전환율이 5% 미만이거나 95% 이상일 때 더 정확합니다.

💡 신뢰구간의 "신뢰"는 장기적 빈도를 의미합니다. "이번 실험에서 95% 확률로 구간에 있다"가 아니라 "100번 실험하면 95번은 맞다"는 뜻임을 이해하세요.

💡 두 그룹의 신뢰구간이 약간 겹쳐도 유의할 수 있습니다. 정확한 판단은 p-value나 차이의 신뢰구간을 계산해야 합니다.

💡 시계열 데이터에서는 자기상관을 고려한 bootstrap 신뢰구간을 사용하세요. 일반 신뢰구간은 독립성을 가정하므로 과소추정될 수 있습니다.


4. 베이지안 A/B 테스트 분석

시작하며

여러분이 전통적인 A/B 테스트를 실행하면서 이런 불편함을 느낀 적 있나요? "실험을 중간에 확인하면 안 되고, p-value 0.05를 넘기면 무조건 실패라니, 너무 경직되어 있지 않나?" 이런 문제는 빈도주의 통계학의 한계에서 비롯됩니다.

미리 정한 샘플 크기에 도달할 때까지 기다려야 하고, 중간에 확인하면 1종 오류가 증가하며, "B가 A보다 나을 확률"을 직접 알 수 없습니다. 바로 이럴 때 필요한 것이 베이지안 접근법입니다.

실시간으로 확률을 업데이트하면서 "B가 A보다 나을 확률이 92%"처럼 직관적으로 해석할 수 있습니다.

개요

간단히 말해서, 이 개념은 사전 지식과 관찰 데이터를 결합하여 사후 확률 분포를 구하고, 이를 통해 의사결정을 내리는 통계 기법입니다. 실무에서는 Beta 분포를 사용하여 전환율의 확률 분포를 모델링하고, Monte Carlo 시뮬레이션으로 "B가 A보다 나을 확률"을 직접 계산합니다.

예를 들어, 매일 데이터를 추가하면서 확률을 업데이트하고, 95% 확률을 넘으면 실험을 조기 종료할 수 있습니다. 기존에는 p-value를 기다려야 했다면, 이제는 언제든지 현재까지의 데이터로 의사결정 확률을 계산할 수 있습니다.

핵심 특징은 (1) 확률의 직관적 해석, (2) 실시간 업데이트 가능, (3) 사전 지식의 활용입니다. 이러한 특징들이 비즈니스 의사결정을 더 유연하고 빠르게 만들어줍니다.

코드 예제

import polars as pl
import numpy as np
from scipy.stats import beta
import matplotlib.pyplot as plt

def bayesian_ab_test(df: pl.DataFrame, prior_alpha=1, prior_beta=1, n_simulations=100000):
    # 그룹별 전환 데이터 집계
    stats_df = df.group_by("variant").agg([
        pl.col("conversion").sum().alias("conversions"),
        pl.col("conversion").count().alias("total")
    ])

    # A 그룹 통계
    a_row = stats_df.filter(pl.col("variant") == "A").row(0)
    a_conversions, a_total = a_row[1], a_row[2]

    # B 그룹 통계
    b_row = stats_df.filter(pl.col("variant") == "B").row(0)
    b_conversions, b_total = b_row[1], b_row[2]

    # 사후 분포 파라미터 (Beta 분포)
    a_alpha = prior_alpha + a_conversions
    a_beta = prior_beta + (a_total - a_conversions)
    b_alpha = prior_alpha + b_conversions
    b_beta = prior_beta + (b_total - b_conversions)

    # Monte Carlo 시뮬레이션
    a_samples = beta.rvs(a_alpha, a_beta, size=n_simulations)
    b_samples = beta.rvs(b_alpha, b_beta, size=n_simulations)

    # B가 A보다 나을 확률
    prob_b_better = np.mean(b_samples > a_samples)

    # 예상 손실 (Expected Loss)
    expected_loss_b = np.mean(np.maximum(a_samples - b_samples, 0))

    return {
        "prob_b_better_than_a": prob_b_better,
        "expected_loss_if_choose_b": expected_loss_b,
        "a_posterior_mean": a_alpha / (a_alpha + a_beta),
        "b_posterior_mean": b_alpha / (b_alpha + b_beta)
    }

설명

이것이 하는 일: 관찰 데이터를 Beta 분포로 모델링하고, Monte Carlo 시뮬레이션을 통해 한 그룹이 다른 그룹보다 나을 확률을 계산합니다. 첫 번째로, Polars의 group_by로 variant별 전환 수와 총 시도 수를 집계합니다.

이 집계 데이터가 Beta 분포의 파라미터를 업데이트하는 데 사용됩니다. Beta 분포는 0과 1 사이 값(전환율)을 모델링하기에 완벽한 분포입니다.

그 다음으로, 사전 분포(prior)와 관찰 데이터를 결합하여 사후 분포(posterior)의 파라미터를 계산합니다. alpha = prior_alpha + conversions, beta = prior_beta + (total - conversions) 공식으로 업데이트됩니다.

prior_alpha=1, prior_beta=1은 무정보 사전분포로, "아무 정보도 없다"는 가정입니다. 과거 데이터가 있다면 더 informative한 prior를 사용할 수 있습니다.

세 번째 단계로, 각 그룹의 사후 분포에서 100,000개의 샘플을 추출합니다. 이것이 Monte Carlo 시뮬레이션의 핵심입니다.

각 샘플은 "가능한 전환율"을 나타내며, 이 샘플들의 분포가 우리의 불확실성을 표현합니다. 마지막으로, 10만 번의 시뮬레이션에서 B 샘플이 A 샘플보다 큰 비율을 세면, 그것이 바로 "B가 A보다 나을 확률"입니다.

예를 들어 92,000번 B가 컸다면 확률은 92%입니다. 또한 expected loss를 계산하여 "B를 선택했을 때 A가 더 나았다면 얼마나 손해볼지"를 정량화합니다.

이 값이 작으면 B를 선택해도 안전합니다. 여러분이 이 코드를 사용하면 "p-value가 0.05 미만인가?"라는 이분법적 판단 대신 "92% 확률로 B가 낫다"는 연속적 확률을 얻을 수 있고, 실험을 매일 모니터링하면서 확신이 충분해지면 조기 종료할 수 있으며, 의사결정의 리스크를 expected loss로 정량화할 수 있습니다.

실전 팁

💡 prob_b_better가 95% 이상이고 expected_loss가 실무적으로 무시 가능한 수준(예: 0.1%p)이면 B를 채택하세요. 두 조건을 모두 만족해야 안전합니다.

💡 과거 실험 데이터가 있다면 prior를 informative하게 설정하세요. 예: 평균 전환율 5%였다면 prior_alpha=5, prior_beta=95로 시작하여 수렴 속도를 높일 수 있습니다.

💡 시뮬레이션 횟수는 최소 10,000회 이상으로 하세요. 너무 적으면 확률 추정이 불안정해집니다. 10만 회면 충분히 안정적입니다.

💡 베이지안 방법도 실험 설계는 중요합니다. 샘플 크기가 너무 작으면 prior에 지배되어 데이터를 제대로 반영하지 못합니다.

💡 다변량 테스트(A/B/C/D)에서는 모든 쌍을 비교하지 말고, "최고 변형일 확률"을 각각 계산하세요. 이렇게 하면 다중 비교 문제를 피할 수 있습니다.


5. Sequential Testing과 조기 종료

시작하며

여러분이 A/B 테스트를 실행하면서 이런 고민을 해본 적 있나요? "이미 B가 압도적으로 좋은데, 미리 정한 2주를 다 채워야 하나?

그 사이에 A 그룹 사용자들은 계속 나쁜 경험을 하는데..." 이런 딜레마는 고정된 샘플 크기 설계의 근본적인 한계입니다. 전통적인 방법론에서는 중간에 확인하면 안 되고, 조기 종료하면 통계적 타당성이 무너집니다.

하지만 비즈니스 현실에서는 매일 데이터를 보며 빠르게 의사결정해야 합니다. 바로 이럴 때 필요한 것이 Sequential Testing입니다.

매일 데이터를 확인하면서도 통계적 엄밀함을 유지하고, 명확한 결과가 나오면 안전하게 조기 종료할 수 있습니다.

개요

간단히 말해서, 이 개념은 실험을 여러 단계로 나누어 각 단계마다 통계 검정을 수행하고, 충분한 증거가 모이면 조기에 종료하는 방법론입니다. 실무에서는 매일 또는 매주 데이터를 확인하면서 alpha spending function을 사용하여 1종 오류를 제어합니다.

O'Brien-Fleming 경계나 Pocock 경계 같은 방법으로 각 단계의 유의수준을 조정합니다. 예를 들어, 5번의 중간 분석을 계획했다면 각 단계의 유의수준을 0.01, 0.02, 0.03, 0.04, 0.05로 점진적으로 완화합니다.

기존에는 실험 종료 시점까지 기다려야 했다면, 이제는 매일 모니터링하면서 조기 성공/실패를 감지하여 실험 기간을 30~50% 단축할 수 있습니다. 핵심 특징은 (1) 1종 오류 제어 하의 중간 분석, (2) alpha spending으로 유의수준 분배, (3) 조기 종료로 실험 비용 절감입니다.

이러한 특징들이 통계적 엄밀함과 비즈니스 효율성을 동시에 달성하게 합니다.

코드 예제

import polars as pl
import numpy as np
from scipy.stats import norm

class SequentialABTest:
    def __init__(self, alpha=0.05, max_looks=5):
        self.alpha = alpha
        self.max_looks = max_looks
        # O'Brien-Fleming 경계 계산
        self.boundaries = self._calculate_obrien_fleming_boundaries()
        self.current_look = 0

    def _calculate_obrien_fleming_boundaries(self):
        """각 중간 분석 시점의 z-score 임계값 계산"""
        boundaries = []
        for k in range(1, self.max_looks + 1):
            # O'Brien-Fleming: 초기에는 보수적, 후반에 완화
            z_boundary = norm.ppf(1 - self.alpha / (2 * np.sqrt(self.max_looks / k)))
            boundaries.append(z_boundary)
        return boundaries

    def analyze(self, df: pl.DataFrame, metric: str):
        """중간 분석 수행"""
        self.current_look += 1

        # 현재 데이터로 z-score 계산
        group_a = df.filter(pl.col("variant") == "A")[metric].to_numpy()
        group_b = df.filter(pl.col("variant") == "B")[metric].to_numpy()

        mean_diff = np.mean(group_b) - np.mean(group_a)
        pooled_se = np.sqrt(np.var(group_a)/len(group_a) + np.var(group_b)/len(group_b))
        z_score = mean_diff / pooled_se

        # 현재 단계의 경계값과 비교
        boundary = self.boundaries[self.current_look - 1]

        if abs(z_score) >= boundary:
            decision = "STOP - Significant difference detected"
            is_b_better = z_score > 0
        elif self.current_look == self.max_looks:
            decision = "STOP - Max looks reached"
            is_b_better = z_score > 0
        else:
            decision = "CONTINUE"
            is_b_better = None

        return {
            "look": self.current_look,
            "z_score": z_score,
            "boundary": boundary,
            "decision": decision,
            "b_is_better": is_b_better
        }

설명

이것이 하는 일: 실험을 여러 단계로 나누고, 각 단계마다 엄격하게 조정된 유의수준으로 검정하여, 충분한 증거가 모이면 안전하게 조기 종료합니다. 첫 번째로, 초기화 시점에 O'Brien-Fleming 경계값들을 미리 계산합니다.

이 방법은 초기 단계에서는 매우 보수적인(높은 z-score 요구) 기준을 사용하고, 후반으로 갈수록 완화합니다. 예를 들어 5번 분석 계획이면 첫 번째는 z>3.5, 마지막은 z>1.96 정도가 됩니다.

이렇게 하면 전체 실험의 1종 오류율이 정확히 0.05로 유지됩니다. 그 다음으로, 매일 또는 매주 analyze 메서드를 호출하여 현재까지의 데이터로 z-score를 계산합니다.

이는 두 그룹 평균 차이를 표준오차로 나눈 값으로, 효과의 크기와 통계적 확신을 동시에 나타냅니다. pooled standard error를 사용하여 두 그룹의 분산을 모두 고려합니다.

세 번째 단계로, 계산된 z-score를 현재 단계의 경계값과 비교합니다. 만약 z-score의 절댓값이 경계값을 넘으면 통계적으로 유의하므로 "조기 종료" 결정을 내립니다.

넘지 않으면 다음 분석 시점까지 계속 진행합니다. 이렇게 하면 극적인 차이는 빨리 감지하고, 미세한 차이는 충분한 데이터를 모을 때까지 기다립니다.

마지막으로, max_looks에 도달하면 유의하지 않더라도 실험을 종료합니다. 무한정 기다릴 수는 없으므로 최대 기간을 설정하는 것이 실무적으로 중요합니다.

이 시점에서 z-score의 부호를 보고 어느 쪽이 나은지만 판단합니다. 여러분이 이 코드를 사용하면 명확한 결과가 나올 때 30~50% 빠르게 실험을 종료하여 비용을 절감할 수 있고, 열등한 변형에 노출되는 사용자 수를 최소화할 수 있으며, 매일 모니터링해도 통계적 타당성을 유지하여 신뢰할 수 있는 결론을 얻을 수 있습니다.

실전 팁

💡 max_looks는 실험 기간을 고려하여 설정하세요. 2주 실험이면 매일 확인(14 looks)보다 주 2회 확인(4 looks)이 더 안정적입니다.

💡 Pocock 경계는 모든 단계에 동일한 기준을 적용하지만 조기 종료 확률이 높습니다. 보수적 접근이 필요하면 O'Brien-Fleming을, 빠른 결정이 필요하면 Pocock을 선택하세요.

💡 실무에서는 "무익성 종료"(futility stopping)도 고려하세요. 효과가 너무 작아서 유의해질 가능성이 없으면 조기에 중단하여 리소스를 절약할 수 있습니다.

💡 중간 분석 시점을 미리 고정하세요. "마음대로 확인"하면 alpha spending이 작동하지 않습니다. 실험 설계 시 "1일, 3일, 7일, 14일"처럼 명시하세요.

💡 여러 지표를 동시에 모니터링한다면 Bonferroni 보정을 추가로 적용하세요. 5개 지표면 각 지표의 alpha를 0.05/5=0.01로 설정하여 다중 검정 문제를 방지합니다.


6. 세그먼트별 이질적 효과 분석 (HTE)

시작하며

여러분이 A/B 테스트에서 "전체적으로는 B가 5% 나았다"는 결과를 보고하면서 이런 의문이 든 적 있나요? "모바일 사용자와 데스크톱 사용자에게 똑같이 5% 효과가 있을까?

아니면 특정 그룹에서는 더 크거나 작을까?" 이런 질문은 매우 중요합니다. 평균 효과만 보면 세그먼트별 차이를 놓칠 수 있습니다.

예를 들어, 신규 사용자에게는 +20% 효과가 있지만 기존 사용자에게는 -5% 효과가 있다면, 전체 평균은 의미가 없어집니다. 바로 이럴 때 필요한 것이 이질적 효과 분석(Heterogeneous Treatment Effect)입니다.

사용자 속성, 디바이스, 지역 등 세그먼트별로 효과를 측정하여 타겟팅 전략을 수립할 수 있습니다.

개요

간단히 말해서, 이 개념은 A/B 테스트 효과가 사용자 그룹에 따라 어떻게 다른지 분석하여, 어떤 세그먼트에 어떤 변형을 적용해야 최적인지 파악하는 방법입니다. 실무에서는 연령, 성별, 디바이스, 지역, 사용 빈도 등 다양한 차원으로 데이터를 나누고 각 세그먼트별로 효과 크기와 유의성을 계산합니다.

예를 들어, "모바일에서는 B가 10% 좋지만 데스크톱에서는 차이 없음"을 발견하면, 모바일만 B를 적용하는 조건부 롤아웃 전략을 세울 수 있습니다. 기존에는 전체 평균만 보고 일률적으로 적용했다면, 이제는 세그먼트별 최적 변형을 선택하여 전체 성과를 극대화할 수 있습니다.

핵심 특징은 (1) 세그먼트별 효과 크기 측정, (2) 상호작용 효과 검정, (3) 조건부 롤아웃 전략 수립입니다. 이러한 특징들이 개인화된 사용자 경험과 최대 비즈니스 성과를 동시에 달성하게 합니다.

코드 예제

import polars as pl
import numpy as np
from scipy import stats
import matplotlib.pyplot as plt

def heterogeneous_treatment_effect_analysis(df: pl.DataFrame, metric: str, segment_col: str):
    """세그먼트별 A/B 테스트 효과 분석"""

    results = []

    # 세그먼트별 분석
    for segment in df[segment_col].unique().to_list():
        segment_df = df.filter(pl.col(segment_col) == segment)

        # A/B 그룹 분리
        a_data = segment_df.filter(pl.col("variant") == "A")[metric].to_numpy()
        b_data = segment_df.filter(pl.col("variant") == "B")[metric].to_numpy()

        # 효과 크기 계산
        a_mean = np.mean(a_data)
        b_mean = np.mean(b_data)
        lift = (b_mean - a_mean) / a_mean * 100  # % 변화

        # 통계적 유의성
        t_stat, p_value = stats.ttest_ind(a_data, b_data)

        results.append({
            "segment": segment,
            "segment_size": len(segment_df),
            "a_mean": a_mean,
            "b_mean": b_mean,
            "lift_percent": lift,
            "p_value": p_value,
            "is_significant": p_value < 0.05
        })

    results_df = pl.DataFrame(results).sort("lift_percent", descending=True)

    # 상호작용 효과 검정 (전체 vs 세그먼트)
    overall_effect = df.group_by("variant").agg(pl.col(metric).mean())

    return results_df

def plot_segment_effects(results_df: pl.DataFrame):
    """세그먼트별 효과 시각화"""
    fig, ax = plt.subplots(figsize=(12, 6))

    segments = results_df["segment"].to_list()
    lifts = results_df["lift_percent"].to_list()
    is_sig = results_df["is_significant"].to_list()

    # 유의한 것은 진한 색, 비유의한 것은 연한 색
    colors = ['green' if sig else 'lightgray' for sig in is_sig]

    bars = ax.barh(segments, lifts, color=colors)
    ax.axvline(0, color='black', linestyle='--', linewidth=0.8)
    ax.set_xlabel("Lift (%)")
    ax.set_title("Heterogeneous Treatment Effects by Segment")
    ax.grid(axis='x', alpha=0.3)

    return fig

설명

이것이 하는 일: 전체 평균 효과를 넘어, 사용자 속성별로 실험 효과가 어떻게 다른지 분석하여 타겟팅된 의사결정을 가능하게 합니다. 첫 번째로, segment_col(예: device_type, age_group, region)의 각 값별로 데이터를 필터링합니다.

Polars의 unique()로 모든 세그먼트 값을 추출하고, 각 세그먼트에 대해 반복문을 실행합니다. 이렇게 하면 "모바일", "데스크톱", "태블릿"별로 독립적인 분석을 수행할 수 있습니다.

그 다음으로, 각 세그먼트 내에서 A와 B 그룹의 평균을 계산하고 lift(% 변화)를 구합니다. lift는 실무에서 가장 직관적인 지표로, "B가 A보다 15% 더 나았다"처럼 비율로 표현됩니다.

이는 절대 차이보다 해석이 쉽고, 세그먼트 간 비교에도 유용합니다. 세 번째 단계로, 각 세그먼트별로 t-test를 수행하여 통계적 유의성을 검정합니다.

중요한 점은 세그먼트 크기가 작으면 검정력이 낮아져 실제 효과가 있어도 유의하지 않게 나올 수 있다는 것입니다. 따라서 segment_size도 함께 기록하여 신뢰도를 평가합니다.

마지막으로, 모든 세그먼트의 결과를 lift 크기 순으로 정렬하여 반환합니다. 시각화에서는 유의한 세그먼트는 진한 색, 비유의한 세그먼트는 회색으로 표시하여 한눈에 어디에 효과가 있는지 파악할 수 있습니다.

0을 기준선으로 표시하여 양수/음수 효과를 명확히 구분합니다. 여러분이 이 코드를 사용하면 "모바일에만 B를 적용하고 데스크톱은 A 유지"처럼 조건부 롤아웃으로 전체 성과를 극대화할 수 있고, 효과가 없거나 역효과가 나는 세그먼트를 보호할 수 있으며, 마케팅 예산을 효과가 큰 세그먼트에 집중하여 ROI를 높일 수 있습니다.

실전 팁

💡 세그먼트가 많으면(10개 이상) 다중 검정 보정을 적용하세요. False discovery rate(FDR) 방법으로 유의수준을 조정하여 우연한 유의성을 걸러낼 수 있습니다.

💡 세그먼트 크기가 전체의 5% 미만이면 분석하지 마세요. 샘플이 너무 적으면 신뢰할 수 없고, 과적합의 위험이 있습니다.

💡 사전에 가설을 세운 세그먼트만 분석하세요. 사후적으로 "어떤 세그먼트에서 유의할까" 탐색하면 p-hacking이 됩니다. 실험 설계 시 "모바일 vs 데스크톱" 같은 주요 세그먼트를 명시하세요.

💡 상호작용 효과를 공식적으로 검정하려면 regression에 interaction term을 추가하세요. metric ~ variant * segment로 모델링하면 세그먼트별 효과 차이가 통계적으로 유의한지 알 수 있습니다.

💡 CATE(Conditional Average Treatment Effect) 추정을 위해 Causal Forest 같은 머신러닝 방법도 고려하세요. 수십 개 변수의 복잡한 상호작용을 자동으로 찾아줍니다.


7. 실시간 대시보드 구축

시작하며

여러분이 A/B 테스트를 실행하면서 이런 불편함을 느낀 적 있나요? "매번 스크립트를 돌려서 결과를 확인하고, 팀원들에게 슬랙으로 공유하고...

너무 비효율적이지 않나?" 이런 문제는 분석 결과의 접근성과 가시성 부족에서 비롯됩니다. PM, 디자이너, 경영진이 실시간으로 실험 현황을 보지 못하면 의사결정이 지연되고, 분석가는 반복적인 요청에 시달리게 됩니다.

바로 이럴 때 필요한 것이 실시간 대시보드입니다. Polars로 데이터를 고속 처리하고, Streamlit이나 Dash로 인터랙티브 웹 앱을 만들어 누구나 실시간으로 실험 현황을 모니터링할 수 있습니다.

개요

간단히 말해서, 이 개념은 A/B 테스트 데이터를 자동으로 집계하고 시각화하여, 웹 기반 대시보드로 실시간 모니터링 및 의사결정을 가능하게 하는 시스템입니다. 실무에서는 데이터베이스나 데이터 레이크에서 최신 데이터를 주기적으로 로드하고, Polars로 고속 집계한 후, 차트와 테이블로 시각화합니다.

예를 들어, 전환율 추이, 통계적 유의성, 신뢰구간, 세그먼트별 효과를 한 화면에 표시하여 팀 전체가 동일한 정보를 공유할 수 있습니다. 기존에는 주피터 노트북으로 일회성 분석을 했다면, 이제는 자동 업데이트되는 웹 대시보드로 24/7 모니터링할 수 있습니다.

핵심 특징은 (1) 실시간 데이터 갱신, (2) 인터랙티브 필터링과 드릴다운, (3) 다중 사용자 동시 접근입니다. 이러한 특징들이 조직 전체의 데이터 리터러시와 의사결정 속도를 향상시킵니다.

코드 예제

import polars as pl
import streamlit as st
from datetime import datetime, timedelta
import plotly.express as px

# Streamlit 대시보드 설정
st.set_page_config(page_title="A/B Test Dashboard", layout="wide")

@st.cache_data(ttl=300)  # 5분 캐시
def load_and_process_data(experiment_id: str):
    """데이터 로드 및 전처리 (Polars 고속 처리)"""
    # 실제로는 DB에서 로드
    df = pl.read_parquet(f"experiments/{experiment_id}.parquet")

    # 최근 7일 데이터만
    cutoff_date = datetime.now() - timedelta(days=7)
    df = df.filter(pl.col("timestamp") >= cutoff_date)

    # 일별 집계
    daily_stats = df.group_by(["date", "variant"]).agg([
        pl.col("conversion").mean().alias("conversion_rate"),
        pl.col("revenue").sum().alias("total_revenue"),
        pl.col("user_id").n_unique().alias("unique_users")
    ]).sort("date")

    return df, daily_stats

# 사이드바: 실험 선택
st.sidebar.header("Experiment Settings")
experiment_id = st.sidebar.selectbox("Select Experiment", ["exp_001", "exp_002"])

# 데이터 로드
df, daily_stats = load_and_process_data(experiment_id)

# 메인 대시보드
st.title(f"A/B Test Dashboard - {experiment_id}")

# KPI 카드
col1, col2, col3 = st.columns(3)
with col1:
    total_users = df["user_id"].n_unique()
    st.metric("Total Users", f"{total_users:,}")
with col2:
    overall_conversion = df["conversion"].mean()
    st.metric("Overall Conversion", f"{overall_conversion:.2%}")
with col3:
    total_revenue = df["revenue"].sum()
    st.metric("Total Revenue", f"${total_revenue:,.0f}")

# 시계열 차트
st.subheader("Conversion Rate Over Time")
fig = px.line(
    daily_stats.to_pandas(),
    x="date", y="conversion_rate", color="variant",
    title="Daily Conversion Rate by Variant"
)
st.plotly_chart(fig, use_container_width=True)

# 통계 검정 결과
st.subheader("Statistical Significance")
from scipy import stats

a_conv = df.filter(pl.col("variant") == "A")["conversion"].to_numpy()
b_conv = df.filter(pl.col("variant") == "B")["conversion"].to_numpy()
t_stat, p_value = stats.ttest_ind(a_conv, b_conv)

if p_value < 0.05:
    st.success(f"✅ Statistically Significant (p={p_value:.4f})")
else:
    st.warning(f"⚠️ Not Significant (p={p_value:.4f})")

설명

이것이 하는 일: 최신 실험 데이터를 자동으로 로드하고 집계하여, 웹 브라우저에서 누구나 실시간으로 A/B 테스트 현황을 모니터링할 수 있는 대시보드를 제공합니다. 첫 번째로, @st.cache_data 데코레이터로 데이터 로딩 함수를 캐싱합니다.

ttl=300으로 5분마다 자동 갱신되므로, 사용자가 새로고침할 때마다 데이터베이스를 조회하지 않아 성능이 크게 향상됩니다. Polars의 read_parquet은 Pandas보다 5~10배 빠르므로 대용량 데이터도 수 초 내에 로드됩니다.

그 다음으로, 최근 7일 데이터만 필터링하고 일별로 집계합니다. group_by와 agg를 체이닝하여 한 번의 스캔으로 전환율, 매출, 유저 수를 동시에 계산합니다.

이렇게 집계된 데이터는 원본보다 훨씬 작아서 시각화 라이브러리로 넘길 때 부담이 없습니다. 세 번째 단계로, Streamlit의 레이아웃 기능(columns, sidebar)으로 대시보드 UI를 구성합니다.

st.metric으로 KPI 카드를 만들고, plotly로 인터랙티브 시계열 차트를 그립니다. Plotly 차트는 줌, 팬, 호버 정보를 지원하여 사용자가 세부 데이터를 탐색할 수 있습니다.

마지막으로, scipy로 실시간 통계 검정을 수행하고 결과를 색상 코딩된 알림(success/warning)으로 표시합니다. p-value가 0.05 미만이면 녹색 체크마크와 함께 "유의함"을 표시하여, 비전문가도 한눈에 실험 성공 여부를 파악할 수 있습니다.

여러분이 이 코드를 사용하면 분석가가 매번 리포트를 만들 필요 없이 팀 전체가 셀프서비스로 데이터를 확인할 수 있고, 실험 진행 중 이상 징후를 실시간으로 감지하여 빠르게 대응할 수 있으며, 경영진 미팅에서 대시보드를 바로 띄워 데이터 기반 논의를 할 수 있습니다.

실전 팁

💡 대시보드에 알림 기능을 추가하세요. p-value가 0.05를 넘거나, 전환율이 급락하면 슬랙이나 이메일로 자동 알림을 보내 즉시 대응할 수 있습니다.

💡 사용자 권한 관리를 구현하세요. 민감한 매출 데이터는 특정 팀만 볼 수 있도록 제한하여 정보 보안을 강화할 수 있습니다.

💡 Streamlit Cloud나 Heroku에 배포하면 설치 없이 브라우저로 접근 가능합니다. Docker 컨테이너로 패키징하면 사내 서버에도 쉽게 배포할 수 있습니다.

💡 대시보드 로딩 시간이 느리면 데이터를 사전 집계하여 저장하세요. 매시간 크론잡으로 집계 테이블을 업데이트하고, 대시보드는 이를 읽기만 하면 됩니다.

💡 A/B/n 테스트나 다변량 테스트에도 확장 가능하도록 설계하세요. variant 컬럼을 동적으로 처리하여 3개 이상의 그룹도 자동으로 비교할 수 있게 만드세요.


8. 검정력 분석 및 샘플 크기 계산

시작하며

여러분이 A/B 테스트를 설계하면서 이런 질문을 받은 적 있나요? "이 실험을 얼마나 오래 돌려야 하나요?

100명으로 충분한가요, 아니면 10,000명이 필요한가요?" 이런 질문에 감으로 답하면 안 됩니다. 샘플이 너무 적으면 실제 효과가 있어도 감지하지 못하고(2종 오류), 너무 많으면 시간과 비용을 낭비합니다.

실험 전에 과학적으로 계산해야 합니다. 바로 이럴 때 필요한 것이 검정력 분석(Power Analysis)입니다.

원하는 효과 크기, 유의수준, 검정력을 입력하면 필요한 샘플 크기를 수학적으로 계산할 수 있습니다.

개요

간단히 말해서, 이 개념은 통계 검정이 실제 효과를 감지할 확률(검정력)을 계산하고, 목표 검정력을 달성하기 위한 최소 샘플 크기를 구하는 방법입니다. 실무에서는 실험 시작 전에 "5%p 전환율 향상을 80% 확률로 감지하려면 그룹당 3,200명 필요"처럼 계산합니다.

검정력 80%는 업계 표준이며, 실제 효과가 있을 때 그것을 놓칠 확률이 20%라는 의미입니다. 예를 들어, 작은 효과를 감지하려면 샘플이 많이 필요하고, 큰 효과는 적은 샘플로도 감지 가능합니다.

기존에는 "일단 2주 돌려보자"처럼 임의로 정했다면, 이제는 목표와 제약을 고려한 최적 샘플 크기를 사전에 계산할 수 있습니다. 핵심 특징은 (1) 효과 크기와 샘플 크기의 trade-off 정량화, (2) 2종 오류(false negative) 제어, (3) 실험 기간과 비용 최적화입니다.

이러한 특징들이 효율적이고 신뢰할 수 있는 실험 설계를 가능하게 합니다.

코드 예제

import numpy as np
from scipy.stats import norm
import polars as pl

def calculate_sample_size(baseline_rate, mde, alpha=0.05, power=0.80):
    """
    A/B 테스트에 필요한 샘플 크기 계산

    Args:
        baseline_rate: A 그룹의 기존 전환율 (예: 0.05)
        mde: 최소 감지 효과 (Minimum Detectable Effect, 예: 0.01은 1%p 향상)
        alpha: 1종 오류율 (default 0.05)
        power: 검정력 (default 0.80)

    Returns:
        각 그룹당 필요한 샘플 크기
    """
    # 표준정규분포의 임계값
    z_alpha = norm.ppf(1 - alpha/2)  # 양측 검정
    z_beta = norm.ppf(power)

    # 효과 후 전환율
    treatment_rate = baseline_rate + mde

    # 풀링된 비율
    pooled_rate = (baseline_rate + treatment_rate) / 2

    # 샘플 크기 공식
    n = (2 * pooled_rate * (1 - pooled_rate) * (z_alpha + z_beta)**2) / (mde**2)

    return int(np.ceil(n))

def power_analysis_table(baseline_rate, mde_range, alpha=0.05, power=0.80):
    """여러 MDE 값에 대한 샘플 크기 테이블 생성"""
    results = []

    for mde in mde_range:
        n = calculate_sample_size(baseline_rate, mde, alpha, power)
        relative_lift = (mde / baseline_rate) * 100

        # 일일 사용자 수를 가정하여 실험 기간 계산
        daily_users = 1000  # 조정 가능
        days_needed = int(np.ceil(2 * n / daily_users))

        results.append({
            "mde_percentage_points": mde * 100,
            "relative_lift_percent": relative_lift,
            "sample_per_group": n,
            "total_sample": 2 * n,
            "days_needed_1000_daily": days_needed
        })

    return pl.DataFrame(results)

# 사용 예시
baseline = 0.05  # 현재 전환율 5%
mde_values = [0.001, 0.005, 0.01, 0.015, 0.02]  # 0.1%p ~ 2%p

table = power_analysis_table(baseline, mde_values)
print(table)

설명

이것이 하는 일: 통계적으로 신뢰할 수 있는 결과를 얻기 위해 각 그룹에 몇 명의 사용자가 필요한지 사전에 계산하여, 실험 리소스를 효율적으로 배분합니다. 첫 번째로, 목표 파라미터들을 설정합니다.

baseline_rate는 현재 시스템의 전환율이고, mde(Minimum Detectable Effect)는 "이 정도 효과는 감지하고 싶다"는 최소 값입니다. 예를 들어 현재 5%인데 6%로 올리고 싶다면 mde=0.01입니다.

alpha는 1종 오류(false positive) 확률로 통상 0.05, power는 2종 오류를 1-power로 제어하며 통상 0.80을 사용합니다. 그 다음으로, 표준정규분포에서 임계값을 구합니다.

z_alpha는 유의수준에 대응하는 z-score(1.96), z_beta는 검정력에 대응하는 z-score(0.84)입니다. 이 값들은 표본 분포의 꼬리 확률을 나타내며, 샘플 크기 공식의 핵심 요소입니다.

세 번째 단계로, 풀링된 비율을 사용하여 분산을 추정합니다. 이는 귀무가설(차이 없음) 하에서의 분산 추정치로, 보수적인 계산을 가능하게 합니다.

최종 샘플 크기는 (z_alpha + z_beta)^2에 비례하고, mde^2에 반비례합니다. 즉, 작은 효과를 감지하려면 샘플이 기하급수적으로 증가합니다.

마지막으로, power_analysis_table 함수는 여러 mde 값에 대해 반복 계산하여 의사결정 테이블을 만듭니다. "1%p 향상 감지하려면 3,842명, 2%p는 961명"처럼 trade-off를 한눈에 볼 수 있습니다.

일일 사용자 수를 입력하면 실험 기간도 자동 계산되어, "1,000명/일이면 8일 필요"처럼 구체적인 계획을 세울 수 있습니다. 여러분이 이 코드를 사용하면 실험 시작 전에 현실적인 목표와 일정을 수립할 수 있고, 샘플 부족으로 인한 실험 실패를 방지할 수 있으며, 경영진에게 "이 실험은 2주, 5,000명이 필요합니다"라고 과학적 근거를 제시할 수 있습니다.

실전 팁

💡 MDE는 실무적으로 의미 있는 최소 효과로 설정하세요. 통계적으로는 0.1%p도 감지 가능하지만, 비즈니스적으로 무의미하면 과도한 샘플이 낭비됩니다.

💡 실험 중 이탈률을 고려하여 10~20% 여유를 두세요. 3,000명 필요하다면 3,600명을 목표로 하여 중도 이탈이나 데이터 품질 문제에 대비하세요.

💡 매출처럼 분산이 큰 지표는 평균 대신 winsorization이나 중앙값을 사용하세요. 극단값이 많으면 필요 샘플 크기가 지나치게 커집니다.

💡 Sequential testing을 계획한다면 샘플 크기를 10~20% 증가시키세요. 중간 분석으로 인한 alpha inflation을 보상하기 위함입니다.

💡 다변량 테스트(A/B/C/D)는 그룹 수만큼 샘플이 곱절로 필요합니다. 4개 그룹이면 2개 그룹 대비 2배의 샘플과 시간이 소요됩니다.


9. 다중 검정 보정 (Multiple Testing Correction)

시작하며

여러분이 한 번의 A/B 테스트에서 10개 지표(클릭율, 전환율, 매출, 체류시간 등)를 동시에 분석하면서 이런 결과를 본 적 있나요? "10개 중 1개가 p<0.05로 유의하게 나왔네요!" 이런 상황은 함정입니다.

유의수준 0.05는 "우연히 유의할 확률 5%"를 의미하므로, 10개를 테스트하면 우연히 1개는 유의하게 나올 수 있습니다. 이를 다중 검정 문제(Multiple Testing Problem)라고 하며, 1종 오류율이 급증합니다.

바로 이럴 때 필요한 것이 다중 검정 보정입니다. Bonferroni, Holm, FDR 같은 방법으로 전체 1종 오류율을 제어하여 거짓 양성을 방지할 수 있습니다.

개요

간단히 말해서, 이 개념은 여러 가설을 동시에 검정할 때 1종 오류율이 누적되는 것을 막기 위해 유의수준을 조정하는 통계 기법입니다. 실무에서는 m개의 지표를 테스트할 때 각 지표의 유의수준을 α/m으로 조정(Bonferroni)하거나, False Discovery Rate를 제어(Benjamini-Hochberg)합니다.

예를 들어, 10개 지표를 0.05 수준에서 테스트한다면 각 지표는 0.005로 평가해야 전체 오류율이 0.05로 유지됩니다. 기존에는 각 지표를 독립적으로 0.05로 테스트하여 과도한 false positive를 양산했다면, 이제는 보정 방법을 적용하여 신뢰할 수 있는 결론을 도출할 수 있습니다.

핵심 특징은 (1) Family-Wise Error Rate(FWER) 또는 False Discovery Rate(FDR) 제어, (2) 보수성과 검정력 간 균형, (3) 탐색적 분석과 확증적 분석의 구분입니다. 이러한 특징들이 대규모 실험에서도 통계적 엄밀함을 유지하게 합니다.

코드 예제

import polars as pl
import numpy as np
from scipy import stats
from statsmodels.stats.multitest import multipletests

def multiple_testing_correction(df: pl.DataFrame, metrics: list, method="fdr_bh"):
    """
    여러 지표에 대한 A/B 테스트 수행 및 다중 검정 보정

    Args:
        df: A/B 테스트 데이터
        metrics: 테스트할 지표 리스트
        method: 보정 방법 ("bonferroni", "holm", "fdr_bh", "fdr_by")

    Returns:
        보정된 p-value와 유의성 판단
    """
    results = []

    # 각 지표에 대해 검정 수행
    for metric in metrics:
        a_data = df.filter(pl.col("variant") == "A")[metric].to_numpy()
        b_data = df.filter(pl.col("variant") == "B")[metric].to_numpy()

        # T-test
        t_stat, p_value = stats.ttest_ind(a_data, b_data)

        # 효과 크기
        effect_size = np.mean(b_data) - np.mean(a_data)

        results.append({
            "metric": metric,
            "p_value_raw": p_value,
            "effect_size": effect_size,
            "a_mean": np.mean(a_data),
            "b_mean": np.mean(b_data)
        })

    # 다중 검정 보정
    p_values = [r["p_value_raw"] for r in results]
    rejected, p_adjusted, _, _ = multipletests(p_values, alpha=0.05, method=method)

    # 보정된 결과 추가
    for i, result in enumerate(results):
        result["p_value_adjusted"] = p_adjusted[i]
        result["is_significant_raw"] = p_values[i] < 0.05
        result["is_significant_adjusted"] = rejected[i]
        result["correction_method"] = method

    return pl.DataFrame(results).sort("p_value_adjusted")

# 사용 예시 및 방법 비교
def compare_correction_methods(df: pl.DataFrame, metrics: list):
    """여러 보정 방법 비교"""
    methods = ["bonferroni", "holm", "fdr_bh"]
    comparison = []

    for method in methods:
        result_df = multiple_testing_correction(df, metrics, method)
        n_significant = result_df["is_significant_adjusted"].sum()

        comparison.append({
            "method": method,
            "n_significant": n_significant,
            "most_conservative": method == "bonferroni"
        })

    return pl.DataFrame(comparison)

설명

이것이 하는 일: m개의 지표를 동시에 검정할 때, 전체적으로 1종 오류(false positive) 확률이 설정한 수준(0.05)을 넘지 않도록 각 지표의 p-value를 조정합니다. 첫 번째로, 모든 관심 지표에 대해 독립적으로 t-test를 수행하여 원시 p-value를 계산합니다.

이 단계에서는 아직 보정하지 않으며, 각 지표별 효과 크기와 평균값도 함께 기록합니다. 이렇게 하면 통계적 유의성뿐만 아니라 실무적 중요성도 평가할 수 있습니다.

그 다음으로, statsmodels의 multipletests 함수에 모든 p-value를 전달하여 일괄 보정합니다. Bonferroni 방법은 각 p-value에 m을 곱하여(상한 1.0) 가장 보수적으로 보정하고, Holm 방법은 순차적으로 보정하여 Bonferroni보다 약간 덜 보수적입니다.

FDR(False Discovery Rate)은 "유의하다고 판단한 것 중 거짓인 비율"을 제어하여 검정력이 높습니다. 세 번째 단계로, 보정된 p-value와 원시 p-value를 모두 DataFrame에 저장합니다.

is_significant_raw와 is_significant_adjusted를 비교하면 보정으로 인해 얼마나 많은 지표가 유의성을 잃었는지 확인할 수 있습니다. 예를 들어 10개 중 3개가 원래 유의했지만 보정 후 1개만 남았다면, 2개는 거짓 양성이었을 가능성이 높습니다.

마지막으로, 여러 보정 방법을 비교하는 함수도 제공합니다. Bonferroni는 매우 보수적이어서 진짜 효과도 놓칠 수 있고(2종 오류 증가), FDR은 탐색적 분석에 적합하며, Holm은 그 중간입니다.

실무에서는 확증적 분석(최종 의사결정)에는 Bonferroni, 탐색적 분석(가설 생성)에는 FDR을 권장합니다. 여러분이 이 코드를 사용하면 10개, 100개 지표를 테스트해도 거짓 양성 확률을 5%로 제어할 수 있고, 우연히 유의하게 나온 지표에 속아 잘못된 의사결정을 내리는 것을 방지할 수 있으며, peer review나 논문 게재 시 통계적 엄밀함을 인정받을 수 있습니다.

실전 팁

💡 사전에 primary metric 1개를 정하고 나머지는 secondary로 분류하세요. Primary는 보정 없이 0.05로 테스트하고, secondary만 보정하면 검정력 손실을 최소화할 수 있습니다.

💡 FDR 방법은 대규모 탐색(수백 개 지표)에 적합합니다. 유전체학에서 검증된 방법으로, 진짜 효과를 놓치지 않으면서도 거짓 발견을 제어합니다.

💡 보정 전후 결과를 모두 보고하세요. "원시 p-value로는 3개 유의, Bonferroni 보정 후 1개 유의"처럼 투명하게 공개하면 신뢰도가 높아집니다.

💡 실험 전에 "몇 개 지표를 테스트할지" 명시하세요. 사후적으로 추가 지표를 테스트하면 p-hacking이 되어 보정이 무의미해집니다.

💡 다중 검정 보정은 독립적인 검정들에 적용됩니다. 지표들이 강하게 상관되어 있으면(예: 매출과 객단가) 보정이 과도하게 보수적일 수 있으므로, 상관 구조를 고려한 방법을 사용하세요.


10. 생존 분석 및 시간 종속 지표 (Time-to-Event)

시작하며

여러분이 A/B 테스트에서 "첫 구매까지 걸린 시간"이나 "이탈까지의 기간"을 비교하려고 할 때 이런 문제를 만난 적 있나요? "어떤 사용자는 아직 구매하지 않았는데, 이걸 어떻게 처리하죠?" 이런 문제는 censored data(중도절단 데이터)라고 하며, 일반적인 평균 비교로는 해결할 수 없습니다.

아직 이벤트가 발생하지 않은 사용자를 제외하면 편향되고, 0으로 처리하면 과소추정됩니다. 바로 이럴 때 필요한 것이 생존 분석(Survival Analysis)입니다.

Kaplan-Meier 곡선과 Log-rank 검정으로 중도절단 데이터를 올바르게 처리하여 시간 종속 지표를 비교할 수 있습니다.

개요

간단히 말해서, 이 개념은 특정 이벤트가 발생하기까지의 시간을 분석하는 통계 기법으로, 아직 이벤트가 발생하지 않은 관측치도 정보로 활용합니다. 실무에서는 신규 가입자가 첫 구매까지 걸리는 시간, 무료 체험 사용자가 유료 전환하기까지의 기간, 사용자가 이탈하기까지의 시간 등을 분석합니다.

예를 들어, A 그룹은 평균 7일, B 그룹은 5일이 걸린다면, B가 전환을 가속화하는 효과가 있다고 결론 내립니다. 기존에는 "이벤트 발생한 사용자만" 분석하여 편향된 결과를 얻었다면, 이제는 모든 사용자 정보를 활용하여 정확한 생존 곡선과 통계 검정을 수행할 수 있습니다.

핵심 특징은 (1) 중도절단 데이터의 올바른 처리, (2) Kaplan-Meier 추정량으로 생존 함수 추정, (3) Log-rank 검정으로 그룹 간 비교입니다. 이러한 특징들이 시간 종속 A/B 테스트의 정확한 분석을 가능하게 합니다.

코드 예제

import polars as pl
import numpy as np
from lifelines import KaplanMeierFitter
from lifelines.statistics import logrank_test
import matplotlib.pyplot as plt

def prepare_survival_data(df: pl.DataFrame, event_col: str, time_col: str):
    """
    생존 분석용 데이터 준비

    Args:
        df: 원본 데이터 (variant, user_id, event_time 등)
        event_col: 이벤트 발생 여부 (1=발생, 0=미발생/censored)
        time_col: 관찰 시간 (일 단위)

    Returns:
        생존 분석용 DataFrame
    """
    # 현재 시점 기준으로 censoring 처리
    max_time = df[time_col].max()

    survival_df = df.with_columns([
        # 이벤트 미발생 시 관찰 종료 시점까지의 시간
        pl.when(pl.col(event_col) == 0)
          .then(max_time)
          .otherwise(pl.col(time_col))
          .alias("duration"),
        pl.col(event_col).alias("event_observed")
    ])

    return survival_df

def survival_analysis_ab_test(df: pl.DataFrame):
    """A/B 테스트에서 생존 곡선 비교"""

    # Kaplan-Meier 추정
    kmf_a = KaplanMeierFitter()
    kmf_b = KaplanMeierFitter()

    # A 그룹
    df_a = df.filter(pl.col("variant") == "A")
    kmf_a.fit(
        durations=df_a["duration"].to_numpy(),
        event_observed=df_a["event_observed"].to_numpy(),
        label="Group A"
    )

    # B 그룹
    df_b = df.filter(pl.col("variant") == "B")
    kmf_b.fit(
        durations=df_b["duration"].to_numpy(),
        event_observed=df_b["event_observed"].to_numpy(),
        label="Group B"
    )

    # Log-rank 검정 (두 생존 곡선이 다른지)
    result = logrank_test(
        durations_A=df_a["duration"].to_numpy(),
        durations_B=df_b["duration"].to_numpy(),
        event_observed_A=df_a["event_observed"].to_numpy(),
        event_observed_B=df_b["event_observed"].to_numpy()
    )

    # 시각화
    fig, ax = plt.subplots(figsize=(10, 6))
    kmf_a.plot_survival_function(ax=ax, ci_show=True)
    kmf_b.plot_survival_function(ax=ax, ci_show=True)
    ax.set_xlabel("Days since signup")
    ax.set_ylabel("Survival probability (not converted)")
    ax.set_title("Kaplan-Meier Survival Curves")
    plt.grid(alpha=0.3)

    # 중앙 생존 시간 (50% 전환 시점)
    median_a = kmf_a.median_survival_time_
    median_b = kmf_b.median_survival_time_

    return {
        "logrank_p_value": result.p_value,
        "is_significant": result.p_value < 0.05,
        "median_time_a": median_a,
        "median_time_b": median_b,
        "test_statistic": result.test_statistic,
        "fig": fig
    }

설명

이것이 하는 일: 이벤트 발생까지의 시간 분포를 추정하고, 중도절단된 관측치(아직 이벤트 미발생)도 정보로 활용하여 A/B 그룹 간 생존 곡선을 비교합니다. 첫 번째로, 데이터를 생존 분석 형식으로 변환합니다.

각 사용자에 대해 (duration, event_observed) 쌍을 만드는데, duration은 "가입 후 경과 일수", event_observed는 "이벤트 발생 여부(1) 또는 censored(0)"입니다. 예를 들어 30일 실험에서 10일째 구매한 사람은 (10, 1), 아직 구매 안 한 사람은 (30, 0)입니다.

그 다음으로, Kaplan-Meier 추정량으로 각 시점의 생존 확률을 계산합니다. 이 방법은 각 이벤트 시점마다 "아직 이벤트 안 일어난 사람 중 이번에 일어날 조건부 확률"을 곱해가며 생존 함수를 추정합니다.

Censored 데이터는 해당 시점까지는 정보를 제공하다가 그 이후에는 제외되므로, 정보 손실 없이 활용됩니다. 세 번째 단계로, Log-rank 검정으로 두 생존 곡선이 통계적으로 다른지 검정합니다.

이는 모든 시점에서 관찰된 이벤트 수와 기대 이벤트 수의 차이를 카이제곱 통계량으로 계산합니다. p-value가 0.05 미만이면 두 그룹의 생존 곡선이 유의하게 다르다고 결론냅니다.

마지막으로, median_survival_time을 계산하여 "50% 사용자가 전환하는 데 걸린 시간"을 비교합니다. 예를 들어 A는 14일, B는 10일이면 "B는 전환을 4일 앞당긴다"는 구체적인 비즈니스 인사이트를 얻을 수 있습니다.

시각화에서는 95% 신뢰구간도 함께 표시하여 불확실성을 전달합니다. 여러분이 이 코드를 사용하면 "첫 구매까지 평균 X일"처럼 단순 평균이 아니라 전체 시간 분포를 파악할 수 있고, 실험 종료 전에도 중간 데이터로 분석하여 조기 인사이트를 얻을 수 있으며, 리텐션, 이탈, 전환 같은 시간 기반 KPI를 정확하게 비교할 수 있습니다.

실전 팁

💡 실험 기간이 짧으면 대부분이 censored 데이터가 되어 검정력이 낮아집니다. 최소 30~50% 사용자가 이벤트를 경험할 때까지 실험을 연장하세요.

💡 Cox proportional hazards 모델로 다변량 분석을 수행하면 variant 효과를 연령, 지역 등 공변량을 조정한 상태에서 측정할 수 있습니다.

💡 생존 곡선이 교차하면 Log-rank 검정이 적절하지 않을 수 있습니다. 시각적으로 확인하고, 교차하면 restricted mean survival time을 비교하세요.

💡 "빠를수록 좋은" 이벤트(전환, 활성화)와 "늦을수록 좋은" 이벤트(이탈, 해지)를 구분하세요. 이탈 분석에서는 생존 확률이 높을수록(늦게 이탈) 좋습니다.

💡 Polars로 시간 데이터를 전처리할 때 타임존을 주의하세요. strptime으로 파싱 시 UTC로 통일하고, duration 계산 시 .dt.total_seconds() / 86400으로 일 단위 변환하세요.


#Python#Polars#ABTest#Statistics#DataAnalysis#데이터분석,Python,Polars

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.