Почему агрегат врёт: когортные треугольники удержания
Кратко
«Удержание у нас 45%» — обычно это среднее по всем когортам. И оно почти всегда врёт вверх. Почему: в момент среза у молодых когорт ещё мало «возраста», и их высокие первые значения (R1 у когорты месячной давности ≈ 100%) тащат среднее. Старые когорты, которые уже дожили до 12-го месяца, в этом среднем почти не представлены.
Честная картинка — треугольная когортная матрица: строки = когорты (месяц привлечения), столбцы = возраст (месяцы с начала жизни), ячейки = доля выживших. Правый нижний треугольник пуст — это не «нет данных», а когорты, которые ещё не прожили этот возраст.
Пример
Строим матрицу в pandas: на каждую пару «когорта × возраст» считаем уникальных пользователей.
import pandas as pd
# Строка на пользователя-месяц: user_id, cohort (месяц регистрации), age (месяц с регистрации), active (0/1)
# df = ...
retention = (
df.groupby(["cohort", "age"])["user_id"]
.nunique()
.unstack("age")
)
Число пользователей в age = 0 — размер когорты. Делим все столбцы на размер когорты — получаем долю выживших.
size = retention[0]
retention_pct = retention.div(size, axis=0)
Как пишется
- Определить когорту — по событию привлечения (регистрация, первая покупка), а не по календарю.
- Определить возраст — целое число периодов с когорты (дни, недели, месяцы).
- Определить «выжил» — что считаем активностью: наличие любого события, платящего события, сессии.
- Свести таблицу когорта × возраст и нормализовать на размер когорты.
- Диагональ / правый нижний треугольник оставить пустым — туда врезать
observation age.
Как понять
Почему среднее по всем когортам врёт
Возьмём срез «сейчас». У него есть когорты возрастом от 0 до 36 месяцев. Усредняя по строкам, мы мешаем «1 месяц после старта» (держит ~40–50%) с «36 месяцев» (держится ~10–15%). Среднее получается где-то 25%, и оно ничего не говорит ни про продукт, ни про отдельную когорту. Единственный честный вопрос — «какая доля когорты месяца X дожила до возраста Y», а на него отвечает только треугольник.
Observation age — главный подводный камень
У когорты возрастом 12 месяцев нет данных про 24-й месяц — и никогда не будет, пока она не проживёт его. Если эти ячейки заполнить нулями и усреднить, удержание «упадёт» просто потому, что срез рано. Пустой треугольник — фича: он буквально показывает, что по этим когортам вывод делать рано.
Сравнивать когорты можно только одного возраста
«Новая когорта лучше старой» — только если сравнивать R1 с R1, R3 с R3. Сравнение «среднего новой» и «среднего старой» ловит эффект возраста, а не качества. Выводы о динамике продукта строятся по горизонталям и по «возраст-за-возраст».
ARPU/LTV: та же ловушка
LTV по когорте считается как сумма выручки по возрастам. Для молодой когорты доступен только хвост наблюдения до текущего среза — LTV занижен, и это не ошибка, а недонаблюденность. В отчёте это надо подписывать явно: «LTV за 6 месяцев наблюдения», а не «LTV».
Подсказки
- Размер когорты фиксируйте в
age = 0и не меняйте: делитель должен быть одинаковым для всех столбцов. - Пустой треугольник не заполняйте нулями — иначе получите «падение» от перехода среза.
- «Выжил» определяйте по одному правилу для всех когорт, иначе сравнение сломается.
- Для недельных/месячных продуктов выбирайте возраст под цикл: подпискам хватает 12 месяцев, ритейлу — по сезону.
- Проверяйте, что новые когорты не «заедают» первую строку матрицы за счёт маленького возраста.
На практике
В проекте Cohort Analysis Dashboard треугольная матрица с «триангулярным» затуханием построена на синтетических данных: когорты по месяцу регистрации, возраст по месяцам, удержание и ARPU/LTV с явной оговоркой про observation age. Папка анализа собрана в пайплайн на pandas + matplotlib/seaborn, а на выходе — экспорт в CSV и .hyper для Tableau. Та же матрица встроена в разбор Volta: удержание читалось именно по диагонали треугольников, а не по «среднему».
На собеседовании
❓ Почему нельзя считать удержание как среднее по всем когортам?
Потому что на момент среза когорты имеют разный возраст, и усреднение смешивает «молодых» (первый месяц, высокие значения) со «старыми» (дожили до 12-го месяца, низкие значения). Среднее завышено и не отвечает ни на один практический вопрос. Правильный вопрос — «какая доля когорты X дожила до возраста Y», и на него отвечает треугольная матрица, где каждая ячейка — своя когорта на своём возрасте.
— Nikita Boyarkin
❓ Что делать с пустым правым нижним углом когортной матрицы?
Ничего не делать — оставить пустым и подписать как observation age. Эти когорты ещё не прожили соответствующий возраст. Заполнение нулями и усреднение даст ложное «падение» удержания, которое не связано с продуктом. Если нужно среднее по колонке — считать его только по когортам, уже дожившим до этого возраста, и это отдельно оговаривать.
— Nikita Boyarkin
Ссылки
- Cohort Analysis Dashboard — треугольная матрица + ARPU/LTV с оговоркой про observation age
- Когортный анализ удержания — базовый разбор матрицы и её ограничений
- Кейс Volta — удержание, прочитанное по диагонали треугольников