Днес ще обсъдим подробно приложението на стратификацията за повишаване на чувствителността на оценката на AB експеримента.

Ще се научиш:

  • какво е стратифицирана извадка;
  • два метода за точкова оценка на извадковата средна стойност;
  • разликата между стратификация и пост-стратификация;
  • как стратификацията влияе на дисперсията на показателя.

Пример за AB експеримент

Нека започнем с един пример. Да предположим, че имаме онлайн магазин за доставка на хранителни стоки и искаме да стимулираме увеличаване на средния приход на потребител. За целта решихме да изпратим рекламни имейли на активни клиенти. Преди да изпратим имейла до всички активни клиенти, трябва да тестваме неговата ефективност с помощта на AB експеримент.

В най-простия сценарий това ще изисква:

  1. Определете целевия показател.
  2. Формулирайте статистическа хипотеза и критерия за нейната проверка.
  3. Задайте минималния очакван ефект и приемливите вероятности за грешки от тип I и тип II.
  4. Преценете необходимия размер на групата.
  5. Формирайте експерименталната и контролната групи.
  6. Проведете експеримента.
  7. Оценете резултатите от експеримента.

Нека разгледаме всяка точка малко по-подробно.

Определете целевия показател

Целевият показател трябва да отразява крайната цел на промените и ще се използва за оценка на успеха на експеримента. В нашия случай целевият показател ще бъде средният приход на клиент по време на експеримента.

Формулирайте статистическа хипотеза и критерия за нейната проверка

Ще проверим хипотезата за равенство на средствата. Нулевата хипотеза е, че средните са равни, а алтернативната хипотеза е, че средните не са равни. Ще използваме t-теста на Стюдънт като критерий. Продължителността на експеримента ще бъде една седмица.

Задайте минималния очакван ефект и приемливите вероятности за грешки от тип I и тип II

Тези параметри са необходими за оценка на необходимия размер на групата.

Спомнете си, че грешка от тип I е събитие, което се състои в това, че казваме, че има ефект, когато няма такъв. Грешка тип II е събитие, което се състои в това, че казваме, че няма ефект, когато всъщност има такъв.

Лесно е да се покаже, че ако искаме да можем да открием някакъв ефект или никога да не направим грешка, ще са необходими безкраен брой наблюдения. Нямаме безкраен брой потребители, за да проведем експеримента. Нека вземем по-обосновани ценности. Вероятностите за грешки от тип I и тип II ще бъдат зададени съответно на 0,05 и 0,20. Да предположим, че въз основа на исторически данни получихме оценка на средния приход на потребител (2500 $) и оценка на стандартното му отклонение (800 $) за потребители, които отговарят на експерименталните условия. След изпращане на имейлите очакваме приходите да се увеличат с поне 100 $.

Преценете необходимия размер на групата

Това може да стане с помощта на формулата:

където α е вероятността за грешка от тип I, β е вероятността за грешка от тип II, σ² е дисперсията на стойностите в контролната и експерименталната групи и ε е минималният очакван ефект.

Нека изчислим размера на извадката:

import numpy as np    
from scipy import stats

alpha = 0.05                    
beta = 0.2                      
mu_control = 2500               
effect = 100                    
mu_pilot = mu_control + effect  
std = 800                       

t_alpha = stats.norm.ppf(1 - alpha / 2, loc=0, scale=1)
t_beta = stats.norm.ppf(1 - beta, loc=0, scale=1)
var = 2 * std ** 2
sample_size = int((t_alpha + t_beta) ** 2 * var / (effect ** 2))
print(f'sample_size = {sample_size}')

Полученият sample_size е 1004.

Нека проверим дали при този размер на групата вероятностите за грешка се контролират на посочените нива. Това може да се направи с помощта на синтетични AA и AB тестове. Ще генерираме двойки проби със и без ефект и ще изчислим пропорциите на случаите, при които t-тестът е допуснал грешка.

В този пример, за простота, ние генерираме данни от нормално разпределение. На практика трябва да се вземат извадки от данни от емпирично разпределение, изградено върху реални исторически данни.

