모두가 함께 움직일 때, 무엇을 기억해야 하나

bandits
experiments
한국어
프로모션처럼 모든 변형을 같이 움직이는 변화 속에서 밴딧이 기억해야 할 것과 버려야 할 것. 여러 분야에 같은 구조가 있고, 그것이 맞는지는 측정할 수 있다.
공개

2026년 10월 15일

English version

여러 분야에 같은 구조가 있다

비교 실험을 하다 보면, 비교하려는 차이와 상관없이 모든 대상이 함께 움직이는 순간이 온다.

  • 온라인 A/B 테스트: 프로모션, 요일, 앱 릴리스가 모든 변형의 전환율을 같이 올리고 내린다.
  • 광고: 시간대와 계절이 모든 소재의 클릭률을 같이 흔든다.
  • 이메일과 푸시: 보내는 요일과 시각이 모든 문구의 반응률을 같이 바꾼다.
  • 모바일 헬스: 그날의 컨디션과 일정이 어떤 알림을 받든 반응 확률 전체를 움직인다.
  • 장기간의 임상시험: 등록 시기와 기관, 표준 치료의 변화가 모든 군의 결과를 함께 바꾼다.
  • 농업 현장 시험: 밭의 구획마다 토양이 달라, 같은 구획의 모든 품종이 함께 잘 되거나 못 된다.

마지막 예가 실마리다. 통계학의 가장 오래된 답 중 하나가 블록화다. 같은 구획 안에서만 비교하고, 구획 자체의 효과는 비교에서 빼 버린다. 모두를 함께 움직이는 것은 “수준” 이고, 알고 싶은 것은 그 위의 “차이” 다.

차이를 어떤 척도로 재느냐도 중요하다. 수준이 움직일 때 확률 차이와 오즈비가 동시에 고정되어 있을 수는 없다. 임상시험 메타분석에서는 확률 차이보다 오즈비 같은 상대 척도가 기저 위험이 다른 연구들 사이에서 더 일정하다는 보고가 오래전부터 있었다(Deeks, 2002). 아래의 방법은 이 상대 척도, 로그오즈 위에서 “차이는 유지되고 수준은 움직인다” 고 가정한다.

적응적 배정은 이 문제를 키운다

트래픽을 반반으로 고정한 실험이라면 수준이 움직여도 크게 문제가 없다. 모든 변형이 같은 시기를 같은 비율로 겪기 때문에 영향이 상쇄된다.

밴딧은 다르다. 더 좋아 보이는 쪽으로 트래픽을 옮기는 것이 본래 하는 일이다. 그러면 변형마다 겪은 시기가 달라진다. 전 기간을 합친 전환율은, 좋은 시기에 트래픽을 더 받은 변형에 유리하게 기운다. 실력이 아니라 운이 순위를 바꾼다.

블록화를 밴딧에 옮기면 답이 나온다. 배치 하나를 블록 하나로 본다. 배치마다 로지스틱 회귀를 적합하되 그 배치의 수준은 별도의 절편으로 흡수해 버리고, 변형 사이의 로그오즈 차이만 다음 배치로 넘긴다. 내가 Odds-Ratio Thompson Sampling(OR-TS)이라는 이름으로 정리하고 pip install orts 로 공개한 방법이다.

측정해 보면: 맞는 곳과 아닌 곳

이 가정이 실제로 맞는지는 측정할 수 있다. 기간마다 수준과 차이를 추정하고, 표본 잡음을 넘어서 각각이 얼마나 움직이는지 보면 된다. 그런 측정이 가능한 공개 데이터는 많지 않았고, 두 개를 썼다.

  • 맞았다: 한 온라인 소매업체의 A/B 테스트 71개(ASOS 공개 데이터). 기간 사이에 수준은 로짓으로 약 3분의 1만큼 움직였고, 변형 간 차이의 크기는 그 100분의 1 수준이었다. 86개 시계열 모두에서 수준이 차이보다 더 움직였다. 다만 차이가 오랫동안 유지되는지는 이 데이터만으로 확정되지 않았다.
  • 틀렸다: 패션 추천 로그 하나(Open Bandit 데이터). 여기서는 오히려 차이가 수준보다 더 움직였다. 아이템 몇 개의 인기가 일주일 사이에 오르내렸기 때문이다. 이 환경에서는 어떤 방법도 다른 방법보다 뚜렷이 낫지 않았다.

