Аналитика данных Когорты и удержание: как читать кривые и не обмануться
0%

Когорты и удержание: как читать кривые и не обмануться

Когорты и удержание: как читать кривые и не обмануться

Метрика «активных пользователей за месяц» в растущем продукте всегда выглядит хорошо и почти ничего не сообщает. В ней смешаны люди, пришедшие вчера, и люди, живущие с продуктом второй год; приток маркетинга и отток разочарования; сезон и релиз. Если такая метрика падает, вы не знаете, кто перестал возвращаться. Если растёт — не знаете, купили вы этот рост или заслужили.

Когортный анализ — это способ не смешивать. Он берёт одну ось (сколько времени человек с вами) и раскладывает по ней всё остальное. Отсюда и его настоящая сила, и его главная опасность: таблица «когорта × номер периода» выглядит настолько убедительно, что из неё делают выводы, которых способ её построения не выдерживает. Полупустой правый край читают как ухудшение. Среднюю кривую строят по разному набору когорт в каждой точке. Падающий риск ухода объявляют доказательством того, что продукт «затягивает». Ни один из этих выводов не следует из данных — они следуют из привычки читать таблицу, не спрашивая, откуда взялась каждая ячейка.

Эта глава — про то, как построить когортную таблицу корректно, как прочитать её в трёх направлениях и где именно в ней спрятаны выводы, которые она не подтверждает. Технический скелет запроса уже был в главе про 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)$, посчитанное сегодня, через месяц вырастет. Сравнивать по нему когорты с разной длиной наблюдения нельзя вообще — старая когорта «доживёт» больше просто потому, что за ней дольше смотрели.

Разброс между определениями — не академический. На одном и том же событийном логе точечное 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/), где та же смесь описана бета-геометрической моделью. Практические следствия жёсткие:

  1. «Пользователи со временем становятся лояльнее» — гипотеза, а не наблюдение. Из формы агрегатной кривой она не следует; проверять её нужно, сравнивая одинаковых людей — например, риск ухода внутри одного заранее выделенного сегмента.
  2. Кривая-смесь лечится сегментацией, а не средним. Разложите когорту на два сегмента — и обе кривые окажутся почти прямыми в логарифмическом масштабе, а задача переформулируется: не «удерживать всех», а увеличивать долю ядра.
  3. Плато — это не свойство продукта, а размер ядра. Всё, что делает продуктовая команда с плато, — это меняет $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 помогает партиционирование по дате и отказ от оборачивания колонки функцией в фильтре (индексы и планы).

Как это показывать

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

  1. Кривые важнее матрицы. Матрица нужна для поиска аномалий (диагональные полосы видны только на ней), кривые — для выводов.
  2. Незрелые когорты — пунктиром или вообще без них, никогда не той же линией, что зрелые.
  3. Ось Y от нуля: обрезанная ось на кривой удержания превращает шум в тренд (визуализация).
  4. Полоса неопределённости обязательна, если когорта меньше нескольких тысяч человек, а определение метрики пишется на самом графике, а не в голове автора.
  5. Не больше восьми линий. Двадцать когорт на одном полотне — декорация; берите первую, последнюю и медианную, остальные покажите серым коридором.

И главный вопрос, который решает судьбу этого графика на дашборде: какое решение изменится от того, что вы увидели? Если кривая удержания живёт на дашборде «для общего понимания», её не будет смотреть никто, и это правильно — она не подключена ни к одному решению (дашборды). Подключается она просто: порогом, при пересечении которого что-то происходит. «Если удержание четвёртой недели у платного канала опустится ниже 18 процентов, мы останавливаем закупку и переразмечаем аудиторию» — вот теперь на кривую будут смотреть.