first_type_errors = []
second_type_errors = []
 
sample_size = 1004
 
for _ in range(10000):
    control_one = np.random.normal(mu_control, std, sample_size)
    control_two = np.random.normal(mu_control, std, sample_size)
    pilot = np.random.normal(mu_pilot, std, sample_size)
    _, pvalue_aa = stats.ttest_ind(control_one, control_two)
    first_type_errors.append(pvalue_aa < alpha)
    _, pvalue_ab = stats.ttest_ind(control_one, pilot)
    second_type_errors.append(pvalue_ab >= alpha)

part_first_type_errors = np.mean(first_type_errors)
part_second_type_errors = np.mean(second_type_errors)
print(f'part_first_type_errors = {part_first_type_errors:0.3f}')
print(f'part_second_type_errors = {part_second_type_errors:0.3f}')

Получихме стойности близки до 0,05 и 0,2, както се очакваше. Стойностите може леко да варират от цикъл до цикъл поради произволността на генерираните данни.

На практика точните стойности на параметрите на разпределението са неизвестни, така че вместо тях се използват техните оценки. Оценките на параметрите може леко да се различават от истинските стойности, така че получената оценка на размера на извадката не е напълно точна. За да сте сигурни, че тестът контролира вероятностите за грешка на определеното ниво, препоръчваме да вземете размер на групата, малко по-голям от изчислената стойност.

Нека размерът на групата е 1100. Ако изпълните същия скрипт с sample_size=1100, очакваната вероятност за грешка от тип I ще остане приблизително 0,05, а вероятността за грешка от тип II ще намалее.

Формирайте експериментални и контролни групи

Определихме размера на групата, сега трябва да изберем конкретни потребители за експеримента. Това може да стане на случаен принцип. Този подход за формиране на групи се нарича произволна извадка.

Да предположим, че имаме общо 10 000 потребители. Тогава хората могат да бъдат избрани за експеримента със следния код:

user_ids = np.arange(10000)
control_user_ids, pilot_user_ids = np.random.choice(
    user_ids, (2, sample_size), replace=False
)

Проведете експеримент

Ние изпращаме имейли на потребителите в експерименталната група и не изпращаме нищо на потребителите в контролната група. Чакаме една седмица.

Оценете резултатите от експеримента

Когато експериментът приключи, трябва да съберем данни, за да изчислим показателите и да извършим тест за значимост на разликите между групите, използвайки алгоритъма, който коригирахме преди експеримента.

Стратифицирана извадка

Изглежда, че сме помислили за всичко и можем да стартираме експеримента. Но нека помислим, може би имаме допълнителна информация, която може да направи нашия тест по-добър.

Имаме програма за лоялност и не всички потребители на нашия магазин са регистрирани в нея. Поведението на регистрираните потребители може да се различава от това на нерегистрираните потребители. Информацията за регистрация в програмата за лоялност може да се използва за повишаване на чувствителността на експеримента.

Нека въведем някои дефиниции:

Ковариат — показател, който корелира с целевия показател, може да бъде измерен преди експеримента (строго погледнато, не е задължително) и е независим от други експерименти. В нашия случай фактът на регистрация в програмата за лоялност преди експеримента ще бъде ковариата.

Ето някои примери за ковариати за експерименти с хора: пол, възраст, град на пребиваване, операционна система на устройството на потребителя. Ако експериментът не се провежда върху хора, а върху магазини, тогава ковариатите могат да бъдат размерът на търговската площ, местоположението (в търговски център или в отделна сграда), работното време (денонощно или не) и т.н. На.

Популация — всички потребители, върху които можем да повлияем чрез нашия експеримент. Нека нашето население се състои от 10 000 активни потребители.

С помощта на ковариати можем да разделим генералната съвкупност на неприпокриващи се подгрупи, които ще имат уникален набор от стойности на ковариати. Такива подгрупи се наричат ​​страти.

В нашия пример ще има два слоя:

  • първият — тези, които не са регистрирани в програмата за лоялност;
  • вторият — тези, които са регистрирани в програмата за лоялност.

