Когорты и удержание: как читать кривые и не обмануться
Метрика «активных пользователей за месяц» в растущем продукте всегда выглядит хорошо и почти ничего не сообщает. В ней смешаны люди, пришедшие вчера, и люди, живущие с продуктом второй год; приток маркетинга и отток разочарования; сезон и релиз. Если такая метрика падает, вы не знаете, кто перестал возвращаться. Если растёт — не знаете, купили вы этот рост или заслужили.
Когортный анализ — это способ не смешивать. Он берёт одну ось (сколько времени человек с вами) и раскладывает по ней всё остальное. Отсюда и его настоящая сила, и его главная опасность: таблица «когорта × номер периода» выглядит настолько убедительно, что из неё делают выводы, которых способ её построения не выдерживает. Полупустой правый край читают как ухудшение. Среднюю кривую строят по разному набору когорт в каждой точке. Падающий риск ухода объявляют доказательством того, что продукт «затягивает». Ни один из этих выводов не следует из данных — они следуют из привычки читать таблицу, не спрашивая, откуда взялась каждая ячейка.
Эта глава — про то, как построить когортную таблицу корректно, как прочитать её в трёх направлениях и где именно в ней спрятаны выводы, которые она не подтверждает. Технический скелет запроса уже был в главе про SQL для анализа; здесь — смысл, ошибки и то, что делают с результатом.
Что такое когорта и что ею не является
Когорта — это множество объектов, объединённых событием и временем этого события. Не «премиум-пользователи», а «пользователи, впервые оплатившие в марте». Не «мобильные», а «зарегистрировавшиеся с мобильного на неделе 12». Время в определении обязательно: именно оно даёт когорте общие часы, начинающие тикать одновременно. Различают два вида, и путаница между ними — самый дорогой источник ложных выводов.
Когорта набора (acquisition cohort) определяется моментом входа: неделя первой сессии, месяц регистрации, дата первой покупки. Признак фиксируется один раз и больше не меняется. Такая когорта — честная основа для сравнения, потому что принадлежность к ней не зависит от того, что человек сделал потом.
Поведенческая когорта (behavioral cohort) определяется действием: «те, кто пригласил коллегу», «те, кто настроил уведомления». Здесь начинается ловушка. Если признак вычисляется по всей истории, группа определена через будущее: «у пользователей с тремя и более заказами удержание 80 процентов» — тавтология, потому что три заказа нельзя сделать, не вернувшись. Это не находка, а определение, записанное задом наперёд. Тот же класс ошибок, что и выживший источник в главе про сбор данных, и типовая ловушка в причинности.
Правило, которое спасает: признак поведенческой когорты фиксируется в закрытом окне в начале жизни, а исход измеряется строго после этого окна. «Пригласил коллегу в первые 7 дней» → «активен на 5-й неделе». Тогда группа хотя бы не содержит исхода внутри себя. Причинности это всё ещё не даёт — приглашающие отличаются от неприглашающих по десятку причин, — но корреляция становится осмысленной, а не арифметической.
-- Признак фиксируем в окне [день 0, день 7), исход измеряем в неделе 5.
-- Берём только тех, у кого 35 дней уже прошли: иначе исход просто не наблюдался.
WITH first_ts AS (
SELECT user_id, min(occurred_at) AS first_ts
FROM events
WHERE occurred_at < :snapshot
GROUP BY user_id
HAVING min(occurred_at) + INTERVAL '35 days' <= :snapshot
), marked AS (
SELECT f.user_id,
coalesce(bool_or(e.event_name = 'team_invite_sent'
AND e.occurred_at < f.first_ts + INTERVAL '7 days'), false) AS invited_w1,
coalesce(bool_or(e.occurred_at >= f.first_ts + INTERVAL '28 days'
AND e.occurred_at < f.first_ts + INTERVAL '35 days'), false) AS active_w5
FROM first_ts f
LEFT JOIN events e ON e.user_id = f.user_id AND e.occurred_at >= f.first_ts
GROUP BY f.user_id
)
SELECT invited_w1,
count(*) AS users,
round(100.0 * count(*) FILTER (WHERE active_w5) / count(*), 1) AS retention_w5_pct
FROM marked
GROUP BY invited_w1;
Отдельный вопрос — чем считать нулевой день. Регистрация и первое содержательное действие дают разные когорты и разные знаменатели. Если день 0 — это первое событие, то $R(0)$ равно 100 процентам по построению, и вся активация оказывается спрятана: люди, зарегистрировавшиеся и не дошедшие до первого действия, вообще не попали в таблицу. Если день 0 — регистрация, $R(0)$ уже меньше единицы и содержит информацию. Оба варианта законны; незаконно менять их между отчётами и молча.
Три оси, которые невозможно разделить
У любой когортной ячейки есть три координаты, и только две из них независимы: возраст $A$ — сколько периодов прошло с нулевого дня когорты (номер столбца); когорта $C$ — когда она вошла (номер строки); календарь $P$ — в какой абсолютный период сделано наблюдение (диагональ). Между ними жёсткое тождество:
$$P = C + A$$
Отсюда следует неприятный факт, известный в демографии и эпидемиологии как проблема идентификации возраст—период—когорта: три линейных эффекта нельзя разделить по одной таблице, потому что любой линейный тренд в одной оси в точности представим комбинацией двух других. Если удержание падает, вы не отличите «продукт стареет» от «когорты становятся хуже» от «в мире что-то происходит» — не потому, что данных мало, а потому, что задача вырождена. Классические разборы — у Холфорда (Holford T.R., Annual Review of Public Health, 1991, https://doi.org/10.1146/annurev.pu.12.050191.002233) и в обзоре https://en.wikipedia.org/wiki/Age%E2%80%93period%E2%80%93cohort_analysis.
Практический выход не в алгебре, а в дизайне: вырожденность снимается внешним ограничением. Релиз с известной датой действует только на периоды после неё — это даёт диагональный разрыв, который нельзя объяснить возрастом. Изменение в закупке трафика действует только на когорты после определённой строки. Ровно это и превращает наблюдение в вывод; универсальный инструмент — A/B-тест, а когда его нет — приёмы из главы о причинности. Отсюда же правило чтения: смотреть таблицу надо в трёх направлениях, а не в одном.
| Направление | Что видно | Типичный вопрос |
|---|---|---|
| Вдоль строки (растёт возраст) | форма кривой удержания одной когорты | где обрыв, есть ли плато |
| Вдоль столбца (растут когорты) | эффект набора при фиксированном возрасте | стал ли новый трафик лучше |
| Вдоль диагонали (растёт календарь) | эффект момента: релиз, авария, сезон, поломка трекинга | что случилось на этой неделе со всеми сразу |
Диагональ читают реже всего, а она чаще всего объясняет аномалию. Поломка трекера или неудачный деплой проявляются как полоса поперёк всех когорт разом: у молодых просядет вторая неделя, у старых — двадцатая. Ни строка, ни столбец такой узор не покажут.
Треугольник: правый край — это не будущее, это отсутствие данных
Таблица когорт всегда треугольная. Когорта, набранная восемь недель назад, прожила восемь недель; набранная на прошлой неделе — одну. Ячейки справа снизу не равны нулю, их не существует: время ещё не прошло. Отсюда две ошибки, встречающиеся почти в каждом самодельном отчёте.
Первая: свежие когорты выглядят лучше. Их кривая обрывается там, где падение ещё не успело произойти. Глаз достраивает продолжение оптимистично, а если график рисует библиотека, которая просто соединяет имеющиеся точки, оптимизм становится линией на слайде.
Вторая: средняя кривая с плавающим составом. Возьмём таблицу с картинки. Средние по столбцам, если усреднять всё, что есть:
$$100 \to 46{,}1 \to 33{,}9 \to 27{,}5 \to 24{,}2 \to 21{,}5 \to 20{,}0 \to 18{,}5 \to 17{,}0$$
Столбец «неделя 1» усреднён по восьми когортам, столбец «неделя 8» — по одной. Ограничимся сравнимым прямоугольником — пятью когортами, каждая из которых прожила все четыре недели:
$$100 \to 42{,}6 \to 31{,}6 \to 26{,}6 \to 24{,}2$$
Разница на первой неделе — 3,5 процентных пункта, и она целиком артефакт состава: в «полное» среднее попали свежие когорты, у которых удержание первой недели выросло с 40 до 55, а до восьмой недели никто из них не дожил. Хуже того, у «полной» кривой появляется излом ровно там, где меняется набор усредняемых когорт, — и этот излом обязательно кто-нибудь объяснит продуктовой гипотезой.
Правило: у сравнения когорт должна быть линия зрелости. Фиксируете горизонт $H$ и берёте только когорты, прожившие $H$ периодов целиком; всё остальное показывается отдельно и подписывается «незрелые». Технически это условие «конец периода наступил раньше среза»: WHERE cohort_week + (week_no + 1) * 7 <= :snapshot. Оно же спасает от второй беды правого края — поздних событий. Данные последних суток всегда недосчитаны, и последняя доступная ячейка занижена не потому, что люди ушли, а потому, что события ещё едут (качество данных). Отсечка AND ingested_at < :snapshot делает отчёт воспроизводимым: вчерашняя версия и сегодняшняя показывают одно и то же.
Три определения удержания, и они дают разные числа
«Удержание D7» — не метрика, а класс метрик. Пусть $N$ — размер когорты, $A^{(m)}$ — множество её пользователей, активных в периоде $m$, а $L_u$ — номер последнего периода, в котором наблюдалась активность пользователя $u$.
Точечное (bracket, «классическое N-day») — активность ровно в периоде $n$:
$$R_{\mathrm{br}}(n) = \frac{# A^{(n)}}{N}$$
Строгое, самое низкое из трёх, немонотонное. У продукта с недельным ритмом дневная кривая пилообразна: $R_{\mathrm{br}}(7) > R_{\mathrm{br}}(6)$, потому что через неделю снова тот же день недели. Пилу регулярно принимают за «эффект седьмого дня» и строят вокруг неё гипотезы.
Интервальное (range) — активность хотя бы раз в окне из $k$ периодов:
$$R_{\mathrm{rng}}(n, k) = \frac{#{u : \exists m,\ n \le m < n+k,\ u \in A^{(m)}}}{N}$$
Компромисс: гасит пилу и терпимо к продуктам, которыми пользуются нерегулярно. «Недельное удержание» — это ровно $R_{\mathrm{rng}}$ с окном в семь дней.
Накопительное (unbounded, rolling) — активность в периоде $n$ или в любом более позднем:
$$R_{\mathrm{roll}}(n) = \frac{#{u : L_u \ge n}}{N}$$
Единственное из трёх невозрастающее, то есть настоящая функция выживания. Отвечает на вопрос «жив ли пользователь», а не «зашёл ли он в конкретный день». Но у него есть цена: оно цензурировано справа. Человек, который вернётся через полгода, сегодня выглядит ушедшим, поэтому $R_{\mathrm{roll}}(4)$, посчитанное сегодня, через месяц вырастет. Сравнивать по нему когорты с разной длиной наблюдения нельзя вообще — старая когорта «доживёт» больше просто потому, что за ней дольше смотрели.
чувствительно, но пилообразно"] A -->|"нет: банк, отчёты, доставка"| C{"Нужна монотонная кривая
для выживания и LTV?"} C -->|"да"| D["Накопительное
обязательно фиксировать
длину наблюдения"] C -->|"нет"| E["Интервальное с окном
в один естественный цикл продукта"] B --> F["Записать определение в витрину,
а не в описание графика"] D --> F E --> F F --> G["Изменится ли решение
при другом определении?"] G -->|"да"| H["Показать оба числа
и назвать разницу в отчёте"] G -->|"нет"| I["Взять любое и больше не менять"]
Разброс между определениями — не академический. На одном и том же событийном логе точечное D30 и накопительное D30 у продукта с недельным циклом отличаются в два-три раза. Поэтому «у нас удержание 30 процентов» без указания определения, окна и того, что считается активностью, — это не число, а звук. Терминология разобрана в документации Amplitude (https://amplitude.com/docs/analytics/charts/retention-analysis) и в главе про продуктовые метрики.
Корректный запрос: сетка, а не то, что вернул JOIN
Главная техническая ловушка когортного SQL: нули пропадают. Если в какую-то неделю у когорты не было ни одного активного, GROUP BY не вернёт строку, в отчёте окажется дырка, и график молча закроет её прямой линией. Поэтому сетка периодов строится явно, а активность к ней приклеивается слева.
-- PostgreSQL. Недельные когорты по первому событию, точечное недельное удержание.
WITH params AS (
SELECT DATE '2026-01-05' AS first_cohort, -- первый понедельник выборки
DATE '2026-07-27' AS snapshot, -- правая граница, полуоткрытая
12 AS horizon -- максимальный номер недели
),
first_seen AS ( -- когорта = неделя первого события
SELECT e.user_id,
date_trunc('week', min(e.occurred_at) AT TIME ZONE 'Europe/Moscow')::date AS cohort_week
FROM events e CROSS JOIN params p
WHERE e.occurred_at < p.snapshot
GROUP BY e.user_id
),
cohorts AS ( -- знаменатель фиксируется один раз
SELECT f.cohort_week, count(*) AS cohort_size
FROM first_seen f CROSS JOIN params p
WHERE f.cohort_week >= p.first_cohort
GROUP BY f.cohort_week
),
activity AS ( -- пара (пользователь, неделя) без дублей
SELECT DISTINCT f.cohort_week, e.user_id,
(date_trunc('week', e.occurred_at AT TIME ZONE 'Europe/Moscow')::date
- f.cohort_week) / 7 AS week_no
FROM events e
JOIN first_seen f USING (user_id)
CROSS JOIN params p
WHERE e.occurred_at < p.snapshot
),
grid AS ( -- полная сетка с линией зрелости
SELECT c.cohort_week, c.cohort_size, w.week_no
FROM cohorts c
CROSS JOIN params p
CROSS JOIN LATERAL generate_series(0, p.horizon) AS w(week_no)
WHERE c.cohort_week + (w.week_no + 1) * 7 <= p.snapshot
)
SELECT g.cohort_week, g.week_no, g.cohort_size,
count(a.user_id) AS retained,
round(100.0 * count(a.user_id) / g.cohort_size, 1) AS retention_pct
FROM grid g
LEFT JOIN activity a ON a.cohort_week = g.cohort_week AND a.week_no = g.week_no
GROUP BY g.cohort_week, g.week_no, g.cohort_size
ORDER BY g.cohort_week, g.week_no;
Что здесь сделано осознанно: DISTINCT в activity сворачивает события в людей до подсчёта — без него count посчитает события, и «удержание» окажется больше ста процентов (классический симптом, по которому отчёт узнают на ревью). count(a.user_id), а не count(*): при LEFT JOIN без совпадений count(*) вернёт единицу, а счёт по колонке — ноль. Знаменатель берётся из cohorts и не пересчитывается в каждой ячейке, иначе получится удержание среди выживших — совсем другая метрика. AT TIME ZONE стоит явно, потому что «неделя» существует только относительно пояса и в UTC граница пройдёт по другим людям. Все интервалы полуоткрытые, snapshot фиксирован параметром: now() в когортном отчёте гарантирует, что два запуска не сойдутся.
Накопительное удержание считается по последнему наблюдённому периоду и требует одной дополнительной агрегации поверх тех же CTE:
-- Продолжение того же WITH. Пользователь жив на неделе N, если у него есть
-- активность на неделе N или позже: кривая невозрастающая, это функция выживания.
, last_seen AS (
SELECT f.cohort_week, f.user_id,
(max(date_trunc('week', e.occurred_at AT TIME ZONE 'Europe/Moscow')::date)
- f.cohort_week) / 7 AS last_week_no
FROM events e
JOIN first_seen f USING (user_id)
CROSS JOIN params p
WHERE e.occurred_at < p.snapshot
GROUP BY f.cohort_week, f.user_id
)
SELECT g.cohort_week, g.week_no, g.cohort_size,
count(*) FILTER (WHERE l.last_week_no >= g.week_no) AS alive,
round(100.0 * count(*) FILTER (WHERE l.last_week_no >= g.week_no)
/ g.cohort_size, 1) AS rolling_pct
FROM grid g
JOIN last_seen l ON l.cohort_week = g.cohort_week
GROUP BY g.cohort_week, g.week_no, g.cohort_size
ORDER BY g.cohort_week, g.week_no;
Чтобы сравнивать когорты между собой, у всех должно быть одинаковое окно наблюдения вперёд. Либо считать интервальное удержание с фиксированным $k$ (условие соединения a.week_no >= g.week_no AND a.week_no < g.week_no + :lookahead, а линия зрелости становится cohort_week + (week_no + lookahead) * 7 <= :snapshot), либо переходить к оценке Каплана — Мейера, которая корректно обрабатывает цензурирование (Kaplan E.L., Meier P., JASA, 1958, https://doi.org/10.1080/01621459.1958.10501452; реализация — https://lifelines.readthedocs.io/).
Как читать кривую: обрыв, изгиб, плато
У кривой удержания три содержательно разные части, и за каждую отвечает своя работа.
Обрыв — падение между периодом 0 и периодом 1. Это почти всегда не про удержание, а про активацию и ожидания: человек не понял, зачем пришёл, или пришёл не за тем, что ему обещала реклама. Лечится онбордингом, первым ценным действием и качеством трафика, а не пуш-уведомлениями.
Изгиб — периоды со второго по пятый-шестой. Здесь отваливаются те, кто попробовал и не встроил продукт в свою жизнь. Лечится привычкой: триггеры, напоминания, накопленное состояние, которое жалко бросить.
Плато — горизонтальный участок, и его наличие или отсутствие есть самый важный бинарный факт о продукте. Плато есть — значит, у продукта существует ядро, которое остаётся навсегда, и каждая новая когорта добавляет к базе постоянную величину: продукт накапливается. Плато нет, кривая идёт в ноль — это протекающее ведро, рост держится только на притоке, и стоит остановить маркетинг, как база растворится. В терминах запасов и потоков это разница между резервуаром и трубой.
Количественно всё это выражается одним соотношением. Если каждый период приходит $N$ новых пользователей, а кривая удержания равна $R(t)$, то в равновесии активная база составит
$$B_{\infty} = N \cdot \sum_{t \ge 0} R(t)$$
Площадь под кривой удержания — потолок продукта в единицах притока. Ни одна оптимизация воронки не поднимет базу выше этого произведения, и это самый прямой способ объяснить, почему удержание важнее конверсии: конверсия множит $N$ линейно и один раз, удержание меняет множитель, который стоит рядом.
Падающий риск ухода не означает, что пользователи полюбили продукт
Самая красивая и самая ложная интерпретация в когортном анализе звучит так: «риск ухода со временем снижается — значит, чем дольше человек с нами, тем крепче он привязывается». Определим риск ухода (hazard) как долю доживших до периода $n-1$, которые не дожили до $n$:
$$h(n) = 1 - \frac{R_{\mathrm{roll}}(n)}{R_{\mathrm{roll}}(n-1)}$$
Теперь возьмём популяцию, в которой ничья индивидуальная привязанность не меняется вообще. Пусть 30 процентов пользователей — «ядро» с постоянной вероятностью остаться 0,95 за период, а 70 процентов — случайные посетители с вероятностью 0,5:
$$R(t) = p \cdot a^{t} + (1-p) \cdot b^{t}, \qquad p = 0{,}30,\ a = 0{,}95,\ b = 0{,}50$$
# Смесь двух групп с ПОСТОЯННЫМ индивидуальным риском ухода
p, a, b = 0.30, 0.95, 0.50 # доля ядра; удержание ядра за период; удержание случайных
def R(t: int) -> float:
return p * a**t + (1 - p) * b**t
prev = 1.0
for t in range(1, 7):
cur = R(t)
print(t,
round(100 * cur, 1), # удержание, %
round(100 * (1 - cur / prev), 1), # риск ухода за период, %
round(100 * p * a**t / cur, 1)) # доля ядра среди выживших, %
prev = cur
| Период | Удержание, % | Риск ухода $h(n)$, % | Доля ядра среди выживших, % |
|---|---|---|---|
| 1 | 63,5 | 36,5 | 44,9 |
| 2 | 44,6 | 29,8 | 60,7 |
| 3 | 34,5 | 22,7 | 74,6 |
| 4 | 28,8 | 16,4 | 84,8 |
| 5 | 25,4 | 11,8 | 91,4 |
| 6 | 23,1 | 8,9 | 95,3 |
Риск ухода упал вчетверо — с 36,5 до 8,9 процента. При этом ни один пользователь не изменил своего поведения: у ядра риск всё время ровно 5 процентов, у случайных — ровно 50. Падает не привязанность, а состав выживших: к шестому периоду в когорте осталось 95 процентов ядра вместо исходных 30. Продукт никого не «затянул» — он просто отфильтровал.
Это фундаментальный эффект, а не курьёз: любая неоднородность популяции порождает убывающий агрегатный риск, даже когда индивидуальные риски постоянны или растут. Классическая работа — Vaupel J.W., Yashin A.I., «Heterogeneity’s Ruses: Some Surprising Effects of Selection on Population Dynamics», The American Statistician, 1985 (https://doi.org/10.1080/00031305.1985.10479424); в маркетинговой постановке — Fader P.S., Hardie B.G.S., «How to Project Customer Retention», 2007 (http://www.brucehardie.com/papers/021/), где та же смесь описана бета-геометрической моделью. Практические следствия жёсткие:
- «Пользователи со временем становятся лояльнее» — гипотеза, а не наблюдение. Из формы агрегатной кривой она не следует; проверять её нужно, сравнивая одинаковых людей — например, риск ухода внутри одного заранее выделенного сегмента.
- Кривая-смесь лечится сегментацией, а не средним. Разложите когорту на два сегмента — и обе кривые окажутся почти прямыми в логарифмическом масштабе, а задача переформулируется: не «удерживать всех», а увеличивать долю ядра.
- Плато — это не свойство продукта, а размер ядра. Всё, что делает продуктовая команда с плато, — это меняет $p$, перетаскивая людей из одной группы в другую.
LTV из кривой: что считается, а что дорисовывается
Ценность когорты за $T$ периодов при среднем доходе с активного пользователя $\mathrm{ARPU}$ равна $\mathrm{LTV}(T) = \mathrm{ARPU} \cdot \sum_{t=0}^{T} R(t)$. Возьмём ту же смесь и 500 рублей с активного пользователя в месяц. Наблюдённая за 6 месяцев сумма $\sum_{t=0}^{6} R(t) = 3{,}199$, то есть 1600 рублей. Асимптотическая сумма считается точно, потому что модель геометрическая:
$$\sum_{t \ge 0} R(t) = \frac{p}{1-a} + \frac{1-p}{1-b} = \frac{0{,}30}{0{,}05} + \frac{0{,}70}{0{,}50} = 6{,}0 + 1{,}4 = 7{,}4$$
то есть 3700 рублей. А теперь два способа, которыми это число обычно получают неправильно. «LTV = ARPU / отток», где отток взят с первого месяца, даёт $500 / 0{,}365 \approx 1370$ рублей — занижение почти втрое. То же самое с оттоком шестого месяца даёт $500 / 0{,}089 \approx 5600$ рублей — завышение в полтора раза. Формула «доход, делённый на отток» верна ровно тогда, когда риск ухода постоянен, — а он постоянным не бывает по причине из предыдущего раздела.
Отсюда правило: наблюдённая накопленная выручка до общего горизонта — измерение, всё, что правее, — модель, и в отчёте эти две части должны быть визуально разделены. Экстраполяция допустима, но подписанная: какая модель, на скольких периодах подогнана, какой разброс даёт бутстрап по пользователям (неопределённость). Отдельно стоит помнить, что доход на пользователя обычно распределён с тяжёлым хвостом, и среднее там — плохая сводка (распределения).
Полезная проверка приоритетов, которую даёт та же модель: во что вкладываться — в первую неделю или в ядро?
| Изменение | $\sum R(t)$ | Потолок базы при 10 000 новых в месяц |
|---|---|---|
| Базовый вариант: $p=0{,}30,\ a=0{,}95,\ b=0{,}50$ | 7,40 | 74 000 |
| $b: 0{,}50 \to 0{,}55$ — лучше первая неделя | 7,56 | 75 600 |
| $p: 0{,}30 \to 0{,}33$ — больше людей попадает в ядро | 7,94 | 79 400 |
| $a: 0{,}95 \to 0{,}96$ — ядро уходит медленнее | 8,90 | 89 000 |
Один процентный пункт удержания у ядра стоит больше, чем пять на входе, и причина чисто арифметическая: $1/(1-a)$ растёт тем быстрее, чем ближе $a$ к единице. Это не значит «не занимайтесь онбордингом» — это значит, что при равной стоимости работ выигрыш неравный, а сравнивать надо в единицах базы, а не в процентных пунктах удержания. Ровно тот разговор, ради которого аналитик и нужен (сначала вопрос). Оговорка обязательна: это выводы из модели, а не из данных. Двухкомпонентная смесь — предположение; на реальных данных её подгоняют и проверяют на отложенных периодах: обучить на первых шести месяцах и посмотреть, попадает ли она в седьмой—девятый.
Отток — это не событие, а порог
В когортной таблице нет строки «пользователь ушёл». Уход ненаблюдаем: есть только отсутствие событий, и назвать его оттоком — решение аналитика, а не факт из лога. Всё, что вы выбираете, — длина окна тишины.
Из диаграммы видно то, что теряется в формуле «отток = ушедшие делить на всех».
- Порог произволен и определяет метрику. 30 дней тишины для мессенджера — почти смерть, для сервиса подачи налоговой декларации — норма. Порог выбирается из распределения интервалов между визитами: за какое время возвращаются 95 процентов тех, кто вообще возвращается, — этот квантиль и берите.
- Состояние «ушёл» обратимо. Воскрешения существуют, и в продуктах с сезонным спросом их доля двузначная. Метрика, не различающая «ушёл навсегда» и «вернётся в декабре», сделает декабрьский всплеск загадкой.
- Наивный отток в растущем продукте занижен. Если считать «ушедшие за месяц делить на всех активных», знаменатель раздут свежими пользователями, которые физически не успели уйти. Отток обязан считаться по когортам: доля ушедших среди тех, у кого срок наблюдения одинаков.
- Порог задним числом переписывает историю. Смена порога с 30 на 14 дней меняет все прошлые значения. Хранить надо не флаг «ушёл», а события; флаг вычисляется на лету из параметра.
Риск ухода по возрасту считается оконной функцией поверх монотонной кривой:
-- Понедельный риск ухода: доля доживших до недели N-1, не доживших до N.
-- Считается ТОЛЬКО по невозрастающей (накопительной) кривой: на точечной
-- из-за недельной сезонности получатся отрицательные значения.
SELECT cohort_week, week_no, rolling_pct,
round(100 * (1 - rolling_pct / nullif(lag(rolling_pct) OVER w, 0)), 1) AS hazard_pct
FROM rolling_retention
WINDOW w AS (PARTITION BY cohort_week ORDER BY week_no)
ORDER BY cohort_week, week_no;
Шум: маленькая когорта — это не плохая когорта
Недельная когорта в 120 человек с удержанием 22 процента на четвёртой неделе — это, по интервалу Уилсона
$$\frac{\hat{p} + \dfrac{z^{2}}{2n} \pm z\sqrt{\dfrac{\hat{p}(1-\hat{p})}{n} + \dfrac{z^{2}}{4n^{2}}}}{1 + \dfrac{z^{2}}{n}}$$
примерно от 15,2 до 29,9 процента. Соседняя когорта с 28 процентами от неё не отличается — а на графике две линии расходятся заметно, и объяснение расхождению всегда находится. Правило простое: если у когортной кривой нет полосы неопределённости, её нельзя обсуждать в терминах «эта неделя лучше». Механика — в главе про неопределённость, а про то, почему из десятка когорт одна обязательно окажется «значимо лучше», — в главах о проверке гипотез и множественных сравнениях.
Отсюда же практическое решение о гранулярности: недельные когорты вместо дневных, если дневная когорта меньше нескольких сотен человек. Семикратный рост знаменателя сужает интервал примерно в 2,6 раза, и кривая перестаёт «дышать». Границы Уилсона считаются прямо в витрине одним выражением по столбцам retained и cohort_size — при $z = 1{,}959964$ и $z^{2} = 3{,}841459$ для 95 процентов.
Симпсон живёт в когортах
Общая кривая может улучшаться при том, что каждый её сегмент ухудшается. Два канала привлечения, удержание на 30-й день:
| Месяц | Органика | Платный | Всего |
|---|---|---|---|
| Апрель | 1000 польз., 40 % → 400 | 9000 польз., 10 % → 900 | 10 000 → 1300, 13,0 % |
| Май | 6000 польз., 38 % → 2280 | 4000 польз., 9 % → 360 | 10 000 → 2640, 26,4 % |
Итоговое удержание выросло вдвое, органика упала с 40 до 38, платный — с 10 до 9. Никакого фокуса: изменился микс, и результат определяется весами, а не значениями. Если в мае команда праздновала успех онбординга, она праздновала работу отдела закупки трафика — а при сдвиге микса в другую сторону отличный онбординг выглядел бы провалом.
Общая когортная кривая почти бесполезна без разреза по источнику. Минимальный набор разрезов: канал привлечения, платформа, страна или тариф. Это тот же парадокс Симпсона, что в главе о причинности, просто в когортной обёртке; в терминах отчётности — типичная ловушка агрегата, скрывающего распределение, о которой пойдёт речь в визуализации. Проверка выполняется одним запросом: сравнить взвешенное и невзвешенное среднее и обязательно показать, сколько когорт участвует в каждой точке.
-- Три числа в одной таблице: сколько когорт усреднено, объединённая доля,
-- среднее по когортам. Расхождение двух последних — сигнал перекоса размеров.
WITH mature AS ( -- только когорты, дожившие до горизонта
SELECT cohort_week FROM cohort_grid
GROUP BY cohort_week HAVING max(week_no) >= 8
)
SELECT g.week_no,
count(*) AS cohorts_in_average,
round(100.0 * sum(g.retained) / sum(g.cohort_size), 1) AS pooled_pct,
round(avg(g.retention_pct), 1) AS mean_of_cohorts_pct
FROM cohort_grid g
JOIN mature m USING (cohort_week)
WHERE g.week_no <= 8
GROUP BY g.week_no
ORDER BY g.week_no;
Если cohorts_in_average меняется от строки к строке — фильтр зрелости не сработал и кривая недостоверна. Если pooled_pct и mean_of_cohorts_pct сильно расходятся — когорты сильно разного размера, и объединённая доля описывает в основном одну большую когорту.
Кого именно вы удерживаете
Знаменатель когорты — это идентификаторы, а вопрос — про людей. Всё, что ломает соответствие между ними, ломает кривую предсказуемым образом.
- Cookie вместо пользователя. Очистка браузера, второе устройство, приватный режим создают «нового» пользователя. MAU при этом завышается, а удержание занижается: вернувшийся человек выглядит впервые пришедшим (источники данных).
- Склейка анонимной и авторизованной личности задним числом. Если идентификатор меняется при логине и события не переписываются, один человек порождает две когорты: анонимную с ужасным удержанием и авторизованную с прекрасным. Обе неверны.
- Аккаунт вместо пользователя в B2B. Удержание команд и удержание отдельных сотрудников — разные метрики с разной динамикой: компания может остаться, потеряв всех, кто работал в ней в первый месяц. Считать надо обе и не выдавать одну за другую.
- Смена схемы событий. Переименовали
app_open— и в таблице начинается провал, похожий на продуктовую катастрофу. Но это диагональ, а не строка: одновременно проседают все когорты (качество и governance). - Определение «активности». Открыл приложение, совершил целевое действие, провёл больше 10 секунд — три разные кривые из одного лога. Записывать это определение нужно рядом с числом, а не в устной традиции команды.
Витрина: когорты считаются один раз
Пересчитывать когортный треугольник в каждом дашборде дорого и, что хуже, порождает несколько несовпадающих определений одной метрики. Правильная форма — предагрегированная витрина «когорта × период», которая строится ночью и переиспользуется всеми отчётами (оркестрация, моделирование данных).
Три поля в COHORT_GRID важнее остальных. retention_definition фиксирует, какая из трёх метрик посчитана, — без него витрина через полгода станет источником споров. snapshot_ts делает отчёт воспроизводимым и позволяет увидеть, как одна и та же ячейка менялась по мере доезда поздних событий. Разрезы acquisition_channel и platform лежат в ключе, потому что без них кривая уязвима к Симпсону, а добавить их задним числом — значит переписать всю историю.
По производительности USER_DAY — самая ценная промежуточная таблица во всей аналитике: она сворачивает миллиарды событий в десятки миллионов строк и переиспользуется воронками, ретеншеном и выручкой. В колоночных хранилищах когортный треугольник строится агрегатом по (cohort_week, week_no) из предагрегата и читается за доли секунды (ClickHouse и OLAP); в PostgreSQL помогает партиционирование по дате и отказ от оборачивания колонки функцией в фильтре (индексы и планы).
Как это показывать
Когортная таблица с цветовой заливкой красива и почти нечитаема: глаз сравнивает интенсивности цвета, а не числа, а незрелые ячейки выглядят как белые дыры, которые каждый интерпретирует по-своему. Практические правила:
- Кривые важнее матрицы. Матрица нужна для поиска аномалий (диагональные полосы видны только на ней), кривые — для выводов.
- Незрелые когорты — пунктиром или вообще без них, никогда не той же линией, что зрелые.
- Ось Y от нуля: обрезанная ось на кривой удержания превращает шум в тренд (визуализация).
- Полоса неопределённости обязательна, если когорта меньше нескольких тысяч человек, а определение метрики пишется на самом графике, а не в голове автора.
- Не больше восьми линий. Двадцать когорт на одном полотне — декорация; берите первую, последнюю и медианную, остальные покажите серым коридором.
И главный вопрос, который решает судьбу этого графика на дашборде: какое решение изменится от того, что вы увидели? Если кривая удержания живёт на дашборде «для общего понимания», её не будет смотреть никто, и это правильно — она не подключена ни к одному решению (дашборды). Подключается она просто: порогом, при пересечении которого что-то происходит. «Если удержание четвёртой недели у платного канала опустится ниже 18 процентов, мы останавливаем закупку и переразмечаем аудиторию» — вот теперь на кривую будут смотреть.
Типичные ошибки
- Читать правый край как ухудшение. Там не падение, там отсутствие данных.
- Средняя кривая по всем когортам без фильтра зрелости. Состав меняется по столбцам, излом объясняют продуктом.
- Сравнивать когорты до и после релиза как эксперимент. Между ними ещё сезон, изменившийся микс каналов и всё остальное; вывод требует A/B или приёмов из главы о причинности.
- Пересчитывать знаменатель в каждой ячейке. Получится удержание среди выживших, которое монотонно растёт и всегда радует.
- Считать события вместо людей. Удержание больше ста процентов — симптом забытого
DISTINCT. - Менять определение удержания между отчётами. Точечное и накопительное отличаются кратно.
- Объяснять падающий риск ухода привязанностью. Почти всегда это отбор состава.
- Экстраполировать плато на два года вперёд ради LTV. Наблюдённая и модельная части должны быть разделены визуально.
- Поведенческая когорта, определённая по всей истории. Группа через будущее — тавтология, а не находка.
- Одна общая кривая без разреза по каналу. Симпсон ждёт именно здесь.
- Дневные когорты по сто человек. Интервал шире любого обсуждаемого эффекта.
- Считать удержание метрикой цели. Как только его начинают повышать напрямую, появляются пуши ради открытия приложения: метрика растёт, не меняя в бизнесе ничего — закон Гудхарта и локальная оптимизация.
Чеклист перед публикацией когортного отчёта
- Какое определение удержания использовано: точечное, интервальное, накопительное? Написано ли оно на графике?
- Что считается активностью и в каком часовом поясе нарезаны периоды?
- Что такое день 0: регистрация или первое действие? Куда делись не активировавшиеся?
- Зафиксирован ли
snapshotи отсечены ли недоехавшие события? - Есть ли линия зрелости и одинаков ли набор когорт во всех точках кривой?
- Пропущенные периоды — это нули из сетки или отсутствующие строки?
- Знаменатель фиксирован по размеру когорты и не пересчитывается?
- Есть ли разрез по каналу и платформе — и совпадает ли направление изменений с общей кривой?
- Показана ли неопределённость? Отличаются ли обсуждаемые когорты за пределами интервалов?
- Не сравниваются ли когорты с разной длиной наблюдения по накопительной метрике?
- Если говорится про причину — что именно исключает эффект календаря и эффект состава?
- Какое решение изменится от этой кривой и при каком пороге?
Мини-итог
Когортный анализ ценен не тем, что рисует красивую кривую, а тем, что разделяет возраст, календарь и состав набора — три вещи, безнадёжно перемешанные в любой агрегированной метрике. Разделяет, однако, не полностью: тождество $P = C + A$ делает задачу вырожденной, и различить линейные тренды трёх осей можно только за счёт внешнего знания — даты релиза, эксперимента, естественной границы.
Читать таблицу нужно в трёх направлениях, а строить — с явной линией зрелости, фиксированным знаменателем и полной сеткой периодов, чтобы нули не превращались в пропуски. Форма кривой сообщает главное бинарное свойство продукта: есть плато — есть ядро и накопление, нет плато — есть протекающее ведро, и рост держится на притоке; количественно потолок базы равен притоку, умноженному на площадь под кривой. Падающий риск ухода почти никогда не означает растущей привязанности — он означает, что состав выживших сместился к ядру, и это меняет всё: и трактовку, и приоритеты, и формулу LTV, в которой «доход, делённый на отток» просто неверно.
Сквозная тема трека звучит здесь особенно резко: когортная таблица — это то, что позволил ваш способ сбора данных. Она про идентификаторы, а не про людей; про наблюдённые события, а не про намерения; про уже прошедшее время, а не про будущее. Всё, что за пределами этих трёх границ, — не вывод, а дорисовка.
Источники
- Holford T.R. «Understanding the effects of age, period, and cohort on incidence and mortality rates». Annual Review of Public Health, 1991. https://doi.org/10.1146/annurev.pu.12.050191.002233
- Vaupel J.W., Yashin A.I. «Heterogeneity’s Ruses: Some Surprising Effects of Selection on Population Dynamics». The American Statistician, 1985. https://doi.org/10.1080/00031305.1985.10479424
- Fader P.S., Hardie B.G.S. «How to Project Customer Retention». Journal of Interactive Marketing, 2007. http://www.brucehardie.com/papers/021/
- Fader P.S., Hardie B.G.S. Заметки и код по вероятностным моделям клиентской базы. http://www.brucehardie.com/notes/
- Kaplan E.L., Meier P. «Nonparametric Estimation from Incomplete Observations». JASA, 1958. https://doi.org/10.1080/01621459.1958.10501452
- Документация lifelines: оценка выживаемости и цензурирование на Python. https://lifelines.readthedocs.io/
- Amplitude Docs: Retention Analysis — определения и различия между ними. https://amplitude.com/docs/analytics/charts/retention-analysis
- Chen A. «New data shows losing 80% of mobile users is normal». https://andrewchen.com/new-data-shows-why-losing-80-of-your-mobile-users-is-normal-and-that-the-best-apps-do-much-better/
- Rachitsky L. «What is good retention?» — ориентиры по индустриям. https://www.lennysnewsletter.com/p/what-is-good-retention-issue-29
- Wikipedia: Age–period–cohort analysis. https://en.wikipedia.org/wiki/Age%E2%80%93period%E2%80%93cohort_analysis
- Документация PostgreSQL: оконные функции,
generate_series, агрегаты сFILTER. https://www.postgresql.org/docs/current/functions-window.html
Что дальше
Мы разобрали, как таблица из чисел превращается в вывод и где по дороге теряется правда. Осталась последняя ступень, на которой всё это может рассыпаться: картинка. График с обрезанной осью, неверным типом или агрегатом вместо распределения врёт, не искажая ни одной цифры, — и делает это убедительнее, чем любая ошибка в расчётах.