pythonpandasпродуктовая-аналитикаretention

Когортный анализ retention в Python и pandas

2026-07-11 9 мин

Когортный retention в pandas строится в четыре движения: присвоить каждому пользователю когорту (обычно месяц его первого события), посчитать для каждого события «возраст» в месяцах от когорты, свести всё в сводную таблицу через groupby + pivot_table, а потом поделить каждую строку на размер когорты в нулевом месяце. Дальше — heatmap, и матрица удержания готова. Всё это без единой строчки SQL, на голом pandas. Ниже — весь путь с кодом, который можно скопировать и запустить на своих данных.

Я считаю такие матрицы почти каждую неделю: когда продукт спрашивает «а мартовская когорта возвращается лучше апрельской?», ответ рождается ровно из этой таблицы. Разберём её по кирпичикам.

Что такое когортный retention и почему его считают в pandas?

Когорта — это группа пользователей, объединённая по одному событию в один период времени. Самый частый вариант — когорта по месяцу регистрации: все, кто завёл аккаунт в марте, — это «мартовская когорта». Retention когорты на N-й месяц показывает, какая доля этой группы вернулась в продукт спустя N месяцев после старта.

Зачем это pandas, а не дашборд? Потому что сырые данные редко приходят готовыми. Тебе дали выгрузку событий в CSV, надо быстро проверить гипотезу, собрать матрицу, покрутить разбивки по каналам — всё это удобнее в ноутбуке, чем гонять по BI-инструменту. На собеседовании продуктового аналитика когортную матрицу тоже нередко просят собрать руками на pandas за 20 минут — это классическая проверка на владение groupby, pivot_table и работой с датами. Похожие задачи разбираются в тренажёре по Python и в подборке вопросов с собеседований.

Retention напрямую связан с базовыми продуктовыми метриками — если не уверен в определениях активности, загляни в разбор метрики DAU: без чёткого «что считаем возвратом» когортная матрица получится бессмысленной.

Какие данные нужны для расчёта?

Минимум — таблица событий с двумя колонками: идентификатор пользователя и дата события. Событием может быть логин, заказ, открытие приложения — что угодно, что вы считаете «активностью».

import pandas as pd

df = pd.read_csv('events.csv', parse_dates=['event_date'])
print(df.head())
#    user_id event_date
# 0        1 2026-01-05
# 1        1 2026-02-11
# 2        2 2026-01-07
# 3        3 2026-03-02
# 4        1 2026-03-19

Обрати внимание на parse_dates — без него event_date будет строкой, и вся арифметика по месяцам сломается. Это ошибка номер один у новичков: колонка выглядит как дата, а на деле это object. Проверяй df.dtypes до того, как двигаться дальше.

Дальше всё считается только из этих двух колонок. Никаких заранее посчитанных «месяцев жизни» нам не нужно — pandas выведет их сам.

Как присвоить пользователю когорту по месяцу регистрации?

Когорта пользователя — это месяц его самого первого события. Берём минимальную дату по каждому user_id и обрезаем её до месяца. Удобнее всего работать с типом Period, потому что он умеет вычитаться и сразу даёт разницу в месяцах.

# месяц самого события
df['order_month'] = df['event_date'].dt.to_period('M')

# месяц ПЕРВОГО события пользователя = его когорта
df['cohort_month'] = (
    df.groupby('user_id')['event_date']
      .transform('min')
      .dt.to_period('M')
)

Ключевой приём здесь — transform('min'). В отличие от agg, он возвращает результат той же длины, что исходный DataFrame, поэтому значение когорты аккуратно проставляется в каждую строку событий пользователя. Если ты путаешься, когда нужен transform, а когда agg — это частый вопрос на собеседовании, и заодно повод заглянуть в справочник по Python и pandas.

Теперь у каждой строки есть две пометки: в каком месяце случилось событие (order_month) и к какой когорте относится пользователь (cohort_month).

Как посчитать номер месяца жизни (cohort index)?

Cohort index — это «возраст» события: сколько месяцев прошло между когортой пользователя и текущим событием. Для нулевого месяца (месяц старта) индекс равен 0, для следующего — 1 и так далее. Поскольку обе колонки типа Period, их разность даёт готовый offset, из которого достаём целое число.

df['cohort_index'] = (
    (df['order_month'] - df['cohort_month'])
    .apply(lambda x: x.n)
)
x.n вытаскивает количество периодов из offset-объекта. Если тебе привычнее считать «руками», тот же результат даёт арифметика по годам и месяцам — иногда её проще объяснить на собеседовании:
df['cohort_index'] = (
    (df['order_month'].dt.year  - df['cohort_month'].dt.year) * 12
  + (df['order_month'].dt.month - df['cohort_month'].dt.month)
)

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

Как собрать матрицу удержания через pivot?

Считаем уникальных пользователей в каждой паре (когорта, месяц жизни). Именно уникальных — nunique, а не count, иначе активный юзер с пятью заказами в месяц раздует цифры.

