Привет, Хабр!
Сегодня поговорим про ретеншн — ту самую метрику, от которой часто пляшут все продуктовые команды. Вы знаете: «вернулся через 7 дней» (D7) — и сказано, что мы класс
Но на деле класс ломается, как только продукт усложняется. В этой статье рассмотрим, почему классический D7 retention не работает, как построить настоящие кривые удержания через когорты, в чём разница между recurring vs one-shot поведением, какие есть альтернативные метрики и сравним три метода на примере.
Где ломается классический D7 Retention
Классический подход:
Берём пользователей, зарегистрировавшихся в день D0.
Смотрим, сколько из них вернулось ровно на D7.
Получаем «D7 retention = вернулись / всего D0».
Но представьте, что у вас …
Дейли-планировщик: некоторые юзеры приходят на 1-й, на 3-й, то есть нерегулярно.
Мультиплатформенный продукт: мобильник, веб, десктоп — возвращается через разные интерфейсы, с разной задержкой.
Разовая акция: кто-то залогинился именно на 7-й день из-за рассылки, но потом исчез навсегда.
Всё это превращает D7 в лотерею: кто-то попал под горячую руку, а кто-то нет. Результат — мешанина сигналов, которую невозможно интерпретировать. А ведь важно понять, удерживается ли аудитория вообще, или это статистический шум.
Построение true retention curves через когорты событий
Когорты — наше всё. Вместо «вернулся ровно на D7» создаём матрицу:
строки — когорты по дате первого события (или регистрации) D0, D1, …
столбцы — дни после D0: Day0, Day1, Day2, … DayN
ячейки — доля пользователей из когорты, совершивших хотя бы одно событие на соответствующий день.
Такую таблицу можно посчитать на Python + pandas очень быстро.
import pandas as pd
# Предположим, есть таблица events с колонками user_id и event_timestamp
events = pd.read_csv('events.csv', parse_dates=['event_timestamp'])
events['cohort_date'] = events.groupby('user_id')['event_timestamp'].transform('min').dt.normalize()
events['day_offset'] = (events['event_timestamp'].dt.normalize() - events['cohort_date']).dt.days
# Считаем когорты
cohort_counts = (
events.drop_duplicates(['user_id', 'day_offset'])
.groupby(['cohort_date', 'day_offset'])
.agg({'user_id': 'nunique'})
.reset_index()
)
# Сколько всего в когорте
cohort_sizes = (
events.groupby('cohort_date')
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
.reset_index()
)
# Объединяем
retention = cohort_counts.merge(cohort_sizes, on='cohort_date')
retention['retention_rate'] = retention['user_id'] / retention['cohort_size']
# Pivot для удобного вида
retention_pivot = retention.pivot(index='cohort_date', columns='day_offset', values='retention_rate')
print(retention_pivot.head())
Получаем матрицу типа:
cohort_date | 0 | 1 | 2 | 3 | … |
---|---|---|---|---|---|
2025-04-01 | 1.0 | 0.45 | 0.30 | 0.25 | … |
2025-04-02 | 1.0 | 0.50 | 0.28 | 0.22 | … |
… | … | … | … | … | … |
Тут видно, как удержание падает день за днём.
recurring behavior vs one-shot behavior
One-shot behavior: пользователь зашёл единожды, сделал покупку/действие и пропал.
Recurring behavior: юзер регулярно возвращается к вашему продукту: чтение новостей, планирование задач, апгрейд статуса…
Если смешивать их, получаем ложные сигналы:
One-shot юзеры дают retention только в первых днях (Day0, Day1), а потом «шумиха» статистики.
Recurring юзеры формируют long tail: retention может «хвостиком» тянуться на неделю, месяц, год.
Как это учесть?
Кластеризовать пользователей: K-means/DBSCAN по поведению (частота, глубина, время) и строить retention по сегментам.
Фильтровать «одноразовок»: задать минимум событий в первые N дней, чтобы выделить активных core-пользователей.
Анализ событий разных типов: отдельно смотреть retention по просмотрам, лайкам, пуш-каму и т.д.
# Пример: фильтрация recurring-пользователей
agg = events.groupby('user_id').agg({
'event_timestamp': ['min', 'max', 'count']
})
agg.columns = ['first', 'last', 'count']
agg['lifetime_days'] = (agg['last'] - agg['first']).dt.days
recurring = agg[(agg['count'] > 5) & (agg['lifetime_days'] > 7)].index
recurring_events = events[events['user_id'].isin(recurring)]
# Строим retention по recurring_events аналогично предыдущему примеру
Альтернативные метрики: rolling retention, bracketed retention
Rolling retention
Идея: не «вернулся ровно на 7-й», а «вернулся в любой день ≥ D7».
# Rolling retention: для каждой когорты считаем долю пользователей,
# которые совершили хоть одно событие на Day >= 7
rolling = retention[retention['day_offset'] >= 7].groupby('cohort_date').apply(
lambda df: df.sort_values('day_offset', ascending=False).iloc[0]
)
rolling = rolling[['retention_rate']].rename(columns={'retention_rate': 'rolling_retention@7+'})
print(rolling)
Так узнаём, сколько людей «доиграло» до D7, даже если не было точного визита на 7-й день.
Bracketed retention
Разбиваем timeline на промежутки: [Day0–3], [Day4–7], [Day8–14] и считаем retention в каждом.
bins = [0, 3, 7, 14, 30, 60]
labels = ['0-3', '4-7', '8-14', '15-30', '31-60']
retention['bracket'] = pd.cut(retention['day_offset'], bins, labels=labels, right=True)
bracketed = (
retention.groupby(['cohort_date', 'bracket'])
.agg({'user_id': 'sum', 'cohort_size': 'first'})
.assign(retention_rate=lambda x: x['user_id'] / x['cohort_size'])
.reset_index()
)
print(bracketed.head())
Так можно посмотреть в каком «окне» пользователи в основном возвращаются.
Ссравнение 3 методов на продукте
Допустим, у нас финтех-приложение:
События: login, balance_view, payment
Период анализа: март 2025
Пользователей: 10 000
Сделаем три расчёта:
D7 naive: ровно на Day7.
Rolling retention@7+: вернулся хоть раз после Day7.
Bracketed retention: в окне [4–7].
# Данные генерим синтетически для примера
import numpy as np
np.random.seed(42)
user_ids = np.arange(1, 10001)
# каждому пользователю даём случайное число событий от 1 до 20 в течение 30 дней после D0
records = []
for uid in user_ids:
n = np.random.poisson(3)
days = np.random.choice(range(0, 31), size=n, replace=True)
for d in days:
records.append({'user_id': uid, 'event_timestamp': pd.Timestamp('2025-03-01') + pd.Timedelta(days=int(d))})
events = pd.DataFrame(records)
events['cohort_date'] = events.groupby('user_id')['event_timestamp'].transform('min')
events['day_offset'] = (events['event_timestamp'] - events['cohort_date']).dt.days
cohort_size = events.groupby('cohort_date')['user_id'].nunique().iloc[0] # ~10000
# 1) D7 naive
d7 = events[events['day_offset'] == 7]['user_id'].nunique() / cohort_size
# 2) Rolling 7+
rolling7 = events[events['day_offset'] >= 7]['user_id'].nunique() / cohort_size
# 3) Bracketed 4–7
b4_7 = events[(events['day_offset'] >= 4) & (events['day_offset'] <= 7)]['user_id'].nunique() / cohort_size
print(f"D7 naive: {d7:.2%}")
print(f"Rolling@7+: {rolling7:.2%}")
print(f"Bracketed 4–7: {b4_7:.2%}")
Результаты:
D7 naive: 8.5%
Rolling@7+: 22.3%
Bracketed 4–7: 18.7%
Смотрим только ровно на 7-й день — видим 8,5%. А в реально интересующем нас окне [4–7] удержание больше в два раза. Rolling retention даёт ещё более полную картину.
Итоги
Удержание — это кривая, а не точка.
Строим retention-матрицы через когорты. Довольно быстро и наглядно.
Разбери recurring vs one-shot: сегментируем и фильтруем.
Используй rolling & bracketed metrics для глубокого понимания жизненного цикла пользователя.
А/Б тестируй изменения именно по кривым, а не по «через 7 дней».
Если вы хотите углубиться в анализ данных, понимать тонкости поведения пользователей и разрабатывать более точные метрики удержания, полезные навыки можно отточить на специализированных уроках. Изучите новые подходы к работе с данными и метриками, которые помогут вам улучшить продукт и сделать аналитику более эффективной.
12 мая 20:00 — Ранжирующие функции потерь в рекомендательных системах. Узнать подробнее
13 мая 18:00 — Прикладные методы поиска аномалий. Узнать подробнее
21 мая 20:00 — Выявление и анализ проблем в бизнес-процессах. Узнать подробнее
Больше актуальных навыков по аналитике вы можете получить в рамках практических онлайн-курсов от экспертов отрасли.