Нека разгледаме историческите данни на потребителите в тези слоеве поотделно. Да предположим, че намерихме следната информация:

  • пропорциите на слоевете в населението са равни и съставляват по 50% всяка;
  • средният приход на седмица в първия слой е 2000 $, във втория — 3000 $;

стандартните отклонения на приходите на седмица са 625 $ и в двата слоя. С тези стойности на стандартното отклонение, комбинирането на данните от двата слоя ще има отклонение от около 800 $, както беше първоначално.

Оказва се, че потребителите от различни слоеве имат различно разпределение на показателя.

При произволно разпределение на потребителите в групи размерите на стратите може да са неравни: броят на потребителите в първата страта в контролната група може да бъде по-голям, отколкото в експерименталната група и обратно. Това може да доведе до неправилни резултати при оценката на пилота, тъй като средните стойности на показателя в стратите се различават.

Би било добре да се използват знания за това как различните групи потребители се държат различно по време на експеримента, за да се премахнат фактори, които могат да увеличат вероятността от грешка. Как може да стане това?

Има просто решение. Можете да създадете контролни и пилотни групи по такъв начин, че пропорциите на всяка страта да са равни на пропорциите на стратите в населението.

Технически това е доста лесно да се направи. Първо, ние определяме броя на представителите на всяка прослойка, от които се нуждаем във всяка група, като умножим размера на групата по пропорциите на слоевете в населението. В нашия случай получаваме 550 и за двата слоя. След като определим размерите на стратите в групата, произволно избираме съответния брой обекти от всяка страта за групите.

Ще проведем синтетични AA и AB експерименти за произволна и стратифицирана извадка и ще видим как се различават вероятностите за грешка.

За целта ще ни трябват функции за генериране на данни с помощта на произволна и стратифицирана извадка, както и функция за изчисляване на p-стойности с помощта на t-теста на Student.

import pandas as pd

def get_stratified_data(strat_to_param, effect=0):
    """Generates data using stratified sampling.

    Returns a dataframe with metric values and user strata in
    control and experimental groups.
    
    strat_to_param - dictionary with strat parameters
    effect - effect size
    """
    control, pilot = [], []
    for strat, (n, mu, std) in strat_to_param.items():
        control += [
            (x, strat,) for x in np.random.normal(mu, std, n)
        ]
        pilot += [
            (x, strat,) for x in np.random.normal(mu + effect, std, n)
        ]
    columns = ['value', 'strat']
    control_df = pd.DataFrame(control, columns=columns)
    pilot_df = pd.DataFrame(pilot, columns=columns)
    return control_df, pilot_df
 
 
def get_random_data(strats, sample_size, strat_to_param, effect=0):
    """Generates data using random sampling.

    Returns a dataframe with metric values and user strata in the control and experimental groups.
    
    strats - a list of strata in the population
    sample_size - group sizes
    strat_to_param - a dictionary with stratum parameters
    effect - effect size
    """
    control_strats, pilot_strats = np.random.choice(
        strats, (2, sample_size), False
    )
    control, pilot = [], []
    for strat, (n, mu, std) in strat_to_param.items():
        n_control_ = np.sum(control_strats == strat)
        control += [
            (x, strat,) for x in np.random.normal(mu, std, n_control_)
        ]
        n_pilot_ = np.sum(pilot_strats == strat)
        pilot += [
            (x, strat,) for x in np.random.normal(mu + effect, std, n_pilot_)
        ]
    columns = ['value', 'strat']
    control_df = pd.DataFrame(control, columns=columns)
    pilot_df = pd.DataFrame(pilot, columns=columns)
    return control_df, pilot_df


def ttest(a: pd.DataFrame, b: pd.DataFrame) -> float:
    """Returns the p-value of the Student's t-test

    a, b - data of users in the control and experimental groups
    """
    _, pvalue = stats.ttest_ind(a['value'].values, b['value'].values)
    return pvalue

Първо, нека проведем AA експерименти и сравним вероятностите за грешки от тип I.