Типичные ошибки

  • Читать правый край как ухудшение. Там не падение, там отсутствие данных.
  • Средняя кривая по всем когортам без фильтра зрелости. Состав меняется по столбцам, излом объясняют продуктом.
  • Сравнивать когорты до и после релиза как эксперимент. Между ними ещё сезон, изменившийся микс каналов и всё остальное; вывод требует A/B или приёмов из главы о причинности.
  • Пересчитывать знаменатель в каждой ячейке. Получится удержание среди выживших, которое монотонно растёт и всегда радует.
  • Считать события вместо людей. Удержание больше ста процентов — симптом забытого DISTINCT.
  • Менять определение удержания между отчётами. Точечное и накопительное отличаются кратно.
  • Объяснять падающий риск ухода привязанностью. Почти всегда это отбор состава.
  • Экстраполировать плато на два года вперёд ради LTV. Наблюдённая и модельная части должны быть разделены визуально.
  • Поведенческая когорта, определённая по всей истории. Группа через будущее — тавтология, а не находка.
  • Одна общая кривая без разреза по каналу. Симпсон ждёт именно здесь.
  • Дневные когорты по сто человек. Интервал шире любого обсуждаемого эффекта.
  • Считать удержание метрикой цели. Как только его начинают повышать напрямую, появляются пуши ради открытия приложения: метрика растёт, не меняя в бизнесе ничего — закон Гудхарта и локальная оптимизация.

Чеклист перед публикацией когортного отчёта

  1. Какое определение удержания использовано: точечное, интервальное, накопительное? Написано ли оно на графике?
  2. Что считается активностью и в каком часовом поясе нарезаны периоды?
  3. Что такое день 0: регистрация или первое действие? Куда делись не активировавшиеся?
  4. Зафиксирован ли snapshot и отсечены ли недоехавшие события?
  5. Есть ли линия зрелости и одинаков ли набор когорт во всех точках кривой?
  6. Пропущенные периоды — это нули из сетки или отсутствующие строки?
  7. Знаменатель фиксирован по размеру когорты и не пересчитывается?
  8. Есть ли разрез по каналу и платформе — и совпадает ли направление изменений с общей кривой?
  9. Показана ли неопределённость? Отличаются ли обсуждаемые когорты за пределами интервалов?
  10. Не сравниваются ли когорты с разной длиной наблюдения по накопительной метрике?
  11. Если говорится про причину — что именно исключает эффект календаря и эффект состава?
  12. Какое решение изменится от этой кривой и при каком пороге?

Мини-итог

Когортный анализ ценен не тем, что рисует красивую кривую, а тем, что разделяет возраст, календарь и состав набора — три вещи, безнадёжно перемешанные в любой агрегированной метрике. Разделяет, однако, не полностью: тождество $P = C + A$ делает задачу вырожденной, и различить линейные тренды трёх осей можно только за счёт внешнего знания — даты релиза, эксперимента, естественной границы.

Читать таблицу нужно в трёх направлениях, а строить — с явной линией зрелости, фиксированным знаменателем и полной сеткой периодов, чтобы нули не превращались в пропуски. Форма кривой сообщает главное бинарное свойство продукта: есть плато — есть ядро и накопление, нет плато — есть протекающее ведро, и рост держится на притоке; количественно потолок базы равен притоку, умноженному на площадь под кривой. Падающий риск ухода почти никогда не означает растущей привязанности — он означает, что состав выживших сместился к ядру, и это меняет всё: и трактовку, и приоритеты, и формулу LTV, в которой «доход, делённый на отток» просто неверно.

Сквозная тема трека звучит здесь особенно резко: когортная таблица — это то, что позволил ваш способ сбора данных. Она про идентификаторы, а не про людей; про наблюдённые события, а не про намерения; про уже прошедшее время, а не про будущее. Всё, что за пределами этих трёх границ, — не вывод, а дорисовка.

Источники

Что дальше

Мы разобрали, как таблица из чисел превращается в вывод и где по дороге теряется правда. Осталась последняя ступень, на которой всё это может рассыпаться: картинка. График с обрезанной осью, неверным типом или агрегатом вместо распределения врёт, не искажая ни одной цифры, — и делает это убедительнее, чем любая ошибка в расчётах.

Визуализация: как график врёт честными данными

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов