Привет, Хабр!
Сегодня поговорим про ретеншн — ту самую метрику, от которой часто пляшут все продуктовые команды. Вы знаете: «вернулся через 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 — Выявление и анализ проблем в бизнес-процессах. Узнать подробнее
Больше актуальных навыков по аналитике вы можете получить в рамках практических онлайн-курсов от экспертов отрасли.