alpha = 0.05                     
N = 10000                           
w_one, w_two = 0.5, 0.5            
N_one = int(N * w_one)             
N_two = int(N * w_two)              
mu_one, mu_two = 2000, 3000         
std_one, std_two = 625, 625         

# list of strats in the population 
strats = [1 for _ in range(N_one)] + [2 for _ in range(N_two)]

# experiment groups size
sample_size = 1100
sample_size_one = int(sample_size * w_one)
sample_size_two = int(sample_size * w_two)

# parameters mapping
strat_to_param = {
    1: (sample_size_one, mu_one, std_one,),
    2: (sample_size_two, mu_two, std_two,)
}

random_first_type_errors = []
stratified_first_type_errors = []
random_deltas = []
stratified_deltas = []

for _ in range(10000):
    control_random, pilot_random = get_random_data(
        strats, sample_size, strat_to_param
    )
    control_stratified, pilot_stratified = get_stratified_data(
        strat_to_param
    )
    random_deltas.append(
        pilot_random['value'].mean() - control_random['value'].mean()
    )
    stratified_deltas.append(
        pilot_stratified['value'].mean() - control_stratified['value'].mean()
    )

    pvalue_random = ttest(control_random, pilot_random)
    random_first_type_errors.append(pvalue_random < alpha)
    pvalue_stratified = ttest(control_stratified, pilot_stratified)
    stratified_first_type_errors.append(pvalue_stratified < alpha)
 
part_random_first_type_errors = np.mean(random_first_type_errors)
part_stratified_first_type_errors = np.mean(stratified_first_type_errors)
print(f'part_random_first_type_errors = {part_random_first_type_errors:0.3f}')
print(f'part_stratified_first_type_errors = {part_stratified_first_type_errors:0.3f}')

Получихме, че изчислената вероятност за грешка от тип I е 0,048 за произволна извадка и 0,010 за стратифицирана извадка.

Стратифицираната извадка намали вероятността от грешка тип I с повече от 4 пъти!

От една страна, намаляването на вероятността от грешка тип I е добро, защото ни позволява по-често да вземаме правилни решения. От друга страна, значително отклонение на вероятността за грешка от тип I от определеното ниво на значимост е лошо, защото това не е тестът, който искахме да изградим.

По време на експериментите запазихме и разликите в средните стойности на приходите между групите, нека да видим как изглежда тяхното разпределение.

import seaborn as sns

sns.displot(
    {'random_deltas': random_deltas, 'stratified_deltas': stratified_deltas},
    kind='kde'
)

Разпределението на разликата в средните стойности със стратифицирана извадка има по-леки опашки и следователно по-ниска дисперсия, което води до намаляване на броя на грешките от тип I.

Нека оценим вероятностите за грешки от тип II чрез изкуствено добавяне на ефект към експерименталната група.

effect = 100
random_second_type_errors = []
stratified_second_type_errors = []

for _ in range(10000):
    control_random, pilot_random = get_random_data(
        strats, sample_size, strat_to_param, effect
    )
    control_stratified, pilot_stratified = get_stratified_data(
        strat_to_param, effect
    )
    pvalue_random = ttest(control_random, pilot_random)
    random_second_type_errors.append(pvalue_random >= alpha)
    pvalue_stratified = ttest(control_stratified, pilot_stratified)
    stratified_second_type_errors.append(pvalue_stratified >= alpha)
 
part_random_second_type_errors = np.mean(random_second_type_errors)
part_stratified_second_type_errors = np.mean(stratified_second_type_errors)
print(f'part_random_second_type_errors = {part_random_second_type_errors:0.3f}')
print(f'part_stratified_second_type_errors = {part_stratified_second_type_errors:0.3f}')

Разпределението на разликата в средните стойности със стратифицирана извадка има по-леки опашки и следователно по-ниска дисперсия, което води до намаляване на броя на грешките от тип I.

Нека оценим вероятностите за грешки от тип II чрез изкуствено добавяне на ефект към експерименталната група.

