Вот тут ув. @dimview на пальцах и Си объясняет за бутстрап решая несложную задачу. И в статистике существует 100500 разных тестов для (не)подтверждения нулевой гипотезы.

Давайте используем ряд самых распространеных и посмотрим на результаты. В конце сравним с бутстрапом. Изложение будет сопровождаться кратким выводом и объяснением основных тестов, их "ручной" реализацией и сравнением результата с готовыми тестами из пакета scipy.stats. В этом плане, мне кажется, повторение лишним не будет, т.к. позволит лучше понять и уяснить принцип и особенности тестов.

Сама задача звучит как: "И вот свежие результаты — в тестовой группе из 893 пришедших у нас что-то купили 34, а в контрольной группе из 923 пришедших что-то купили 28. Возникает вопрос — идти к начальству и говорить «в тестовой группе конверсия 3.81%, в контрольной группе 3.03%, налицо улучшение на 26%, где моя премия?» или продолжать сбор данных, потому что разница в 6 человек — ещё не статистика?"

Небольшая ремарка о p-value и доверительных интервалах под катом.

Hidden text

Мы исходим из того, что читатель знаком с понятиями p-value (вики) и доверительного интервала. Но кратко покажем, как должен выглядеть ответ в нашем случае.

Предположим в бесконечном количестве параллельных вселенных произвели такой же тест с таким же объемом выборок. В нашем случае из 893х мы получили 34 продажи, в "другой вселенной" - 37, еще в одной - 28 и т.д.. То же самое и для контрольной выборки в 923 пользователя. Для каждого раза мы посчитаем разницу конверсий (конверсия - количество продаж/объем выборки). И получим истинное распределение этих разниц.

p-value = 0.05 означает, что наша разница конверсия попала в 5% самых больших разниц. Иными словами, вероятность получить такую или большую разницу случайно составляет всего лишь 0.05. И значит можем считать, что в тестовой группе действительно выше конверсия чем в контрольной. Если же p-value = 0.35, то значит получить такую разницу конверсий случайно - вполне вероятно. И мы не можем с уверенностью сказать, что конверсия в тестовой группе лучше. В целом же уровень доверия мы выбираем сами.

Касаемо доверительных интервалов. Например мы смотрим 95% доверительный интервал - значит 95% значений случайной величины попадут в него. Если речь идет про разницу конверсий, то нам важно чтобы в нее не попал 0 (если мы хотим подтвердить, что конверсия увеличилась). Иначе нулевая разница - высоко вероятное событие, что нас не устраивает.

В целом же понятия уровня значимости и доверительного интервала одни из основных в прикладной статистике. И лучше им уделить отдельное внимание.

Сравнение средних

Первый подход - рассмотреть продажи как 1, а остальные посещения как 0. И сравним конверсии (т.е. отношение положительных исходов к общему числу испытаний). И первое, приходит на ум - использовать двувыборочный t-тест.

Пусть число продаж в первом случае - k1 (=34), а всего пользователей n1 (=893). И k2, n2 соответственно для контрольной группы. Мы хотим посмотреть на распределение случайной величины k1/n1 - k2/n2 если бы эксперименты много раз проводились в параллельных мирах. Но при этом бы выполнялись условия: n1 и n2 были бы фиксированы, а k1 + k2 = const. Как раз в такой ситуации у нас возникает t-распределение.

Самое простое - сделать два массива и напрямую подставить в t-test из пакета scipy.stats и получить p-value.

import scipy.stats as ss
#Тестовая бырока
n1 = 893
k1 = 34
#Контрольная выборка
n2 = 923
k2 = 28

s1 = np.zeros(n1)
s1[:k1] = 1
s2 = np.zeros(n2)
s2[:k2] = 1
ss.ttest_ind(s1,s2)
#>>TtestResult(statistic=0.9075373501154826, pvalue=0.36424324967099464, df=1814.0)

#s1 и s2 - это представление нашей выборки в виде 1 - продаж
#и 0 - их отсутствия. Это нам понадобится еще и на бутстрапе.
print(s1[:70])
#>>[1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]

Напомню как устроен t-test. Мы смотрим на распределение разницы выборочных средних (двувыборочный t-критерий).

