Урок курса
Расчёт retention и горизонт наблюдения
Продвинутый SQL: сложные запросы и задачи для собеседованийАрифметика календарных месяцев: вычисление номера месяца активности
Для расчёта когортного удержания (retention) каждое последующее действие клиента нужно соотнести с моментом его первой покупки или регистрации. Это смещение выражают не числом прошедших дней, а порядковым номером календарного месяца относительно старта когорты: нулевой месяц — месяц входа, первый — следующий календарный месяц и так далее.
Разница дней, делённая на 30 или 30.4375, здесь непригодна. Февраль содержит 28 или 29 дней, а действия от 31 января и 1 февраля разделены лишь одним днём, хотя принадлежат разным календарным месяцам. Чтобы получить точное целочисленное смещение month_number, вычисляют разность лет, умноженную на 12, и складывают с разностью номеров месяцев.
Подготовим схему данных для расчёта:
DROP TABLE IF EXISTS cohort_sizes;
DROP TABLE IF EXISTS user_cohorts;
DROP TABLE IF EXISTS user_actions;
DROP TABLE IF EXISTS month_grid;
CREATE TABLE cohort_sizes (
cohort_month TEXT PRIMARY KEY,
cohort_size INTEGER NOT NULL
);
CREATE TABLE user_cohorts (
user_id INTEGER PRIMARY KEY,
cohort_month TEXT NOT NULL
);
CREATE TABLE user_actions (
action_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
action_date TEXT NOT NULL
);
CREATE TABLE month_grid (
month_number INTEGER PRIMARY KEY
);
INSERT INTO cohort_sizes (cohort_month, cohort_size) VALUES
('2024-01-01', 4),
('2024-02-01', 2);
INSERT INTO user_cohorts (user_id, cohort_month) VALUES
(101, '2024-01-01'),
(102, '2024-01-01'),
(103, '2024-01-01'),
(104, '2024-01-01'),
(201, '2024-02-01'),
(202, '2024-02-01');
INSERT INTO user_actions (action_id, user_id, action_date) VALUES
(1, 101, '2024-01-10'),
(2, 102, '2024-01-15'),
(3, 103, '2024-01-20'),
(4, 104, '2024-01-25'),
(5, 101, '2024-02-05'),
(6, 102, '2024-02-14'),
(7, 201, '2024-02-02'),
(8, 202, '2024-02-18'),
(9, 201, '2024-03-12'),
(10, 201, '2024-03-20');
INSERT INTO month_grid (month_number) VALUES
(0), (1), (2), (3);
Соединим пользователей с их действиями и вычислим month_number для каждого события:
SELECT DISTINCT
uc.user_id,
uc.cohort_month,
strftime('%Y-%m-01', ua.action_date) AS activity_month,
(CAST(strftime('%Y', ua.action_date) AS INTEGER) - CAST(strftime('%Y', uc.cohort_month) AS INTEGER)) * 12
+ (CAST(strftime('%m', ua.action_date) AS INTEGER) - CAST(strftime('%m', uc.cohort_month) AS INTEGER)) AS month_number
FROM user_cohorts uc
JOIN user_actions ua ON uc.user_id = ua.user_id
ORDER BY uc.cohort_month, uc.user_id, activity_month;
Результат запроса:
| user_id | cohort_month | activity_month | month_number |
|---|---|---|---|
| 101 | 2024-01-01 | 2024-01-01 | 0 |
| 101 | 2024-01-01 | 2024-02-01 | 1 |
| 102 | 2024-01-01 | 2024-01-01 | 0 |
| 102 | 2024-01-01 | 2024-02-01 | 1 |
| 103 | 2024-01-01 | 2024-01-01 | 0 |
| 104 | 2024-01-01 | 2024-01-01 | 0 |
| 201 | 2024-02-01 | 2024-02-01 | 0 |
| 201 | 2024-02-01 | 2024-03-01 | 1 |
| 202 | 2024-02-01 | 2024-02-01 | 0 |
В строке пользователя 201 действие 2024-03-12 даёт activity_month = '2024-03-01'. При вычитании cohort_month = '2024-02-01' разность лет равна (2024 - 2024) * 12 = 0, а разность месяцев 3 - 2 = 1. Итоговый month_number равен 1. Для января этой же когорты вычисление не производилось, так как первое действие зафиксировано в феврале. Формула корректно работает и при переходе через границы календарного года: например, для января 2025 года разность лет (2025 - 2024) * 12 = 12, а разность месяцев 1 - 2 = -1, что даёт 12 + (-1) = 11 месяцев смещения.
Горизонт наблюдения на дату среза и разделение истинного нуля от недоступного будущего
Дата среза отчёта (cutoff_date) задаёт предел времени, за который в базе накоплены факты. Если анализ проводится по состоянию на 31 марта 2024 года (cutoff_month = '2024-03-01'), то мартовские события январской когорты соответствуют номеру 2: они уже могли произойти, но никто из пользователей действий не совершил. Это наблюдённый нулевой retention.
Иная ситуация у февральской когорты. Месяц 0 для неё — февраль, месяц 1 — март. Месяц 2 для февральской когорты наступит только в апреле 2024 года. На дату среза 31 марта апрель физически ещё не наступил. Если в аналитической витрине для февральской когорты на втором месяце вывести 0%, это исказит картину: будущий период будет ошибочно представлен как стопроцентный отток пользователей.
Посмотрим, к чему приводит наивное объединение когорт со справочником номеров месяцев через CROSS JOIN с последующей заменой пропусков через COALESCE(..., 0) без учёта горизонта среза:
WITH user_act_months AS (
SELECT DISTINCT
uc.cohort_month,
uc.user_id,
(CAST(strftime('%Y', ua.action_date) AS INTEGER) - CAST(strftime('%Y', uc.cohort_month) AS INTEGER)) * 12
+ (CAST(strftime('%m', ua.action_date) AS INTEGER) - CAST(strftime('%m', uc.cohort_month) AS INTEGER)) AS month_number
FROM user_cohorts uc
JOIN user_actions ua ON uc.user_id = ua.user_id
),
activity_totals AS (
SELECT cohort_month, month_number, COUNT(DISTINCT user_id) AS active_users
FROM user_act_months
GROUP BY cohort_month, month_number
)
SELECT
cs.cohort_month,
mg.month_number,
COALESCE(act.active_users, 0) AS active_users,
ROUND(1.0 * COALESCE(act.active_users, 0) / cs.cohort_size, 4) AS naive_retention
FROM cohort_sizes cs
CROSS JOIN month_grid mg
LEFT JOIN activity_totals act
ON cs.cohort_month = act.cohort_month AND mg.month_number = act.month_number
WHERE mg.month_number <= 2
ORDER BY cs.cohort_month, mg.month_number;
Результат выполнения с ошибкой интерпретации:
| cohort_month | month_number | active_users | naive_retention |
|---|---|---|---|
| 2024-01-01 | 0 | 4 | 1.0 |
| 2024-01-01 | 1 | 2 | 0.5 |
| 2024-01-01 | 2 | 0 | 0.0 |
| 2024-02-01 | 0 | 2 | 1.0 |
| 2024-02-01 | 1 | 1 | 0.5 |
| 2024-02-01 | 2 | 0 | 0.0 |
В строке ('2024-02-01', 2) появился показатель 0.0. Пользователи февральской когорты не могли совершить покупки в апреле, поскольку срез зафиксирован концом марта. Подстановка нуля исказила метрику: отсутствие календарной возможности приравнено к оттоку.
Предел наблюдения рассчитывается индивидуально для каждой когорты:
$$\text{max_month} = (Y_{\text{cutoff}} - Y_{\text{cohort}}) 12 + (M_{\text{cutoff}}
- M_{\text{cohort}})$$
Пары (cohort_month, month_number), где month_number > max_month, лежат за горизонтом наблюдения и должны отсекаться до объединения с активностью.
Сетка когорт и номеров месяцев: пошаговый конвейер расчёта retention
Для построения корректной таблицы retention конструируется конвейер из пяти этапов:
user_act_months: отбирает уникальные пары(user_id, activity_month)до даты среза и вычисляетmonth_numberразностью года и месяца.activity_totals: группирует данные по(cohort_month, month_number)и считает число уникальных активных клиентов черезCOUNT(DISTINCT user_id).observed_grid: формирует допустимую опорную сетку черезCROSS JOINвитриныcohort_sizesсо справочникомmonth_grid. Пары, у которыхmonth_numberпревышает разность между срезом и датой когорты, отсекаются вWHERE.LEFT JOIN: соединяет допустимую сеткуobserved_gridс агрегатамиactivity_totalsпо составному ключу(cohort_month, month_number).- Финальный
SELECT: заменяет пропуски внутри горизонта наблюдения на0черезCOALESCEи делит активных пользователей на базовый размер когортыcohort_sizeс вещественным множителем1.0.
Запрос конвейера со срезом на 2024-03-31:
WITH user_act_months AS (
SELECT DISTINCT
uc.cohort_month,
uc.user_id,
(CAST(strftime('%Y', ua.action_date) AS INTEGER) - CAST(strftime('%Y', uc.cohort_month) AS INTEGER)) * 12
+ (CAST(strftime('%m', ua.action_date) AS INTEGER) - CAST(strftime('%m', uc.cohort_month) AS INTEGER)) AS month_number
FROM user_cohorts uc
JOIN user_actions ua ON uc.user_id = ua.user_id
WHERE ua.action_date <= '2024-03-31'
),
activity_totals AS (
SELECT cohort_month, month_number, COUNT(DISTINCT user_id) AS active_users
FROM user_act_months
GROUP BY cohort_month, month_number
),
observed_grid AS (
SELECT
cs.cohort_month,
cs.cohort_size,
mg.month_number
FROM cohort_sizes cs
CROSS JOIN month_grid mg
WHERE mg.month_number <= (
(2024 - CAST(strftime('%Y', cs.cohort_month) AS INTEGER)) * 12
+ (3 - CAST(strftime('%m', cs.cohort_month) AS INTEGER))
)
)
SELECT
og.cohort_month,
og.month_number,
COALESCE(at.active_users, 0) AS active_users,
ROUND(1.0 * COALESCE(at.active_users, 0) / og.cohort_size, 4) AS retention_rate
FROM observed_grid og
LEFT JOIN activity_totals at
ON og.cohort_month = at.cohort_month AND og.month_number = at.month_number
ORDER BY og.cohort_month ASC, og.month_number ASC;
Результат запроса:
| cohort_month | month_number | active_users | retention_rate |
|---|---|---|---|
| 2024-01-01 | 0 | 4 | 1.0 |
| 2024-01-01 | 1 | 2 | 0.5 |
| 2024-01-01 | 2 | 0 | 0.0 |
| 2024-02-01 | 0 | 2 | 1.0 |
| 2024-02-01 | 1 | 1 | 0.5 |
В этом конвейере агрегат COUNT(DISTINCT user_id) применяется как групповая функция внутри activity_totals, а не как оконная функция. Разделение подготовки сетки observed_grid и подсчёта фактов в activity_totals сохраняет в отчёте строки с нулевой активностью, но не создаёт строк за пределами доступного календаря.
Сквозной разбор матрицы retention: когорты января и февраля со срезом в марте
Разберём происхождение каждой строки итоговой таблицы на основе исходных таблиц cohort_sizes, user_cohorts и user_actions.
Для когорты 2024-01-01 базовый размер cohort_size = 4 (пользователи 101, 102, 103, 104):
month_number = 0(январь 2024): все 4 пользователя совершили действия (номера 1, 2, 3, 4).active_users = 4,retention_rate = 4 / 4 = 1.0.month_number = 1(февраль 2024): действия совершили двое — 101 и 102 (действия 5 и 6).active_users = 2,retention_rate = 2 / 4 = 0.5.month_number = 2(март 2024): предел наблюдения на дату2024-03-31равен(2024 - 2024) * 12 + (3 - 1) = 2. Значит, месяц 2 входит в диапазон наблюдения (2 <= 2). Вuser_actionsнет ни одного действия от пользователей 101–104 за март. В CTEactivity_totalsстрока('2024-01-01', 2)отсутствует.LEFT JOINсobserved_gridвозвращаетNULL, которыйCOALESCEпреобразует в0. Итоговыйretention_rate = 0 / 4 = 0.0. Это подтверждённый нулевой возврат.
Для когорты 2024-02-01 базовый размер cohort_size = 2 (пользователи 201 и 202):
month_number = 0(февраль 2024): действия совершены обоими пользователями (действия 7 и 8).active_users = 2,retention_rate = 2 / 2 = 1.0.month_number = 1(март 2024): пользователь 201 совершил два действия (9 и 10). БлагодаряCOUNT(DISTINCT user_id)он учтён ровно один раз. Пользователь 202 действий не совершал.active_users = 1,retention_rate = 1 / 2 = 0.5.month_number = 2(апрель 2024): предел наблюдения для когорты равен(2024 - 2024) * 12 + (3 - 2) = 1. Условиеmg.month_number <= 1в CTEobserved_gridисключило значение 2 из сетки. Строка('2024-02-01', 2)не была сгенерирована и не попала в финальный результат.
Таким образом, мартовский ноль январской когорты зафиксирован как расчётная величина, а апрельская ячейка февральской когорты исключена из анализа до наступления соответствующего периода.
Задача 1
Когорта 2023-11-01, событие 2024-02-15. Какая календарная формула правильно вычисляет month_number при нумерации месяца когорты с 0?
Задача 2
Анализ когорт строится на дату среза '2024-04-30'. Для когорты '2024-03-01' формируется сетка месяцев month_number от 0 до 3. Пользователи когорты совершали повторные покупки только в марте. Как должны интерпретироваться значения retention для month_number = 1 (апрель 2024) и month_number = 2 (май 2024)?
Задача 3
При расчёте числа активных пользователей когорты в месяце month_number = 1 аналитик выполнил запрос: SELECT cohort_month, month_number, COUNT(order_id) / cohort_size AS retention_rate FROM ... GROUP BY cohort_month, month_number. Какой методологический дефект содержит данный расчёт?
Задача 4
Retention и повторные покупатели месяца
Что уже дано
Таблицы cohorts(user_id INTEGER PRIMARY KEY, cohort_month TEXT NOT NULL, cohort_size INTEGER NOT NULL) и orders(order_id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, order_date TEXT NOT NULL, status TEXT NOT NULL). До 500 строк в каждой; ID положительные. cohort_month — YYYY-MM-01 в 2023–2024 годах; cohort_size >0, одинаков для пользователей одной когорты и равен их числу в cohorts. UTC-время заказа YYYY-MM-DD HH:MM:SS; status строго completed, cancelled или refunded. Заказы ссылаются на существующих пользователей; выгрузка может содержать лишь часть их истории.
Что нужно сделать
По каждой паре месяц когорты/календарный номер месяца с успешными покупками покажите active_users — разных покупателей, retention_rate — их долю в исходном размере когорты с округлением до 4 знаков, и repeat_buyers — разных пользователей с минимум двумя completed-заказами именно в этом месяце. Месяц когорты имеет номер 0; номера считаются календарно, включая переход года. Активность раньше месяца когорты исключается. Пары без активности не добавляются.
Ввод и вывод
Отправьте один SELECT или WITH-запрос; stdin и печать не используются. Ровно 5 столбцов в указанном порядке и с этими именами: cohort_month, month_number, active_users, retention_rate, repeat_buyers. cohort_month — TEXT; month_number, active_users, repeat_buyers — INTEGER; retention_rate — REAL; NULL нет. Сортировка: cohort_month ASC, month_number ASC. Если допустимой активности нет, результат пустой. В тестовых результатах разделитель |, NULL — пустое поле. Допускается любой эквивалентный запрос.
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Задача 5
Наблюдаемый retention и возврат из прошлого месяца
Что уже дано
Готовы cohort_sizes(cohort_month TEXT PRIMARY KEY, cohort_size INTEGER NOT NULL), user_cohorts(user_id INTEGER PRIMARY KEY, cohort_month TEXT NOT NULL), user_actions(action_id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, action_date TEXT NOT NULL) и month_grid(month_number INTEGER PRIMARY KEY) со значениями 0,1,2,3. До 500 строк в каждой. cohort_month — YYYY-MM-01, от января до марта 2024. Размер каждой когорты положителен и равен числу её пользователей; ссылки действительны. action_date — валидная дата YYYY-MM-DD в 2024 году; ID положительные.
Что нужно сделать
Дата среза —2024-03-31 включительно. Постройте все наблюдаемые пары когорты и номера месяца из сетки; будущие месяцы исключите, пустые наблюдаемые заполните active_users = 0 и retention_rate = 0.0. Активность — наличие хотя бы одного действия пользователя за месяц; учитывается только с месяца когорты по дату среза. retention_rate — доля активных в исходном размере когорты, округлённая до 4 знаков. Добавьте returning_users: активные в текущем месяце пользователи, активные также в непосредственно предыдущем календарном месяце. Для month_number = 0 этот счётчик 0; возврат после месячного пропуска не считается.
Ввод и вывод
Отправьте один SELECT или WITH-запрос; stdin и печать не используются. Ровно 5 столбцов в указанном порядке и с этими именами: cohort_month, month_number, active_users, retention_rate, returning_users. cohort_month — TEXT; month_number, active_users, returning_users — INTEGER; retention_rate — REAL; NULL нет. Сортировка: cohort_month ASC, month_number ASC. При пустом справочнике когорт результат пустой. В тестовых результатах разделитель |, NULL — пустое поле. Допускается любой эквивалентный запрос.
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