Оказва се, че при случайна извадка делът на грешки от тип II е 0,167, докато при стратифицирана извадка е 0,109.

Стратифицираната извадка намали дела на грешки от тип II с около един път и половина!

Стратифицираното вземане на проби ни позволи да намалим вероятностите за грешки от тип I и тип II, което е страхотно. Но получихме тест, който не е точно това, което искахме първоначално. Искахме да тестваме хипотези на ниво на значимост от 0,05, но се оказа, че тестваме на по-ниско ниво на значимост. Това се случва, защото със стратифицираната извадка ние не само стесняваме разпределението на средните, но и намаляваме дисперсията, като фиксираме броя на стратите във всяка група. T-тестът не е наясно с факта, че вземаме извадки от данни неслучайно, така че при изчисляването на статистиката се използва завишена оценка на дисперсията. Стратифицираната средна стойност ще ни помогне да върнем теста до желаното ниво на значимост.

Стратифицирана средна стойност

Когато изчисляваме обичайната средна стойност, ние разделяме общия приход на общия брой потребители. Когато изчисляваме стратифицираната средна стойност, ние изчисляваме нормалната средна стойност за всяка страта поотделно и след това изчисляваме тяхната претеглена сума, където теглото на всяка страта е делът на стратата в популацията.

Нека обърнем внимание на две свойства на стратифицираната средна стойност.

Първо, при стратифицираната извадка оценката на стратифицираната средна стойност е равна на оценката на простата (извадкова) средна стойност.

Второ, в случай на произволна извадка, очакваната стойност на оценката на стратифицираната средна стойност е равна на очакваната стойност на оценката на простата средна стойност. Освен това тези оценки са безпристрастни.

Оценката на дисперсията на стратифицираната средна стойност може да се изчисли по формулата:

Нека използваме тази оценка на дисперсията, за да изчислим статистиката за нашия тест:

Нека повторим синтетичните експерименти AA и AB, използвайки стратифицираната средна стойност като наша метрика.

Нека повторим синтетичните A/A и A/B експерименти, като използваме стратифицираната средна стойност като показател.

def calc_strat_mean(df: pd.DataFrame, weights: pd.Series) -> float:
    strat_mean = df.groupby('strat')['value'].mean()
    return (strat_mean * weights).sum()


def calc_strat_var(df: pd.DataFrame, weights: pd.Series) -> float:
    strat_var = df.groupby('strat')['value'].var()
    return (strat_var * weights).sum()


def ttest_strat(a: pd.DataFrame, b: pd.DataFrame, weights: pd.Series) -> float:
    a_strat_mean = calc_strat_mean(a, weights)
    b_strat_mean = calc_strat_mean(b, weights)
    a_strat_var = calc_strat_var(a, weights)
    b_strat_var = calc_strat_var(b, weights)
    delta = b_strat_mean - a_strat_mean
    std = (a_strat_var / len(a) + b_strat_var / len(b)) ** 0.5
    t = delta / std
    pvalue = 2 * (1 - stats.norm.cdf(np.abs(t)))
    return pvalue


weights = pd.Series({1: w_one, 2: w_two})

first_type_errors = []
second_type_errors = []

for _ in range(10000):
    control_aa, pilot_aa = get_stratified_data(
        strat_to_param
    )
    control_ab, pilot_ab = get_stratified_data(
        strat_to_param, effect
    )

    pvalue_aa = ttest_strat(control_aa, pilot_aa, weights)
    first_type_errors.append(pvalue_aa < alpha)
    pvalue_ab = ttest_strat(control_ab, pilot_ab, weights)
    second_type_errors.append(pvalue_ab >= alpha)


part_first_type_errors = np.mean(first_type_errors)
part_second_type_errors = np.mean(second_type_errors)
print(f'part_first_type_errors = {part_first_type_errors:0.3f}')
print(f'part_second_type_errors = {part_second_type_errors:0.3f}')

Нека повторим синтетичните A/A и A/B експерименти, като използваме стратифицирана средна стойност като показател.