При сложении независимых случайных величин их дисперсии также складываются (D(\xi+\mu)=D\xi+D\mu). Поделив нашу разность на стандартное отклонение получим t-распределение.

t=\frac{ \overline{x_1}-\overline{x_2} } { \sqrt{ \frac{D_1}{n_1}+\frac{D_2}{n_2}} }

Количество степеней свободы df = n1+n2-2.

Нужно чтобы а) переменные имели схожую и конечную дисперсию б) распределение выборочных средних было похоже на нормальное. Обычно делают ошибку, когда говорят что исходные случайные величины должны быть нормально распределенными.

t-распределение похоже на гауссово, но имеет более тяжелые хвосты из-за того, что мы используем выборочную дисперсию в знаменателе. При при увеличении количество степеней свободы t-распределение стремится к нормальному (спойлер: их у нас тут много).

Давайте теперь посчитаем t-значение и найдем вероятность того, что разница будет такая или более выражена из-за случайности, а не потому, что у нас разные выборки с разной средней конверсией в их генеральных совокупностях.

Данные выражения получаются из элементарной арифметики, если подставить их в формулы для несмещенной дисперсии

d1 = k1/(n1-1)*(1-k1/n1)
d2 = k2/(n2-1)*(1-k2/n2)
D = d1+d2

m = k1/n1 - k2/n2
std = np.sqrt( d1/n1 + d2/n2 )
t = m/std
print('t-value: ',t)
df = n1+n2-2
print('p-value: ',1 - ss.t.cdf(t, df) )
>>t-value:  0.905901136585428
>>p-value:  0.18255424508928209

# Наше p-value получилось почти в 2 раза меньше, чем из теста.
# Потому что мы посчитали односторонее значение. 
# Для двустороннего значения, строго говоря, нужно вычислить вот такую штуку:
print('two-side p-value: ',ss.t.cdf(-t, df) + 1 - ss.t.cdf(t, df) )
>>two-side p-value:  0.36510849017856417

#Или неспортивно домжножить на 2, т.к. распределение симметричное 
print('two-side p-value: ', (1 - ss.t.cdf(t, df))*2 )
two-side p-value:  0.36510849017856417

#Ну и ради интереса сравним с нормальным распределением
print('two-side p-value: ', (1 - ss.norm.cdf(m, scale=std) )*2 )
>>two-side p-value:  0.3649881826944741

Как видим, значения p-value для нормального и t-распределений отличаются фактически в 4м знаке после запятой, т.к. количество степеней свободы оч. большое и можно не запариваться с t-распределением. Еще у теста из пакета scipy немного меньшее t-значение (не знаю, почему).

# Ради интереса доверительные интервалы 95% для разности средних
print(f'{ss.t.ppf(.025,df,loc=m,scale=std)*100:.3f}%; {ss.t.ppf(.975,df,loc=m,scale=std)*100:.3f}%')
>>-0.901%; 2.449%

# И для нормального
print(f'{ss.norm.ppf(.025,loc=m,scale=std)*100:.3f}%; {ss.norm.ppf(.975,loc=m,scale=std)*100:.3f}%')
>>-0.900%; 2.448%

Критерий

Ранее мы рассматривали задачу как случайную величину с некоторым средним. Теперь мы можем представить, что бросаем несимметричную монетку. Отличие ожидаемых частот оценивается расстоянием Пирсона \chi^2 = \sum \frac{(O-E)^2}{E}, где O и E - observed and expected частоты. И это расстояние имеет \chi^2 распределение.

Например мы бросаем монетку 60 раз. Мы ожидаем (expect), что орел выпадет 30 раз, 60-30 - выпадет решка.

А наблюдаем (observe) 20 решек и 40 орлов. Должно быть E(орла)=Е(решка)=30. Получили О(решка)=20, О(орла)=40.

Отсюда

\chi^2 = \left(\frac{20-30}{\sqrt{30}}\right)^2+\left(\frac{40-30}{\sqrt{30}}\right)^2

Теперь вернемся к нашей задаче. У нас есть 2 измерения с частотами. Какова вероятность, что распределения частот между ними на самом деле одинаковы, а наблюдаемая разница случайна (принимаем H_0 - нулевую гипотезу)? Порешаем прошлый пример.

