D7 — не показатель: ищем правду

от автора

Привет, Хабр!

Сегодня поговорим про ретеншн — ту самую метрику, от которой часто пляшут все продуктовые команды. Вы знаете: «вернулся через 7 дней» (D7) — и сказано, что мы класс

Но на деле класс ломается, как только продукт усложняется. В этой статье рассмотрим, почему классический D7 retention не работает, как построить настоящие кривые удержания через когорты, в чём разница между recurring vs one-shot поведением, какие есть альтернативные метрики и сравним три метода на примере.

Где ломается классический D7 Retention

Классический подход:

  1. Берём пользователей, зарегистрировавшихся в день D0.

  2. Смотрим, сколько из них вернулось ровно на D7.

  3. Получаем «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 может «хвостиком» тянуться на неделю, месяц, год.

Как это учесть?

  1. Кластеризовать пользователей: K-means/DBSCAN по поведению (частота, глубина, время) и строить retention по сегментам.

  2. Фильтровать «одноразовок»: задать минимум событий в первые N дней, чтобы выделить активных core-пользователей.

  3. Анализ событий разных типов: отдельно смотреть 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

Сделаем три расчёта:

  1. D7 naive: ровно на Day7.

  2. Rolling retention@7+: вернулся хоть раз после Day7.

  3. 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 даёт ещё более полную картину.

Итоги

  1. Удержание — это кривая, а не точка.

  2. Строим retention-матрицы через когорты. Довольно быстро и наглядно.

  3. Разбери recurring vs one-shot: сегментируем и фильтруем.

  4. Используй rolling & bracketed metrics для глубокого понимания жизненного цикла пользователя.

  5. А/Б тестируй изменения именно по кривым, а не по «через 7 дней».


Если вы хотите углубиться в анализ данных, понимать тонкости поведения пользователей и разрабатывать более точные метрики удержания, полезные навыки можно отточить на специализированных уроках. Изучите новые подходы к работе с данными и метриками, которые помогут вам улучшить продукт и сделать аналитику более эффективной.

Больше актуальных навыков по аналитике вы можете получить в рамках практических онлайн-курсов от экспертов отрасли.


ссылка на оригинал статьи https://habr.com/ru/articles/905068/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *