Ilya Brin - Software Engineer

History is written by its contributors

Probability Theory: байесовская статистика для A/B тестов на Go

2025-04-29 5 min read Mathematics Ilya Brin

Привет, любители Go и статистики!

A/B тестирование повсюду: цвета кнопок, цены, алгоритмы. Но большинство реализаций использует частотную статистику.

Байесовский подход даёт вероятность того, что вариант лучше, а не просто “статистически значимо”.

Разберемся в деталях и реализуем это на Go.

Частотный vs Байесовский подход

Частотный: “Различие статистически значимо?”

  • p-value < 0.05 → отвергаем нулевую гипотезу
  • Не говорит вероятность того, что A лучше B

Байесовский: “Какова вероятность, что B лучше A?”

  • Прямой ответ: “B лучше A с вероятностью 95%”
  • Обновляет убеждения по мере поступления данных

Базовый A/B тест

type Variant struct {
    Name        string
    Conversions int
    Visitors    int
}

func (v *Variant) Rate() float64 {
    if v.Visitors == 0 {
        return 0
    }
    return float64(v.Conversions) / float64(v.Visitors)
}

Бета-распределение

Например, для оценки конверсий часто используют бета-распределение.

Для конверсий используем бета-распределение:

  • Prior: Beta(α, β) - наше начальное убеждение
  • Likelihood: Binomial(conversions, visitors)
  • Posterior: Beta(α + conversions, β + visitors - conversions)
import "math"

type BetaDistribution struct {
    Alpha float64
    Beta  float64
}

func NewBetaPrior() BetaDistribution {
    return BetaDistribution{Alpha: 1, Beta: 1} // Равномерный prior
}

func (b BetaDistribution) Update(conversions, visitors int) BetaDistribution {
    return BetaDistribution{
        Alpha: b.Alpha + float64(conversions),
        Beta:  b.Beta + float64(visitors-conversions),
    }
}

func (b BetaDistribution) Mean() float64 {
    return b.Alpha / (b.Alpha + b.Beta)
}

func (b BetaDistribution) Variance() float64 {
    sum := b.Alpha + b.Beta
    return (b.Alpha * b.Beta) / (sum * sum * (sum + 1))
}

Монте-Карло симуляция

Чтобы найти P(B > A), сэмплируем из обоих распределений:

import (
    "math/rand"
    "time"
)

func (b BetaDistribution) Sample(rng *rand.Rand) float64 {
    // Используем гамма-распределение для сэмплирования из бета
    x := sampleGamma(b.Alpha, rng)
    y := sampleGamma(b.Beta, rng)
    return x / (x + y)
}

func sampleGamma(shape float64, rng *rand.Rand) float64 {
    if shape < 1 {
        return sampleGamma(shape+1, rng) * math.Pow(rng.Float64(), 1/shape)
    }
    
    d := shape - 1.0/3.0
    c := 1.0 / math.Sqrt(9.0*d)
    
    for {
        z := rng.NormFloat64()
        v := math.Pow(1+c*z, 3)
        
        if v > 0 {
            u := rng.Float64()
            if math.Log(u) < 0.5*z*z+d-d*v+d*math.Log(v) {
                return d * v
            }
        }
    }
}

func ProbabilityBBeatsA(a, b BetaDistribution, samples int) float64 {
    rng := rand.New(rand.NewSource(time.Now().UnixNano()))
    wins := 0
    
    for i := 0; i < samples; i++ {
        sampleA := a.Sample(rng)
        sampleB := b.Sample(rng)
        if sampleB > sampleA {
            wins++
        }
    }
    
    return float64(wins) / float64(samples)
}

Полный A/B тест

type ABTest struct {
    VariantA Variant
    VariantB Variant
    PriorA   BetaDistribution
    PriorB   BetaDistribution
}

func NewABTest() *ABTest {
    return &ABTest{
        VariantA: Variant{Name: "A"},
        VariantB: Variant{Name: "B"},
        PriorA:   NewBetaPrior(),
        PriorB:   NewBetaPrior(),
    }
}

func (t *ABTest) RecordConversion(variant string, converted bool) {
    if variant == "A" {
        t.VariantA.Visitors++
        if converted {
            t.VariantA.Conversions++
        }
    } else {
        t.VariantB.Visitors++
        if converted {
            t.VariantB.Conversions++
        }
    }
}

func (t *ABTest) Results() TestResults {
    posteriorA := t.PriorA.Update(t.VariantA.Conversions, t.VariantA.Visitors)
    posteriorB := t.PriorB.Update(t.VariantB.Conversions, t.VariantB.Visitors)
    
    probBBeatsA := ProbabilityBBeatsA(posteriorA, posteriorB, 10000)
    
    return TestResults{
        VariantA:    t.VariantA,
        VariantB:    t.VariantB,
        PosteriorA:  posteriorA,
        PosteriorB:  posteriorB,
        ProbBBeatsA: probBBeatsA,
    }
}

type TestResults struct {
    VariantA    Variant
    VariantB    Variant
    PosteriorA  BetaDistribution
    PosteriorB  BetaDistribution
    ProbBBeatsA float64
}

func (r TestResults) Winner() string {
    if r.ProbBBeatsA > 0.95 {
        return "B"
    } else if r.ProbBBeatsA < 0.05 {
        return "A"
    }
    return "Неопределённо"
}

Реальный пример

Рассмотрим тест с вариантами A и B, где A имеет 10 конверсий из 100 посетителей (10%), а B - 15 конверсий из 100 посетителей (15%).

func main() {
    test := NewABTest()
    
    // Симулируем трафик
    // A: 100 посетителей, 10 конверсий (10%)
    for i := 0; i < 100; i++ {
        test.RecordConversion("A", i < 10)
    }
    
    // B: 100 посетителей, 15 конверсий (15%)
    for i := 0; i < 100; i++ {
        test.RecordConversion("B", i < 15)
    }
    
    results := test.Results()
    
    fmt.Printf("Вариант A: %d/%d (%.2f%%)\n",
        results.VariantA.Conversions,
        results.VariantA.Visitors,
        results.VariantA.Rate()*100)
    
    fmt.Printf("Вариант B: %d/%d (%.2f%%)\n",
        results.VariantB.Conversions,
        results.VariantB.Visitors,
        results.VariantB.Rate()*100)
    
    fmt.Printf("Вероятность B лучше A: %.2f%%\n", results.ProbBBeatsA*100)
    fmt.Printf("Победитель: %s\n", results.Winner())
}

Вывод:

Вариант A: 10/100 (10.00%)
Вариант B: 15/100 (15.00%)
Вероятность B лучше A: 89.23%
Победитель: Неопределённо

Ожидаемые потери

Сколько мы теряем, если выберем неправильный вариант:

func ExpectedLoss(a, b BetaDistribution, samples int) (lossA, lossB float64) {
    rng := rand.New(rand.NewSource(time.Now().UnixNano()))
    
    for i := 0; i < samples; i++ {
        sampleA := a.Sample(rng)
        sampleB := b.Sample(rng)
        
        if sampleA > sampleB {
            lossB += sampleA - sampleB
        } else {
            lossA += sampleB - sampleA
        }
    }
    
    return lossA / float64(samples), lossB / float64(samples)
}

Multi-Armed Bandit

Проведение A/B теста с фиксированным трафиком 50/50 не всегда оптимально. Вместо этого можно динамически распределять трафик на основе текущих результатов.

Вместо фиксированного 50/50, распределяем трафик динамически:

type Bandit struct {
    Variants []Variant
    Priors   []BetaDistribution
}

func (b *Bandit) SelectVariant() int {
    rng := rand.New(rand.NewSource(time.Now().UnixNano()))
    
    // Thompson Sampling
    maxSample := 0.0
    maxIdx := 0
    
    for i, prior := range b.Priors {
        posterior := prior.Update(
            b.Variants[i].Conversions,
            b.Variants[i].Visitors,
        )
        sample := posterior.Sample(rng)
        
        if sample > maxSample {
            maxSample = sample
            maxIdx = i
        }
    }
    
    return maxIdx
}

Продакшен реализация

type ABTestService struct {
    tests map[string]*ABTest
    mu    sync.RWMutex
}

func NewABTestService() *ABTestService {
    return &ABTestService{
        tests: make(map[string]*ABTest),
    }
}

func (s *ABTestService) GetTest(name string) *ABTest {
    s.mu.RLock()
    defer s.mu.RUnlock()
    
    if test, ok := s.tests[name]; ok {
        return test
    }
    return nil
}

func (s *ABTestService) CreateTest(name string) *ABTest {
    s.mu.Lock()
    defer s.mu.Unlock()
    
    test := NewABTest()
    s.tests[name] = test
    return test
}

func (s *ABTestService) RecordEvent(testName, variant string, converted bool) error {
    test := s.GetTest(testName)
    if test == nil {
        return fmt.Errorf("test not found: %s", testName)
    }
    
    test.RecordConversion(variant, converted)
    return nil
}

HTTP Handler

func (s *ABTestService) HandleResults(w http.ResponseWriter, r *http.Request) {
    testName := r.URL.Query().Get("test")
    test := s.GetTest(testName)
    
    if test == nil {
        http.Error(w, "Test not found", http.StatusNotFound)
        return
    }
    
    results := test.Results()
    
    response := map[string]interface{}{
        "variant_a": map[string]interface{}{
            "conversions": results.VariantA.Conversions,
            "visitors":    results.VariantA.Visitors,
            "rate":        results.VariantA.Rate(),
        },
        "variant_b": map[string]interface{}{
            "conversions": results.VariantB.Conversions,
            "visitors":    results.VariantB.Visitors,
            "rate":        results.VariantB.Rate(),
        },
        "probability_b_beats_a": results.ProbBBeatsA,
        "winner":                results.Winner(),
    }
    
    json.NewEncoder(w).Encode(response)
}

Когда остановить тест

func (r TestResults) ShouldStop(threshold float64) bool {
    // Останавливаем если вероятность > threshold или < (1 - threshold)
    return r.ProbBBeatsA > threshold || r.ProbBBeatsA < (1-threshold)
}

// Использование
if results.ShouldStop(0.95) {
    fmt.Println("Тест завершён, можно принимать решение")
}

Заключение

Байесовское A/B тестирование даёт:

  • Прямую интерпретацию вероятности
  • Работает с малыми выборками
  • Можно остановить тест досрочно
  • Естественно для multi-armed bandits

Преимущества над частотным подходом:

  • Нет путаницы с p-values
  • Непрерывный мониторинг без штрафа
  • Учитывает априорные знания

Для продакшена учитывайте:

  • Кеширование расчётов posterior
  • Персистентное хранение данных тестов
  • Мониторинг sample ratio mismatch
  • Сегментационный анализ

Байесовская статистика делает A/B тестирование интуитивным и практичным.

Дополнительные материалы и примеры на GitHub: github.com/ilyabrin/ab-testing-bayesian-go

comments powered by Disqus