Rоличество степеней свободы:df = (m-1)(n-1)
В данном случае равно (2-1)(2-1) = 2

# Найдем общее среднее.
# Можно интерпретировать это как количество положительных
# исходов на общее число испытаний, если в контрольной
# и тестовой выборках чатота на самом деле одинакова.
total_mean = (k1+k2)/(n1+n2)

#Теперь найдем частоты, какими бы они должны были
#быть с учетом дисбаланса классов
k1_e = n1*total_mean
k2_e = n2*total_mean
print(f'k1 expected = {k1_e:.1f}; k2 expected = {k2_e:.1f}')
#>>k1 expected = 30.5; k2 expected = 31.5

#Это для частот "промахов"
t1 = n1 - k1
t2 = n2 - k2

t1_e = n1*(t1+t2)/(n1+n2)
t2_e = n2*(t1+t2)/(n1+n2)

#Или можно просто использовать 1 - total_mean
#вместо (t1+t2)/(n1+n2)

print(f't1 expected = {t1_e:.1f}; t2 expected = {t2_e:.1f}')
#>>t1 expected = 862.5; t2 expected = 891.5

O = np.array([k1,k2,t1,t2])
E = np.array([k1_e,k2_e,t1_e,t2_e])

chi2 = sum( (O-E)**2/E )
print('расстояние chi2: ',chi2)
#>>расстояние chi2:  0.8241579182647449

print(f'p-value: {1 - ss.chi2(1).cdf(chi2):.3f}', )
#>>p-value: 0.364

Заметим, что ничего тут на 2 не умножаем, т.к. распределение несимметричное и одностороннее. Ну и воспользуемся тестом \chi^2 из scipy

#Тут мы убрали поправку Йейтса
res = ss.chi2_contingency( [[k1,k2],[n1-k1,n2-k2]], correction=False)
print(f'Расстояние хи2: {res.statistic:.3f}')
#>>Расстояние хи2: 0.824

print(f'p-value: {res.pvalue:.3f}')
#>>p-value: 0.364

Как видим, результаты в точности совпадют с таковыми для t-теста. Что не очень удивительно, учитывая эквивалентность этих тестов для матрицы сопряженности размером 2х2.

ANOVA

Однофакторный дисперсионный анализ.

В первом случае мы сравнивали средние значения и получали t-распределение. Во втором - дисперсии и получали хи2 распределение. В случае с anova мы будем сравнить отношение дисперсий и получим, соответственно, F-распределение.

Anova основана на том, что есть несколько категориальных переменных, которые в среднем принимают различные значения. В нашем случае - это тестовая и контрольная группа посетителей на сайт. Есть изменчивость данных, которая складывается из изменчивости внутри групп так и изменчивости между группами (внутригрупповая и межгрупповая дисперсии). На цифрах это вот так выглядит.

Пусть есть группы x_i^j, где j=1..M - количество групп (в нашем случае 2), а i=1..N - количество элементов в группе (в нашем случае 893 и 923).

Сначала мы находим общегрупповое среднее

 \bar{\bar{x}} =\frac{1}{MN} \sum_{i,j}  x_i^j

Потом найдем общегрупповую сумму квадратов

SST = \sum_{i,j} (x_i^j-\bar{\bar{x}})^2

SST раскладывается на SST=SSW+SSB

Теперь рассчитаем внутрегрупповую сумму квадратов

SSW = \sum_j \sum_i(x_i^j-\bar{x}^j)^2

Т.е. для каждой группы берется свое среднее {x}^j. df = N\cdot M-M . Т.е. общее количество элементов минус количество групп.

Теперь межгруппову сумму квадратов

SSB = \sum_j N_j( \bar{x}^j-\bar{\bar{x}} )^2

Каждое слагаемое домножили на количество элементов в группе (N_j). Количество степеней свободы df = M-1. Тут вроде понятно - знаем общегупповое среднее и пару средних - можем вычислить третье.

Теперь можно посчитать F значаение

F = \tfrac{ \dfrac{SSB}{M-1} }{ \dfrac{SSW}{N\cdot M-M} }

