Когортная матрица в pandas: почему будущие месяцы нельзя заполнять нулями
В когортной таблице октябрьские покупатели выглядят хуже августовских: после первого месяца у них стоят нули. Но ноябрь ещё не наступил. Построим retention в pandas так, чтобы отсутствие возвращений отличалось от отсутствия времени для наблюдения, а график помогал сравнивать качество клиентских когорт.
Что именно означает ячейка retention
Когортой назовём месяц первой покупки. Возраст 1 означает следующий календарный месяц после первого, возраст 2 — второй следующий. Ячейка показывает долю клиентов первоначальной когорты, которые покупали на этом возрасте. Две покупки одного человека в одном месяце дают одно возвращение. Так метрика отвечает на вопрос о возвращении покупателей, а частота заказов остаётся отдельным показателем.
Календарный возраст отличается от срока в днях. Покупка 31 августа и повторная покупка 1 сентября дают возраст 1, хотя прошли сутки. Этот вариант удобен для месячного управленческого отчёта. Если продукт требует возвращения через 30 дней, нужно другое правило расчёта возраста. Переходить между этими определениями от отчёта к отчёту нельзя: внешне похожие проценты будут означать разные действия клиентов.
Первая покупка определяется по полной доступной истории, а не только по анализируемым трём месяцам. Если база начинается в августе, старый клиент может ошибочно попасть в августовскую когорту. Перед запуском проверьте начало наблюдения и миграции идентификаторов. Полезно хранить подтверждённую дату первого заказа в клиентском справочнике и сверять её с исходными событиями.
Как отличить ноль от будущего месяца
В примере отчёт закрыт по конец октября. Августовская когорта успела прожить возраст 0, 1 и 2, сентябрьская — 0 и 1, октябрьская — только 0. Ноль можно поставить там, где период уже завершён и не вернулся ни один клиент. В будущих ячейках оставляем NaN. На тепловой карте этим ячейкам нужен отдельный нейтральный цвет.
Сначала группируем покупки по когорте и возрасту, считаем nunique(user), затем разворачиваем возраст в столбцы. reindex добавляет требуемые столбцы даже там, где событий пока не было. После деления на размер когорты заполняем пропуски нулями, а затем маской возвращаем NaN в будущие периоды. Именно последний шаг сохраняет смысл пустой части матрицы.
Размер каждой когорты считаем отдельно по клиентам и фиксируем. Он не уменьшается из-за того, что часть покупателей перестала возвращаться. Если для следующего возраста брать только клиентов, которые были активны на предыдущем, получится условное продолжение активности. Это полезный показатель, но он уже не является обычным retention от исходной когорты.
import pandas as pd
d = pd.DataFrame({"user":[1,1,1,2,3,3,4],"date":["2026-08-02","2026-09-03","2026-10-03","2026-08-10","2026-09-05","2026-10-06","2026-10-01"]})
d["month"] = pd.to_datetime(d.date).dt.to_period("M")
d["cohort"] = d.groupby("user").month.transform("min")
d["age"] = [m.ordinal-c.ordinal for m,c in zip(d.month,d.cohort)]
counts = d.groupby(["cohort","age"]).user.nunique().unstack().reindex(columns=range(3))
sizes = d.groupby("cohort").user.nunique()
ret = counts.div(sizes,axis=0).fillna(0)
cutoff = pd.Period("2026-10",freq="M")
for cohort in ret.index:
for age in ret.columns:
if cohort+age > cutoff:
ret.loc[cohort,age] = float("nan")
print("cohort sizes",sizes.to_dict())
print(ret.to_string())
assert ret.loc[pd.Period("2026-08"),1] == 0.5
assert pd.isna(ret.loc[pd.Period("2026-10"),1])
assert ret.loc[pd.Period("2026-09"),1] == 1
В августовской когорте два клиента, в сентябре вернулся один: retention возраста 1 равен 50%. В сентябрьской когорте один клиент, и он вернулся в октябре: 100%. У октябрьской когорты возраст 1 пока пустой. Она ещё не показала плохого удержания; сравнение просто не состоялось. Число клиентов рядом с процентом показывает, насколько мало данных стоит за красивой ячейкой.
Перед выводом сравнивайте когорты на одинаковом возрасте. Для среднего по нескольким когортам выберите веса: средняя доля когорты приравнивает маленькую группу к большой, а общая доля вернувшихся взвешивает их числом покупателей. В записке стоит показать сам размер групп, особенно если рекламная акция дала резкий приток новых клиентов.
Следующее действие — разобрать отличающиеся когорты по каналу приобретения, продукту или условиям первой покупки. Начните с одного правдоподобного различия и проверьте его в исходных данных. Одновременная смена скидок и рекламы может объяснять рисунок, но сама матрица не определяет причину. Для незакрытого текущего месяца либо исключите его полностью, либо явно обозначьте неполный период: скрыто сравнивать его с завершённым нельзя.
Те же правила единицы наблюдения можно разобрать на SQL-примере: Retention по когортам в SQL: знаменатель, дубли и периоды.
Контрольная сверка перед тепловой картой
Первый столбец наблюдавшихся когорт должен быть равен единице: каждый клиент по определению совершил первую покупку. Если появляется значение выше 100%, вероятно, в числитель попали события вместо уникальных клиентов или размер когорты взят из другой выборки. Значение ниже 100% часто указывает на фильтр, применённый только к числителю.
Проверьте также последовательность наблюдавшихся возрастов. У одной когорты не может быть завершён возраст 2 и одновременно оставаться будущим возраст 1. Если такая форма возникает после выгрузки в таблицу, вероятно, потерялась маска будущих периодов. Полезно сохранять числовую матрицу и маску отдельно от оформления графика, чтобы форматирование не превращало NaN в нули.
Следующая рабочая задача
В «Практической аналитике данных» удержание клиентов — один из трёх прикладных кейсов. Вы проверяете таблицы, рассчитываете метрики через pandas или SQL, строите графики в DataLens и объясняете результат. В финале анализ повторяется на новом месяце и оформляется в аналитическую записку. Откройте программу курса «Практическая аналитика данных».
Продолжить на практике
- Практическая аналитика данных ↗
Превратите знания SQL или Python в аналитическую работу: проверяйте данные, считайте бизнес-метрики, стройте дашборды и объясняйте выводы на трёх прикладных кейсах.