Получихме процент на грешки от тип I от 0,05 и процент на грешки от тип II от 0,035. Сега тестът контролира вероятността от грешка от тип I при даденото ниво на значимост, докато мощността му значително се е увеличила.

Стратификация и пост-стратификация

Така можем да различим няколко начина:

  1. образуване на група: а. случаен; b. стратифицирани.

2. средна оценка: a. извадкова средна стойност; b. стратифицирана средна стойност.

Освен това те могат да се комбинират в различни сценарии.

В основната версия ние произволно разпределяме потребителите между групите и изчисляваме средната стойност на извадката. Този подход може да не е оптимален, ако имаме допълнителна информация за пробите.

Стратификацията обикновено предполага едновременно използване на стратифицирана извадка за формиране на групи и стратифицирана средна стойност за оценка на средната стойност на извадките. Тоест в последния числен експеримент използвахме стратификация. Има ситуации, когато не е възможно да се проведе стратифицирана извадка, но се оказва, че в този случай все още можем да използваме стратифицирана средна стойност, за да получим оценка. Този подход се нарича пост-стратификация.

Нека проверим дали пост-стратификацията също повишава чувствителността на теста. Нека повторим последния експеримент, като заменим стратифицираната извадка със случайна извадка.

first_type_errors = []
second_type_errors = []


for _ in range(10000):
    control_aa, pilot_aa = get_random_data(
        strats, sample_size, strat_to_param
    )
    control_ab, pilot_ab = get_random_data(
        strats, sample_size, strat_to_param, effect
    )

    pvalue_aa = ttest_strat(control_aa, pilot_aa, weights)
    first_type_errors.append(pvalue_aa < alpha)
    pvalue_ab = ttest_strat(control_ab, pilot_ab, weights)
    second_type_errors.append(pvalue_ab >= alpha)


part_first_type_errors = np.mean(first_type_errors)
part_second_type_errors = np.mean(second_type_errors)
print(f'part_first_type_errors = {part_first_type_errors:0.3f}')
print(f'part_second_type_errors = {part_second_type_errors:0.3f}')

Получихме резултати, които бяха практически идентични с експеримента със стратификация, където беше използвана стратифицирана извадка. Това означава ли, че можем да пропуснем стратифицираната извадка и да използваме само стратифицирани средни стойности? Винаги ли е така? За да отговорим на тези въпроси, нека разгледаме стратификацията и пост-стратификацията от математическа гледна точка.

Нека изброим вариантите на средствата за всички разгледани методи. Колкото по-малка е дисперсията на показателя, толкова по-чувствителен е тестът.

Индексите „srs“ и „strat“ в дисперсията се отнасят до метода за вземане на проби от данни — обикновено произволно вземане на проби и съответно стратифицирано вземане на проби.

Дисперсията на показателя за стратификация и след стратификация се различава с O(1/n²). Това означава, че при достатъчно голям набор от данни разликите между тях ще бъдат минимални. В нашия пример размерите на групите бяха около 1000, което беше достатъчно, за да гарантира, че оценките на вероятността за грешка са практически еднакви.

Имайте предвид, че стратификацията само увеличава чувствителността на теста, когато има разлики в средните метрични стойности между стратите. Как избирате характеристиките за стратификация? Като цяло няма отговор на този въпрос, тъй като зависи до голяма степен от поставената задача и спецификата на данните. За да намерите най-оптималното решение, можете да опитате различни начини на стратификация и да ги тествате върху исторически данни, за да видите как влияят върху вероятността от грешка от тип I и чувствителността на теста. Въпреки това, не трябва да се увличате от настройката на параметрите, тъй като това може да доведе до изкуствено намаляване на дисперсията за сметка на фалшива корелация.

Заключение

Изследвахме стратификация и пост-стратификация. В нашия пример тези методи ни позволиха да намалим вероятността от грешка от тип II повече от 4 пъти, като същевременно контролираме вероятността от грешка от тип I на дадено ниво. На практика повишаването на чувствителността на тестовете позволява по-бързи експерименти и намиране на ефекти с по-малък размер, което е значително конкурентно предимство.