Т.е. тут мы смотрим на отношение межгрупповой дисперсии к внутригрупповой. Чем больше межгрупповая дисперсия и меньше внутригрупповая, тем более вероятно, что группы различны и нулевая гипотеза неверна.

mean1 = k1/n1
mean2 = k2/n2
total_mean = (k1+k2)/(n1+n2)

ssw = k1*(1-mean1)**2 + (n1-k1)*mean1**2 + k2*(1-mean2)**2 + (n2-k2)*mean2**2
df_ssw = n1+n2-2

ssb = n1*(mean1-total_mean)**2 + n2*(mean2-total_mean)**2
df_ssb = 2 - 1

F = ssb/df_ssb / (ssw/df_ssw)
print('F-value: ',F )
#>>F-value:  0.8236240418546322

print('P-value: ',1 - ss.f.cdf(F,df_ssb,df_ssw) )
#>>P-value:  0.36424324967094446

Получили ровно такое же p-value.

В отличии от χ² anova может сравнивать непрерывные данные (т.е. ему не обязательно частоты вычислять). И может проводить множественные сравнения в отличии от t-теста.

Про χ² на Хабре есть обстоятельная статья.

Точный тест Фишера

Точный тест Фишера как бы "в лоб" подсчитывает вероятность получить такие или более выраженные отклонения наблюдаемых значений при условии верности нулевой гипотезы. В нашем случае это то, что подвыборки (k1,n1) и и (k2,n2) получились из одной выборки.
Т.е. у нас есть k1+k2 положительных исходов в N = n1+n2 испытаниях.

Давайте построим матрицу

\begin{matrix}{}    k_1   & k_2   & k_1 + k_2\\    \nu_1 & \nu_2 & \nu_1+\nu_2\\    n_1   & n_2   & N\end{matrix}

Третья колонка и третий столбец фиксированный. Т.е. у нас фиксировано общее количество положительных/отрицательных исходов, количество экспериментов в каждом тесте (\nu_{1,2} = n_{1,2} - k_{1,2}) и, соответственно, общее количество экспериментов (показов).

H_0 гласит, что вероятность получить положительный исход в обоих выборках одинакова и равна p.

Первый шаг

Рассмотрим наш "процесс" как ряд событий.

Событие С: К нам пришли N=n1+n2 пользователей и k1+k2 из них у нас что-то купили. Вероятность этого события посчитается как C_N^{k_1+k_2}p^{k_1+k_2}(1-p)^{N}. Как видим - это выборка из Биномиального распределения с вероятностью продажи p.

Событие А: В тестовой группе было k1 продаж из n1 пользователей

\frac{n_1!}{k_1!(n_1-k_1)!}p^{k_1}(1-p)^{\nu_1} = C_{n_1}^{k_1}p^{k_1}(1-p)^{\nu_1}


Событие B: То же самое, только для пары (k2,n2)

Теперь нужно найти вероятность получить событие AB при условии, что произошло C: P(AB|C). Воспользуемся формулой Байеса и независимостью событий A и B.

P(AB|C) = \frac{P(C|AB)P(AB)}{P(C)} = \frac{P(AB)}{P(C)} = \frac{P(A)P(B)}{P(C)}

Тут применено соображение что P(C|AB)=1 т.к. AB является следствие C. Далее легко подставить наши вычисленные вероятности:

P(AB|C) = \frac{ C_{n_1}^{k_1}p^{k_1}(1-p)^{\nu_1}                    C_{n_2}^{k_2}p^{k_2}(1-p)^{\nu_2} }                  {C_{n_1+n_2}^{k_1+k_2}p^{k_1+k_2}(1-p)^{\nu_1+\nu_2}} =                   \frac{ C_{n_1}^{k_1}                    C_{n_2}^{k_2} }                  {C_{n_1+n_2}^{k_1+k_2}}$$  Это не что иное как гипергеометрическое распределение с параметрами $$ HG(k_1;n_1+n_2,k_1+k_2,n_1) =                                                                             \frac{ C_{k_1+k_2}^{k_1} C_{n_1+n_2-k_1-k_2}^{n_1-k_1} }                                                                            { С_{n_1+n_2}^{n_1} }

Убедиться в этом можно арифметически.

