Когортный 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 — что выбрать?
Это тот самый вопрос, на котором спотыкаются на собеседованиях, и от ответа зависит вся логика расчёта.
- Classic (N-day / N-month) retention — пользователь считается удержанным на месяц N, только если он был активен именно в этом месяце. Это то, что мы посчитали выше через
nuniqueпоcohort_index. - Rolling retention — удержан на месяц N, если был активен в месяце N или в любом более позднем. Считается через кумулятивную логику: для каждого юзера берём его максимальный
cohort_indexи говорим, что он «дожил» до всех месяцев вплоть до максимального. - Unbounded / range 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: логика ровно та же, отличается только синтаксис.
Какие ошибки чаще всего ломают когортный анализ?
Собрал грабли, на которые наступал сам и вижу у джунов:
countвместоnunique. Считаешь строки событий, а не людей — retention улетает за 100%. Всегдаnunique('user_id').- Заполнение
NaNнулями. Молодые когорты ещё не дожили до дальних месяцев. Нули занизят средние и покрасят heatmap в ложный «провал». - Дата как строка. Забыл
parse_dates— и вычитание месяцев либо падает, либо даёт мусор. - Когорта по случайному, а не первому событию. Когорта — это
min(event_date)пользователя. Если взять произвольную дату, поедет вся матрица. - Смешение classic и rolling в одной таблице. Определись заранее.
- Дубли событий. Один и тот же лог задвоился при выгрузке —
nuniqueчастично спасает, но лучше проверитьdf.duplicated().sum()до расчёта.
Если хочется закрепить это руками, а не читая, — на Python-тренажёре есть задачи ровно на такие сводные таблицы, а в разделе тестовых заданий и продуктовых кейсов retention встречается в связке с воронками и юнит-экономикой.
Что дальше
Когортная матрица — это фундамент, на который потом ложатся разбивки: retention по каналу привлечения, по первой покупке, по тарифу. Механика та же — меняется только колонка, по которой строим когорту. Освоив базовый расчёт на pandas, ты закрываешь добрую половину продуктовых вопросов на собеседовании.
Если готовишься системно — на нашем тренажёре 402 Python-задачи и 425 SQL-задач, многие как раз про retention, когорты и метрики; первые открыты бесплатно, остальное — в Pro. Заходи в Python-тренажёр, найди задачи с тегом «retention» и собери свою первую матрицу на реальных данных — это лучший способ, чтобы код из этой статьи закрепился навсегда.