A/B-тесты в PostgreSQL
Привет, Хабр! Сегодня разберём, как реализовать A/B‑тестирование на чистом PostgreSQL, без выгрузки данных в сторонние системы. Рассмотрим полный цикл: от структуры таблиц и оптимизации запросов до статистического анализа (T‑тест, Манна‑Уитни, байесовские методы) и визуализации результатов.
Постановка задачи и проектирование базы
Начнём с основ. Представим, что мы отвечаем за A/B‑тест нового лендинга. Заказчик требует реального времени, а выгрузка в сторонние BI‑системы — это вообще не про нас. Так что нужно построить базу, где все данные будут сразу готовы к анализу.
Для начала создадим таблицу, которая будет собирать все события пользователей. Вот как выглядит базовая структура:
-- Создаем таблицу для хранения событий A/B-теста
CREATE TABLE ab_test_events (
event_id SERIAL PRIMARY KEY, -- Уникальный идентификатор события
user_id INTEGER NOT NULL, -- Идентификатор пользователя
variant CHAR(1) NOT NULL, -- Вариант теста: 'A' или 'B'
event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Время события
event_type TEXT NOT NULL, -- Тип события (например, 'page_view', 'click', 'submit')
event_payload JSONB NOT NULL -- Дополнительные данные (например, время до конверсии, метаданные)
);
Используем JSONB для event_payload — потому что иногда пользователи творят что угодно: один день клик, другой — заполняют анкету с кучей метаданных. Гибкость и масштабируемость важнее, чем жесткая структура.
Пример данных:
INSERT INTO ab_test_events (user_id, variant, event_type, event_payload) VALUES
('550e8400-e29b-41d4-a716-446655440000', 'A', 'submit', '{"conversion": "1"}'),
('550e8400-e29b-41d4-a716-446655440001', 'B', 'submit', '{"conversion": "0"}'),
('550e8400-e29b-41d4-a716-446655440002', 'A', 'submit', '{"conversion": "1"}'),
('550e8400-e29b-41d4-a716-446655440003', 'B', 'submit', '{"conversion": "1"}');
Никакой крутой код не спасёт ситуацию, если запросы выполняются медленно. Поэтому создаем GIN‑индекс для быстрого поиска по JSONB.
-- Индекс для ускорения поиска по полям внутри JSONB
CREATE INDEX idx_event_payload ON ab_test_events USING gin (event_payload);
Агрегация данных:
После того как данные начали сыпаться, самое время собрать статистику. Ведь что толку в красивой таблице, если мы не можем понять, какой вариант лендинга работает лучше? Для нас критичен показатель конверсии — событие типа submit
означает успешную регистрацию.
Приведу пример запроса, который группирует данные по вариантам теста и вычисляет конверсию:
-- Агрегируем данные по вариантам A/B-теста
SELECT
variant, -- Группируем по варианту ('A' или 'B')
COUNT(*) AS total_events, -- Общее количество событий
COUNT(*) FILTER (WHERE event_type = 'submit') AS total_conversions, -- События регистрации
COUNT(*) FILTER (WHERE event_type = 'submit')::numeric / COUNT(*) AS conversion_rate -- Расчет конверсии
FROM ab_test_events
GROUP BY variant;
Фильтрация через конструкцию FILTER позволяет сразу получить нужные подсчеты.
Иногда хочется знать не только конверсию, но и дополнительные детали — например, сколько времени пользователи тратят на регистрацию. Допустим, параметр time_to_submit
хранится в event_payload
. Тогда запрос будет таким:
-- Вычисляем среднее время до регистрации для каждой группы
SELECT
variant,
AVG((event_payload->>'time_to_submit')::numeric) AS avg_time_to_submit -- Приводим строку к числу
FROM ab_test_events
WHERE event_payload ? 'time_to_submit' -- Проверяем наличие ключа в JSONB
GROUP BY variant;
Оператор ?
позволяет отфильтровать данные и не портить статистику нулевыми значениями.
Важно учитывать случаи, которые могут исказить результаты. Перед расчетом тестов нужно проверить, есть ли аномальные пользователи:
SELECT user_id, COUNT(*)
FROM ab_test_events
WHERE event_type = 'submit'
GROUP BY user_id
HAVING COUNT(*) > 10;
Если у одного пользователя слишком много событий, возможно, он тестирует систему, а не является реальным пользователем.
Иногда вариант A имеет больше пользователей, чем B. Проверяем:
SELECT variant, COUNT(*) AS users
FROM ab_test_events
WHERE event_type = 'submit'
GROUP BY variant;
Если разница > 10%, тесты могут быть смещены.
Если пользователи видели A и B, это может создать шум в данных:
SELECT user_id, COUNT(DISTINCT variant)
FROM ab_test_events
WHERE event_type = 'submit'
GROUP BY user_id
HAVING COUNT(DISTINCT variant) > 1;
Таких пользователей можно исключить из анализа.
Освоить мощные навыки анализа данных (анализ требований + статистика + BI) можно на курсе «Аналитик данных».
Статистический анализ: тесты, доверительные интервалы и байесовский разбор
T-тест для оценки разницы в конверсии
T‑тест применяется, если распределение данных нормально. Проверим это:
Прежде чем применять T‑тест, важно убедиться, что данные нормально распределены:
SELECT variant, AVG((event_payload->>'conversion')::numeric) AS avg_conversion,
STDDEV((event_payload->>'conversion')::numeric) AS stddev_conversion
FROM ab_test_events
WHERE event_type = 'submit'
GROUP BY variant;
Если стандартное отклонение слишком велико, T‑тест может быть неточным.
Теперь применим T‑тест:
WITH stats AS (
SELECT
variant,
AVG((event_payload->>'conversion')::numeric) AS avg_conversion,
VARIANCE((event_payload->>'conversion')::numeric) AS variance,
COUNT(*) AS cnt
FROM ab_test_events
WHERE event_type = 'submit'
GROUP BY variant
)
SELECT
a.avg_conversion AS conversion_A,
b.avg_conversion AS conversion_B,
(a.avg_conversion - b.avg_conversion) /
sqrt((a.variance / a.cnt) + (b.variance / b.cnt)) AS t_statistic
FROM stats a, stats b
WHERE a.variant = 'A' AND b.variant = 'B';
Если t_statistic
> 2 или < -2, разница между группами A и B статистически значима. Если t_statistic
ближе к 0, различие случайное.
Манна-Уитни
Если данные не подчиняются нормальному распределению (например, если конверсии редкие или неравномерные), применяется тест Манна‑Уитни.
Перед запуском теста, проверим:
SELECT variant, COUNT(*) AS cnt,
SUM((event_payload->>'conversion')::numeric) AS total_conversions
FROM ab_test_events
WHERE event_type = 'submit'
GROUP BY variant;
Если между группами A и B большая разница в количестве наблюдений, Манна‑Уитни предпочтительнее T‑теста.
Манна‑Уитни работает по ранжированию значений:
WITH ranked AS (
SELECT
user_id,
variant,
(event_payload->>'conversion')::numeric AS conversion,
RANK() OVER (ORDER BY (event_payload->>'conversion')::numeric) AS rnk
FROM ab_test_events
WHERE event_type = 'submit'
)
SELECT
variant,
SUM(rnk) AS total_rank,
COUNT(*) AS cnt
FROM ranked
GROUP BY variant;
Если суммы рангов сильно различаются, разница между вариантами значима.
Байесовский анализ
Байесовский анализ не только оценивает вероятность, но и показывает распределение вероятности конверсии.
Сначала вычислим 95% доверительный интервал:
SELECT
variant,
PERCENTILE_CONT(0.025) WITHIN GROUP (ORDER BY (event_payload->>'conversion')::numeric) AS lower_bound,
PERCENTILE_CONT(0.975) WITHIN GROUP (ORDER BY (event_payload->>'conversion')::numeric) AS upper_bound
FROM ab_test_events
WHERE event_type = 'submit'
GROUP BY variant;
Теперь применим PL/Python для моделирования вероятностей.
Создадим функцию:
CREATE OR REPLACE FUNCTION beta_distribution(alpha numeric, beta numeric)
RETURNS TABLE(x numeric, pdf numeric)
AS $$
import numpy as np
from scipy.stats import beta as beta_dist
x = np.linspace(0, 1, 100)
pdf = beta_dist.pdf(x, alpha, beta)
return list(zip(x, pdf))
$$ LANGUAGE plpython3u;
Вызовем функцию:
SELECT * FROM beta_distribution(5, 2);
Чем выше alpha и beta, тем уже доверительный интервал.
Если распределения сильно пересекаются, разница между группами незначительна.
Если variant A имеет высокий пик в beta‑распределении, он значительно лучше variant B.
Оптимизация производительности
Оптимизация — это не разовое действие, а постоянный процесс.
Partial indexes и BRIN-индексы
Если ваша таблица растёт до гигантских размеров, индексировать всё подряд не стоит. Частичные индексы — наше всё для актуальных данных.
-- Частичный индекс для ускорения выборок по последним 30 дням
CREATE INDEX idx_recent_events ON ab_test_events (event_time)
WHERE event_time > NOW() - INTERVAL '30 days';
Если данных слишком много, можно применить BRIN‑индексы, которые работают быстрее и экономят место:
-- BRIN-индекс для эффективного сканирования временных данных
CREATE INDEX idx_brin_time ON ab_test_events USING brin (event_time);
GROUPING SETS: многомерная агрегация в одном запросе
Когда требуется собрать несколько уровней агрегации (например, по дням и по вариантам), GROUPING SETS приходят на помощь:
-- Используем GROUPING SETS для получения детальной и сводной статистики
SELECT
variant,
DATE_TRUNC('day', event_time) AS day,
COUNT(*) AS total_events
FROM ab_test_events
GROUP BY GROUPING SETS (
(variant, DATE_TRUNC('day', event_time)), -- Статистика по каждому дню для каждого варианта
(variant) -- Общая статистика по каждому варианту
);
GROUPING SETS позволяют одним запросом получить сразу несколько уровней агрегации.
Визуализация результатов
Первым делом подключаемся к PostgreSQL, выполняем запрос и загружаем данные в DataFrame.
import psycopg2
import pandas as pd
import matplotlib.pyplot as plt
# Устанавливаем соединение с базой данных
conn = psycopg2.connect(
host="localhost", # Адрес сервера базы данных
database="production_db", -- Имя базы данных
user="prod_user", # Пользователь базы данных
password="supersecret" # Пароль (в продакшене храните в секьюрном хранилище!)
)
# SQL-запрос для получения агрегированных данных по вариантам
query = """
WITH aggregated AS (
SELECT
variant,
COUNT(*) AS total_events,
COUNT(*) FILTER (WHERE event_type = 'submit') AS total_conversions,
COUNT(*) FILTER (WHERE event_type = 'submit')::numeric / COUNT(*) AS conversion_rate,
STDDEV((event_payload->>'conversion')::numeric) AS std_dev
FROM ab_test_events
GROUP BY variant
)
SELECT * FROM aggregated;
"""
# Загружаем данные в DataFrame
df = pd.read_sql(query, conn)
conn.close()
# Выводим данные для проверки
print(df)
А теперь построим график:
import matplotlib.pyplot as plt
import pandas as pd
# Симулируем данные, как если бы они были получены из запроса
data = {
'variant': ['A', 'B'],
'conversion_rate': [0.15, 0.22], # Пример конверсии: 15% для A и 22% для B
'std_dev': [0.02, 0.03] # Пример стандартного отклонения
}
df = pd.DataFrame(data)
# Создаем график
plt.figure(figsize=(10, 6))
bars = plt.bar(df['variant'], df['conversion_rate'], yerr=df['std_dev'], capsize=8)
# Настраиваем оси и заголовок
plt.xlabel('Вариант теста')
plt.ylabel('Конверсия')
plt.title('Результаты A/B-теста: Конверсия по вариантам')
plt.grid(axis='y', linestyle='--', alpha=0.7)
plt.ylim(0, 0.3) # Устанавливаем лимиты по оси Y
# Добавляем числовые аннотации над столбиками
for bar in bars:
height = bar.get_height()
plt.text(bar.get_x() + bar.get_width()/2, height + 0.01, f'{height:.2%}',
ha='center', va='bottom')
plt.show()

В заключение всем, кому интересна сфера аналитики данных, рекомендую посетить открытый урок 25 марта «Базовый сбор требований в работе дата-аналитика».
Рассмотрим жизненный цикл задачи в анализе данных, обсудим, какие ключевые вопросы необходимо задать и как грамотно зафиксировать ответы. Также разберем, как справляться с возражениями и избегать лишнего стресса в процессе. Записаться