Мы меняем параметр k_1 на k_1' т.е. в первой группе было не 34 продажи, а 35 или 36 например. При этом должно быть соблюдено условие, где k_1 + k_2 = k_1' + k_2'. Т.е. если в первой группе было 35 продаж, то во второй - 27 вместо 28.

Второй шаг

Нам нужно найти вероятности больших отклонение чем мы наблюдаем сейчас при (!) верности нулевой гипотезы (т.е. что p в обоих выборках одинаков), с учетом того, что суммы строк и столбцов фиксированы (т.е. сумма положительных и отрицательных исходов фиксированы).

#Для начала убедимся, что у нас действительно гипергеометрическое распределене
print( math.comb(n1,k1)*math.comb(n2,k2)/math.comb(n1+n2,k1+k2) )
#>>0.06830756250854167

print( ss.hypergeom(n1+n2,k1+k2,n1).pmf(k1) )
#>>0.06830756250854171
#Использование такой формы избавляет нас от того,
#чтобы прибавлять к k1 в первом comb и вычитать из k2 во втором. 

Вероятность получить такое же или большее отклонение опишется как можно найти:

p_all = ss.hypergeom(n1+n2,k1+k2,n1).pmf(np.arange(0,63))
p_less = p_all[p_all <= ss.hypergeom(n1+n2,k1+k2,n1).pmf(34)]
print(f'''Двухсторонний тест: {p_less.sum():.4f} ''')
#>>Двухсторонний тест: 0.3694 

print(f'''Чрез функцию scipy: {ss.fisher_exact(np.array([[34,859],[28,895]])
                              ,alternative='two-sided')[1]:.4f} ''')
#>>Чрез функцию scipy: 0.3694 

# Ради интереса выведем аргументы k1, где вероятность такая же или меньше
np.array( [np.where(p_all==i)[0][0] for i in p_less] )
>>array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16,
       17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 34, 35, 36, 37, 38, 39, 40,
       41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57,
       58, 59, 60, 61, 62], dtype=int64)

Значение двухстороннего p-value почти точно совпадает с значениями двухсторонних тестов выше (хотя в тысячных различия есть). ANOVA c t-тестом и Хи2 фактически могут мерить только двухсторонние p-value (т.к. они симметричные/квадратичные).

Точный Фишер может и одностороннее значение померять, которое отличается на 0.035 сотых, что довольно не мало.

При "точном Фишере" не нужно особо забоититься о минимальных частотах, гауссовости выборочных средних. В общем там где считается быстро, данный тест выглядит привлекательно и легко интерпретируется.

Bootstrap для народа

Ну и давайте перейдем к, так сказать, численным методам. Вот мы получили две выборки. Давайте будем считать, что это и есть генеральная совокупность. Т.е. Данные у нас бинарные, можно представить их как 2 мешка с черными и белыми шарами, из которых мы вынимаем шары с возвращением. Соответсственно мы можем вынуть сколько угодно шаров. Это наша имитация генеральной совокупности.

Из нее мы можем делать выборки любой величины и получать эмпирические распределения любых величин.

Метод, при всей вычислительной затратности, обещает нам хорошие результаты где угодно. И не нужно запариваться на счет проверки на гауссовость и всего такого.

import plotly.express as px
from scipy.stats import bootstrap

Мы считаем, что наши продажи имеют номера от 0 до k1-1. И мы генерируем слуайные числа в диапазоне от 0 до n1-1. Количество числе < k1 будет количеством положительных исходов (продаж) в данной выборке в тестовой группе. То же самое в контрольной.

Мы делаем M таких выборок с возвращением в тестовой и контрольной группе. Каждый раз считаем разницу конверсий в группах, т.е. k_1^m/n_1 - k_2^m/n_2, где m=0...M-1. Это такая "логическая имитация" биномиального процесса

#Создали дефолтный генератор
rng = np.random.default_rng()
M = 9999
dlt = np.zeros(M)
m = k1/n1 - k2/n2
for i in range(M):    
    smpl1 = rng.integers(n1,size=n1)
    k11 = (smpl1<k1).sum()
    smpl2 = rng.integers(n2,size=n2)
    k12 = (smpl2<k2).sum()
    dlt[i] = k11/n1 - k12/n2
print('p-value: ', (  (dlt - m >= m) | (dlt - m <= -m) ).sum()/M )
#>>p-value:  0.36503650365036505

Можно и напрямую нагенерировать выборок из биномиального распределения

sales1 = rng.binomial(n1,k1/n1,M)
sales2 = rng.binomial(n2,k2/n2,M)
dlt2 = sales1/n1 - sales2/n2
print('p-value: ', (  (dlt2 - m >= m) | (dlt2 - m <= -m) ).sum()/M )
#>>p-value:  0.35733573357335735

Или с помощью функции scipy

def difmean(s1,s2):
    return s1.sum()/s1.shape[0] - s2.sum()/s2.shape[0]
res = bootstrap((s1,s2), difmean, confidence_level=0.95, n_resamples=M, )
dlt3 = res.bootstrap_distribution
print('p-value: ', (  (dlt3 - m >= m) | (dlt3 - m <= -m) ).sum()/M )
#>>p-value:  0.3611361136113611

Этот подход идентичен тому, на который я ссылался в самом начале статьи.

Да, p-значения получились такими, какими мы и ожидали. Но в обоих случаях мы смещаем наше распределение на m. Это потому что мы генерируем наши выборки из двух имитаций генеральных совокупностей. И это, вообще говоря, не такой уж и интерпретируемый результат.

Если верна нулевая гипотеза, то на самом деле у нас одна генеральная совокупность с k1+k2 положительными исходами при n1+n2 испытаниях. Вообще говоря все тесты выше из этого и исходят. Тогда можно модифицировать пример следующим образом:

#Тут нам не нужно ничего никуда смещать.
sales1 = rng.binomial(n1,(k1+k2)/(n1+n2),M)
sales2 = rng.binomial(n2,(k1+k2)/(n1+n2),M)
dlt4 = sales1/n1 - sales2/n2
print('p-value: ', ( (dlt4 >= m) | (dlt4 <= - m) ).sum()/M )
#>>p-value:  0.3625362536253625
px.histogram({'bootstrap':res.bootstrap_distribution-m
              ,'craftbootstrap':dlt4}
             ,title='Распределение разности конверсий'
             ,histnorm='probability'
             ,barmode="overlay"
             ,nbins=500)

Так выглядит наше бутстрап-распределение. Понятно, почему оно изрезанное - т.к. конверсия на самом деле дискретна и это образует вот такую картину.

Понятно, что для нашего примера применять бутстрап странно. Но давайте ради интереса посмотрим, например, на распределение отношения конверсий. Каким оно должно быть в случае верности нулевой гипотезы.

sales1 = rng.binomial(n1,k1/n1,M)
sales2 = rng.binomial(n2,k2/n2,M)
dlt5 = ( sales1/n1 / ( sales2/n2 ) )
px.histogram({'craftbootstrap':dlt5}
             ,title='Распределение отношения конверсий'
             ,histnorm='probability'
             ,barmode="overlay"
             ,nbins=100)

Так же можно получить распределение любой другой функции, если вдруг потребуется

Итого

Бывает смотришь на забор. В нем конечно есть какие-то щелочки, но ничего не видно. Стоит поехать вдоль забора, как сразу вырисовывается картина. Это называется синтез апертуры. Буквально посмотреть на один и тот же объект под разными углами. Цель этой работы как раз в этом и заключается - посмотреть с разных точек зрения на одну и ту же задачу.

Комментарии (2)


  1. CBET_TbMbI
    18.05.2024 15:27
    +8

    Что-то за всей этой математикой утерян её смысл

    Возникает вопрос — идти к начальству и говорить «в тестовой группе конверсия 3.81%, в контрольной группе 3.03%, налицо улучшение на 26%, где моя премия?» или продолжать сбор данных, потому что разница в 6 человек — ещё не статистика?

    Так где ответ на этот вопрос в каждом из пяти этих способов? На какое цифры смотреть и как решить просить премии или продолжения наблюдения?


    1. vasilymat Автор
      18.05.2024 15:27
      +3

      А вот это хорошее замечание, спасибо. Я исходил из того, что читателю известно что такое p-value. Но нигде не написал об этом/не сделал описания этого понятия. Как освобожусь, добавлю.