SQL для cohort retention analysis
Что такое cohort retention
Когортный retention — один из главных инструментов продуктовой аналитики. Он показывает, какой процент пользователей, пришедших в определённое время, возвращается в продукт через N дней/недель.
Пример: когорта пользователей, зарегистрировавшихся 1 апреля. Из 1000 человек через 7 дней активны 350. D7 retention = 35%.
Разные когорты сравниваются между собой. Если retention новой когорты упал — что-то изменилось: релиз, качество трафика, сезонность.
Построить эту матрицу в SQL — навык, который спрашивают на любом продуктовом собесе.
Базовый подход: две CTE
Классическая структура запроса:
WITH cohort AS (
-- Определяем когорту каждого пользователя
SELECT
user_id,
DATE_TRUNC('week', MIN(event_time))::DATE AS cohort_week
FROM events
GROUP BY user_id
),
activity AS (
-- Активности каждого пользователя с указанием когорты
SELECT
c.cohort_week,
c.user_id,
DATE_TRUNC('week', e.event_time)::DATE AS active_week
FROM cohort c
JOIN events e USING (user_id)
)
SELECT
cohort_week,
EXTRACT(WEEK FROM active_week - cohort_week)::int AS weeks_since_signup,
COUNT(DISTINCT user_id) AS active_users
FROM activity
GROUP BY cohort_week, weeks_since_signup
ORDER BY cohort_week, weeks_since_signup;Результат — long format. Для каждой когорты и каждой недели со starttime показано число активных.
Для визуализации обычно разворачивают в широкий формат (pivot). Это удобнее сделать в pandas или BI-инструменте, чем в SQL.
Retention в процентах
Предыдущий запрос даёт абсолютные числа. Для процентов нужно разделить на размер когорты:
WITH cohort AS (
SELECT user_id, DATE_TRUNC('week', MIN(event_time))::DATE AS cohort_week
FROM events GROUP BY user_id
),
cohort_size AS (
SELECT cohort_week, COUNT(*) AS size FROM cohort GROUP BY cohort_week
),
activity AS (
SELECT
c.cohort_week,
c.user_id,
EXTRACT(WEEK FROM DATE_TRUNC('week', e.event_time)::DATE - c.cohort_week)::int AS weeks_since
FROM cohort c JOIN events e USING (user_id)
)
SELECT
a.cohort_week,
a.weeks_since,
COUNT(DISTINCT a.user_id) AS active_users,
cs.size AS cohort_size,
ROUND(COUNT(DISTINCT a.user_id) * 100.0 / cs.size, 1) AS retention_pct
FROM activity a
JOIN cohort_size cs USING (cohort_week)
GROUP BY a.cohort_week, a.weeks_since, cs.size
ORDER BY a.cohort_week, a.weeks_since;Retention_pct всегда 100% на неделе 0 (все из когорты были активны в свою неделю). Дальше падает.
Classic vs rolling retention
Есть разные определения retention.
Classic (N-day) retention. Пользователь активен ровно в день N после регистрации.
-- D7 classic retention
SELECT
COUNT(DISTINCT a.user_id) * 100.0 / (SELECT COUNT(*) FROM cohort) AS d7_classic
FROM cohort c
JOIN activity a ON a.user_id = c.user_id
AND a.active_day = c.cohort_day + INTERVAL '7 day';Rolling retention. Пользователь активен в день N или позже.
-- D7 rolling retention
SELECT
COUNT(DISTINCT a.user_id) * 100.0 / (SELECT COUNT(*) FROM cohort) AS d7_rolling
FROM cohort c
JOIN activity a ON a.user_id = c.user_id
AND a.active_day >= c.cohort_day + INTERVAL '7 day';Unbounded retention. Активен хотя бы раз после регистрации. Только один раз снизу граница.
Разные определения дают разные цифры. Для одних и тех же данных classic D7 может быть 20%, rolling D7 — 35%, unbounded — 45%. Всегда уточняйте, какое определение используется.
Разбираться в тонкостях когортного анализа — основа продуктовой аналитики. В тренажёре Карьерник есть задачи на когорты разной сложности с разборами.
Retention matrix в одном запросе
Для широкого формата (который отображается как таблица), можно использовать CASE WHEN:
WITH cohort AS (
SELECT user_id, DATE_TRUNC('week', MIN(event_time))::DATE AS cohort_week
FROM events GROUP BY user_id
),
retention AS (
SELECT
c.cohort_week,
c.user_id,
BOOL_OR(e.event_time >= c.cohort_week + INTERVAL '1 week'
AND e.event_time < c.cohort_week + INTERVAL '2 week') AS w1,
BOOL_OR(e.event_time >= c.cohort_week + INTERVAL '2 week'
AND e.event_time < c.cohort_week + INTERVAL '3 week') AS w2,
BOOL_OR(e.event_time >= c.cohort_week + INTERVAL '3 week'
AND e.event_time < c.cohort_week + INTERVAL '4 week') AS w3,
BOOL_OR(e.event_time >= c.cohort_week + INTERVAL '4 week'
AND e.event_time < c.cohort_week + INTERVAL '5 week') AS w4
FROM cohort c
LEFT JOIN events e USING (user_id)
GROUP BY c.cohort_week, c.user_id
)
SELECT
cohort_week,
COUNT(*) AS cohort_size,
SUM(CASE WHEN w1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS w1_retention,
SUM(CASE WHEN w2 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS w2_retention,
SUM(CASE WHEN w3 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS w3_retention,
SUM(CASE WHEN w4 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS w4_retention
FROM retention
GROUP BY cohort_week
ORDER BY cohort_week;Результат сразу в широком формате: строки — когорты, столбцы — W1, W2, W3, W4 retention.
Retention с сегментацией
Часто нужно сравнить retention по каналам или платформам. Добавляем сегмент в когорту:
WITH cohort AS (
SELECT
u.user_id,
u.acquisition_channel,
DATE_TRUNC('week', u.registered_at)::DATE AS cohort_week
FROM users u
)
SELECT
c.cohort_week,
c.acquisition_channel,
COUNT(DISTINCT c.user_id) AS cohort_size,
COUNT(DISTINCT CASE
WHEN a.active_date >= c.cohort_week + INTERVAL '7 day'
AND a.active_date < c.cohort_week + INTERVAL '14 day'
THEN a.user_id END
) AS d7_active
FROM cohort c
LEFT JOIN activity a USING (user_id)
GROUP BY c.cohort_week, c.acquisition_channel
ORDER BY c.cohort_week, d7_active DESC;Теперь видно retention по каждому каналу привлечения. Обычно organic имеет лучший retention, чем performance.
Ловушки
Новые когорты ещё не имеют данных. Когорта этой недели не может иметь W4 retention — прошло меньше 4 недель. Для retention matrix это приведёт к NULL или 0 в правом верхнем углу.
Решение — фильтровать когорты по возрасту:
WHERE cohort_week <= CURRENT_DATE - INTERVAL '4 week'Оставлять только когорты, которым хватило времени.
Неправильная работа с timezone. Если события в UTC, а бизнес в МСК, граница «день регистрации» отличается на 3 часа. Для пользователей, зарегистрированных в 23:30 МСК, retention D7 будет посчитан неправильно. Приводите к бизнес-timezone через AT TIME ZONE.
Активность ≠ ценность. Retention на «открытие приложения» может быть 50%, но если пользователи только открывают и закрывают — это плохой retention. Лучше считать retention на «meaningful action» — совершил покупку, послал сообщение, посмотрел N контента.
Продвинутые паттерны
Weekly cohorts vs monthly. Для продукта с долгим lifecycle (SaaS B2B, страховка) weekly слишком шумные. Используйте monthly.
Rolling cohorts. Вместо строгого «когорта 1 апреля» можно «когорта первой недели апреля», «первой декады» и т.д. Сглаживает.
Retention curves for visualization. Для графиков — линия на когорту, где y — % retention, x — дни с регистрации. Видно, какие когорты лучше/хуже.
Compare pre/post launch. Разбить когорты на «до релиза» и «после», сравнить retention curves. Если пост-релизные выше — фича работает.
pandas для дальнейшей обработки
SQL даёт long format, для визуализации удобнее pandas pivot:
import pandas as pd
df = pd.read_sql("""
-- SQL из примера выше
""", conn)
cohort_matrix = df.pivot(
index='cohort_week',
columns='weeks_since',
values='retention_pct'
)
# Красивая визуализация
import seaborn as sns
import matplotlib.pyplot as plt
plt.figure(figsize=(14, 8))
sns.heatmap(cohort_matrix, annot=True, fmt='.0f', cmap='YlGnBu')
plt.title('Retention Cohort Heatmap')
plt.show()Heatmap — классический способ показать retention матрицу. Строки — когорты, столбцы — недели, цвет — процент retention.
Читайте также
FAQ
Weekly или monthly когорты?
Weekly для молодых продуктов (до года). Monthly для зрелых с длинным lifecycle. Daily — только для очень интенсивных продуктов вроде игр.
Как считать, если у пользователя несколько first-events разных типов?
Зависит от определения «начала». Регистрация, первая покупка, первая значимая активность — разные когорты. Выберите одно и следуйте.
Что делать с пользователями-ботами?
Фильтровать в cohort CTE. Обычно есть флаг is_bot или user_type = 'real'.
Retention упал — это всегда плохо?
Не всегда. Если вы агрессивно привлекаете новую аудиторию, новые когорты могут иметь хуже retention, чем старые. Смотрите total active users — они могут расти, несмотря на падающий retention.