cohort_data = (
    df.groupby(['cohort_month', 'cohort_index'])['user_id']
      .nunique()
      .reset_index()
)

cohort_counts = cohort_data.pivot_table(
    index='cohort_month',
    columns='cohort_index',
    values='user_id'
)

print(cohort_counts)
# cohort_index      0     1     2     3
# cohort_month
# 2026-01         500   210   150   120
# 2026-02         640   250   180   NaN
# 2026-03         700   300   NaN   NaN

Ступенчатая форма с NaN в правом нижнем углу — это норма, а не баг. Мартовская когорта физически не могла прожить три месяца, если данные заканчиваются в марте. Эти NaN мы не трогаем и не заполняем нулями — иначе retention просядет искусственно.

Нулевая колонка (cohort_index == 0) — это размер каждой когорты: сколько всего человек стартовало. Она станет знаменателем.

Как перевести матрицу в проценты retention?

Делим каждую строку на её нулевую колонку. divide(..., axis=0) делает это построчно, и pandas сам протягивает деление по всем столбцам.

cohort_size = cohort_counts[0]
retention = cohort_counts.divide(cohort_size, axis=0)

print((retention * 100).round(1))
# cohort_index      0     1     2     3
# cohort_month
# 2026-01       100.0  42.0  30.0  24.0
# 2026-02       100.0  39.1  28.1   NaN
# 2026-03       100.0  42.9   NaN   NaN

Первая колонка всегда 100% — по определению, стартовали все. Дальше читаем матрицу по строкам: мартовская когорта на первый месяц удержала 42.9%. Читаем по столбцам — сравниваем когорты между собой на одинаковом возрасте: на первом месяце январь дал 42%, февраль 39.1%, март 42.9%. Если бы февраль стабильно проваливался, стоило бы вспомнить, что происходило в феврале — акция, сломанный онбординг, сезонность.

Средний retention по продукту на N-й месяц — это среднее по столбцу N (retention.mean()), но осторожно: молодые когорты не участвуют в дальних столбцах, и среднее считается по неполной выборке.

Как построить heatmap когортного retention?

Матрицу удобно смотреть цветом: тёплый — высокое удержание, холодный — низкое. Библиотека seaborn делает это в пару строк.

import matplotlib.pyplot as plt
import seaborn as sns

plt.figure(figsize=(12, 7))
sns.heatmap(
    retention,
    annot=True,
    fmt='.0%',
    cmap='YlGnBu',
    linewidths=0.5,
    vmin=0, vmax=0.5   # обрезаем шкалу, чтобы 100% в нулевой колонке не съедал контраст
)
plt.title('Когортный retention по месяцу регистрации')
plt.ylabel('Когорта')
plt.xlabel('Месяц жизни')
plt.tight_layout()
plt.show()

Один нюанс из практики: нулевая колонка всегда 100%, и если не ограничить шкалу через vmin/vmax, она перетянет на себя всю яркость, а интересные различия в дальних месяцах сольются в один цвет. Я обычно либо режу шкалу, либо вообще выкидываю нулевую колонку из heatmap (retention.iloc[:, 1:]), оставляя её только в подписи размера когорты.

Дальше матрица читается мгновенно: диагональные полосы одного цвета — это «возрастной» паттерн (все когорты ведут себя похоже), а вертикальные аномалии — это уже про конкретный календарный месяц.

Classic, rolling и unbounded retention — что выбрать?

Это тот самый вопрос, на котором спотыкаются на собеседованиях, и от ответа зависит вся логика расчёта.

Простой rolling-вариант в pandas:

last_index = df.groupby('user_id')['cohort_index'].max()
cohort_of  = df.groupby('user_id')['cohort_month'].first()

alive = pd.DataFrame({'cohort_month': cohort_of, 'last_index': last_index})
# юзер "жив" на месяц N, если last_index >= N

Классический retention честнее показывает вовлечённость (человек реально вернулся именно тогда), rolling — мягче и лучше подходит для продуктов с нерегулярным использованием. Главное — договориться о единственном определении и не смешивать их в одном отчёте. Полный разбор этих типов и edge-кейсов с NULL я делал в отдельном материале — когортный retention в SQL: логика ровно та же, отличается только синтаксис.

Какие ошибки чаще всего ломают когортный анализ?

Собрал грабли, на которые наступал сам и вижу у джунов:

Если хочется закрепить это руками, а не читая, — на Python-тренажёре есть задачи ровно на такие сводные таблицы, а в разделе тестовых заданий и продуктовых кейсов retention встречается в связке с воронками и юнит-экономикой.

Что дальше

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

Если готовишься системно — на нашем тренажёре 402 Python-задачи и 425 SQL-задач, многие как раз про retention, когорты и метрики; первые открыты бесплатно, остальное — в Pro. Заходи в Python-тренажёр, найди задачи с тегом «retention» и собери свою первую матрицу на реальных данных — это лучший способ, чтобы код из этой статьи закрепился навсегда.

Отработай pandas на задачах
groupby, pivot и когорты на живых данных через Pyodide — первые задачи бесплатно.
Python-тренажёр →