즉 이것은 모든 분야에 맞는 법칙이 아니라 분야마다 확인할 성질이다. 그리고 확인하는 데 오래 걸리지 않는다.

당신의 데이터에서 확인하는 법

기간과 변형별로 노출 수와 이벤트 수만 있으면 된다. 실험 플랫폼의 집계 테이블이면 충분하다.

from orts import batches_from_rows
from orts.diagnostics import batch_contrasts, excess_sd

levels, contrasts = [], []
for obs in batches_from_rows(rows):          # 기간×변형 한 행씩
    level, effects = batch_contrasts(obs, reference="control")
    levels.append(level)
    contrasts.append(effects["treatment"])

print("수준이 움직인 정도", excess_sd(*zip(*levels)))
print("차이가 움직인 정도", excess_sd(*zip(*contrasts)))

두 숫자는 표본 잡음을 뺀 기간 사이의 표준편차이고, 단위는 로짓이다. 첫 번째가 두 번째보다 훨씬 크다면, 그 분야에서는 수준을 버리고 차이만 기억하는 쪽이 맞는 선택이다. 두 번째가 크다면 차이도 시간에 따라 움직인다는 뜻이고, 그때는 과거를 조금씩 잊는 장치(decay)가 필요하다.

실제 제품 코드와의 비교

가정이 맞는 환경에서 얼마나 차이가 나는지는, 오픈소스 실험 플랫폼 GrowthBook 의 밴딧 엔진과 비교해 봤다. 엔진 코드가 공개되어 있어서 실제 제품 코드를 그대로 돌릴 수 있었다. 이 엔진은 기간마다 평균을 구해 모든 변형에 같은 가중치로 합치는 방식, 즉 블록화의 다른 형태를 쓴다.

이진 결과, 기간 40개에 기간당 2만 명, 변형 다섯 개, 20회 반복. 누적 regret 은 항상 최선의 변형만 보여 줬을 때와 비교해 잃은 전환 수이며 낮을수록 좋다.

공통 시프트 시프트 없음
기간 가중 (GrowthBook) 642 622
가중 없이 전 기간 합산 798 400
기간마다 수준을 버림 (OR-TS) 421 360

기간 가중은 시프트를 막아 주지만(642 대 798), 아무것도 움직이지 않을 때는 합산보다 못하다(622 대 400). 가중치가 그 변형이 받은 사용자 수가 아니라 전체 트래픽 비중을 따르기 때문이다. 수준을 버리는 방식은 각 관측을 담은 정보만큼 가중하므로 그 대가 없이 보호를 얻는다.

사용자 집단마다 최선의 변형이 다른 경우로도 넓혀 봤다. 집단과 기간마다 수준을 버리고 집단마다 차이를 두되, 얇은 집단은 다른 집단에서 정보를 빌려 오게 했다. 결과를 보기 전에 가설 네 개와 실패 기준을 먼저 커밋하고 돌렸다. 셋은 성립했고 하나는 실패했다. 실패한 것은 결과 수준으로 집단을 나누는 트리의 리프를 그대로 집단으로 쓰는 방식이었다. 최선의 변형이 다른 집단이 한 리프에 묶이면 다시 가를 수 없다. 블록은 결과가 아니라 비교 구조에 맞춰 나눠야 한다는, 블록화의 원래 교훈과 같은 이야기다.

이 글이 말하는 것과 말하지 않는 것

비교는 시뮬레이션이고, 이진 결과뿐이다. 실제 데이터 측정은 두 데이터셋에서 했고, 하나에서는 가정이 맞고 하나에서는 틀렸다. 광고, 모바일 헬스, 임상시험, 현장 시험은 구조가 같다는 이야기이지, 그 분야에서 측정했다는 이야기가 아니다. 그 분야의 데이터를 가진 사람이 위의 진단을 돌려 본다면, 그것이 이 방법이 어디까지 맞는지에 대한 가장 좋은 답이 될 것이다.

기간 가중이 틀렸다는 이야기도 아니다. 평균 메트릭에도 쓰이고 계산이 단순하다. 수준을 버리는 방식은 지금은 이진 결과에만 있다.

엔진을 공개해 두고 내 제안을 꼼꼼히 검토해 준 GrowthBook 팀에 감사한다.