Коротко (BLUF): Типичное pandas-тестовое для аналитика 2026 — это лог событий и просьба «посчитайте сессии, конверсию по шагам и среднее время до целевого действия». Правильное решение — три приёма: sort_values + groupby.diff() для интервалов между событиями, разметка новых сессий флагом «разрыв > 30 минут», cumsum() для сквозной нумерации. Никаких for и iterrows() — всё векторно. Ниже разбираю задачу от чтения .csv до проверки результата, показываю, где джуны спотыкаются, и как это подать интервьюеру, чтобы задание засчитали.
Откуда взята задача
Я собрал этот кейс из реальных тестовых, которые встречаются на рынке. В каталоге No Data No Growth 189+ разобранных заданий, и у части из них тег #python #pandas #кейс — это, например, задания Альфа-Банка (продуктовый аналитик, middle), Сбера (data analyst), Bolt (ridetech), Aviasales и Самоката. Формат почти всегда один: дают сырой лог (заказы, клики, события приложения) в .csv и просят «сессионизировать», «построить воронку», «посчитать RFM» или «rolling-метрику без готовых агрегатов».
Самый частый и самый показательный вариант — сессионизация лога событий с таймаутом 30 минут. Это классика: техника с groupby('user_id')['ts'].diff() и разметкой сессий подробно описана в разборе Datalytics «Делаем сессии из лога событий с помощью Pandas», а логика «событие открывает новую сессию, если прошло больше 30 минут после предыдущего» — стандарт продуктовой аналитики. На нём и построю разбор, добавив воронку и время до конверсии — ровно то, что обычно идёт вторым пунктом задания.
Условие
Вам дали выгрузку событий мобильного приложения за неделю (events.csv, ~500 тыс. строк). Нужно:
1. Разбить события на сессии (сессия обрывается, если между событиями пользователя прошло больше 30 минут).
2. Посчитать конверсию воронки app_open → view_item → add_to_cart → purchase.
3. Найти медианное время от первого события сессии до purchase для сессий, где покупка была.
Решение — на Python + pandas, без циклов по строкам. Приложите код и краткие выводы.
Пример данных:
| event_id | user_id | ts | event_name |
|---|---|---|---|
| 1 | 1001 | 2026-06-01 08:21:04 | app_open |
| 2 | 1001 | 2026-06-01 08:22:10 | view_item |
| 3 | 1001 | 2026-06-01 08:23:55 | add_to_cart |
| 4 | 1001 | 2026-06-01 09:40:12 | app_open |
| 5 | 1001 | 2026-06-01 09:41:30 | purchase |
| 6 | 1002 | 2026-06-01 10:02:00 | app_open |
Обратите внимание на пользователя 1001: между событиями 3 и 4 прошёл 1 час 16 минут — значит, это две разные сессии, хотя пользователь один. Именно на этом ловят: наивный аналитик считает «одна сессия = один user_id за день» и получает неверную воронку.
Шаг 1. Чтение данных и типизация
Первое, что оценивает интервьюер, — умеете ли вы сразу привести типы. Строковая дата в pandas — источник 90% багов в тестовых.
import pandas as pd
df = pd.read_csv(
"events.csv",
parse_dates=["ts"], # сразу datetime64, а не object
dtype={"user_id": "int64", "event_name": "category"},
)
# Санити-чек до любых расчётов
print(df.shape)
print(df["ts"].dtype) # должно быть datetime64[ns]
print(df["event_name"].value_counts())
print(df.isna().sum()) # пропуски убьют diff — проверяем сразу
Если ts прочитался как object, весь diff() дальше сломается молча — разница строк даст мусор или ошибку. Поэтому parse_dates — не украшение, а часть решения. Категориальный тип для event_name — приятный бонус: на 500 тыс. строк экономит память и ускоряет groupby.
Шаг 2. Сессионизация без единого цикла
Ядро задания. Логика ровно как в разборе Datalytics: сортируем по пользователю и времени, считаем интервал между соседними событиями внутри каждого user_id, помечаем новую сессию там, где интервал больше 30 минут (или это первое событие пользователя — там будет NaT).
TIMEOUT = pd.Timedelta(minutes=30)
df = df.sort_values(["user_id", "ts"]).reset_index(drop=True)
# Интервал до предыдущего события ЭТОГО пользователя
df["gap"] = df.groupby("user_id")["ts"].diff()
# Флаг начала новой сессии: первое событие (NaT) ИЛИ разрыв > 30 мин
df["is_new_session"] = df["gap"].isna() | (df["gap"] > TIMEOUT)
# Сквозной номер сессии через накопительную сумму флагов
df["session_id"] = df["is_new_session"].cumsum()
Разберу, почему это работает и почему это красиво:
groupby("user_id")["ts"].diff()— векторная операция, считает разницу с предыдущей строкой внутри группы. Для первого события каждого пользователя даётNaT.is_new_session— булев вектор.Trueтам, где новая сессия начинается.cumsum()по булевому вектору — гениальный трюк:Trueсчитается за 1,Falseза 0. Накопительная сумма растёт ровно на каждой новой сессии, давая уникальный сквознойsession_id. Не нужен ниmerge_asof, ни цикл.
На нашем примере пользователь 1001 получит session_id = 1 для событий 1–3 и session_id = 2 для событий 4–5, потому что между ними разрыв 76 минут — флаг is_new_session сработал.
Если интервьюер просит id сессии в человекочитаемом виде, добавьте:
df["session_key"] = df["user_id"].astype(str) + "_" + df["session_id"].astype(str)
Шаг 3. Воронка конверсии
Теперь считаем, сколько сессий дошло до каждого шага. Ключевая мысль: воронку считаем по сессиям, а не по событиям — иначе три view_item в одной сессии раздуют числитель.
FUNNEL = ["app_open", "view_item", "add_to_cart", "purchase"]
# Множество событий каждой сессии (уникальные шаги)
reached = (
df[df["event_name"].isin(FUNNEL)]
.groupby("session_id")["event_name"]
.agg(set)
)
# Сколько сессий достигло каждого шага
counts = {step: reached.apply(lambda s: step in s).sum() for step in FUNNEL}
funnel = pd.Series(counts)
# Конверсия шаг-к-шагу и к вершине воронки
funnel_df = pd.DataFrame({"sessions": funnel})
funnel_df["conv_from_top"] = (funnel_df["sessions"] / funnel_df["sessions"].iloc[0] * 100).round(1)
funnel_df["conv_step"] = (funnel_df["sessions"] / funnel_df["sessions"].shift(1) * 100).round(1)
print(funnel_df)
Результат — таблица, где сразу видно, на каком шаге отваливается больше всего людей:
| event_name | sessions | conv_from_top | conv_step |
|---|---|---|---|
| app_open | 41 230 | 100.0 | NaN |
| view_item | 28 910 | 70.1 | 70.1 |
| add_to_cart | 9 640 | 23.4 | 33.3 |
| purchase | 4 118 | 10.0 | 42.7 |
Вывод формулируется в одну строку: главная точка отвала — переход view_item → add_to_cart (33.3%), туда и стоит копать. Именно такой вывод, а не голый код, отличает middle от джуна.
Шаг 4. Время до покупки — векторно через transform
Последний пункт: медианное время от старта сессии до purchase. Здесь новички тянутся к iterrows() или apply(axis=1) — и это главная ошибка, за которую снижают оценку. apply(axis=1) итерирует строки как Python-объекты и на 500 тыс. строк работает в десятки раз медленнее векторного варианта (см. разбор Pandas GroupBy at Speed). Правильный инструмент — groupby.transform, который «размазывает» агрегат группы обратно на каждую строку с сохранением формы.
# Время старта каждой сессии, приклеенное к каждой строке — это transform, а не agg
df["session_start"] = df.groupby("session_id")["ts"].transform("min")
# Берём только purchase-события
purchases = df[df["event_name"] == "purchase"].copy()
purchases["time_to_purchase"] = purchases["ts"] - purchases["session_start"]
median_ttp = purchases["time_to_purchase"].median()
print("Медианное время до покупки:", median_ttp) # напр. 0 days 00:04:37
Почему transform("min"), а не agg? agg схлопнул бы группу в одну строку, и я потерял бы соответствие с исходными событиями. transform возвращает вектор той же длины, что вход — ровно то, что нужно, чтобы вычесть старт из времени покупки построчно. Это тот самый водораздел «reduce vs broadcast», о котором пишет документация pandas по groupby: agg — чтобы сжать, transform — чтобы разложить обратно.
Шаг 5. Проверка результата (то, что забывают все)
Задание не закончено, пока вы не проверили, что не соврали в числах. Три быстрых санити-чека, которые я всегда прикладываю:
# 1. Сессий не больше, чем событий, и не меньше, чем пользователей
assert df["session_id"].nunique() <= len(df)
assert df["session_id"].nunique() >= df["user_id"].nunique()
# 2. Внутри сессии интервалы между событиями не превышают таймаут
max_gap = df.groupby("session_id")["ts"].agg(lambda s: s.diff().max())
assert max_gap.dropna().max() <= TIMEOUT, "Внутри сессии есть разрыв > 30 мин — ошибка разметки"
# 3. Воронка монотонно убывает
assert funnel_df["sessions"].is_monotonic_decreasing, "Воронка не может расти вверх"
Второй ассерт — самый ценный. Если он падает, значит вы забыли отсортировать по user_id, ts перед diff(), и сессии слиплись. Этот баг я видел в чужих решениях чаще всего: diff() посчитался по неотсортированным строкам и дал отрицательные интервалы.
Если хотите потренировать groupby/transform/merge на автопроверяемых задачах, у меня на python-sandbox 538 таких — от парсинга логов до сессионизации, с решениями и разбором сложности.
Альтернатива: если дали задачу на RFM
Иногда вместо сессий просят RFM-сегментацию — то же семейство, только по заказам. Логика: для каждого клиента считаем Recency (дней с последней покупки), Frequency (число заказов), Monetary (сумму), затем режем на квантили. Публичный датасет для тренировки — бразильский Olist (olist_orders_dataset.csv + olist_order_payments_dataset.csv), на нём построено большинство разборов, включая RFM analysis in Python.
snapshot = df["order_ts"].max() + pd.Timedelta(days=1)
rfm = df.groupby("customer_id").agg(
recency=("order_ts", lambda s: (snapshot - s.max()).days),
frequency=("order_id", "nunique"),
monetary=("payment", "sum"),
)
# Оценки 1–3 через квантили; для recency метки инвертированы (меньше дней = лучше)
rfm["R"] = pd.qcut(rfm["recency"], 3, labels=[3, 2, 1])
rfm["F"] = pd.qcut(rfm["frequency"].rank(method="first"), 3, labels=[1, 2, 3])
rfm["M"] = pd.qcut(rfm["monetary"], 3, labels=[1, 2, 3])
rfm["segment"] = rfm["R"].astype(str) + rfm["F"].astype(str) + rfm["M"].astype(str)
Тонкость, на которой валятся: для frequency через qcut без .rank(method="first") вы получите ValueError: Bin edges must be unique, потому что у частот много одинаковых значений и границы квантилей совпадают. Ранжирование разводит связки. Если поймали эту ошибку на собеседовании — объясните причину вслух, это плюс к оценке.
Как подать это на собеседовании
Само задание — половина дела. Вторая половина — рассказ. Что делаю я:
- Начинаю с уточняющих вопросов, а не с кода. «Таймаут сессии 30 минут — это продуктовый стандарт или можно обсудить? Считаем воронку по сессиям или по пользователям?» Это сразу показывает продуктовое мышление. Даже если ответа нет — фиксирую допущение в комментарии.
- Проговариваю, почему без циклов. Не «я знаю, что циклы медленные», а конкретно: «
apply(axis=1)итерирует строки как Python-объекты, теряя векторизацию;groupby.transformделает то же самое на C-уровне и на полумиллионе строк быстрее в разы». - Показываю проверку. Ассерты из шага 5 — это то, что запоминается. Аналитик, который сам ловит свои ошибки, дороже того, кто пишет красивый, но непроверенный код.
- Заканчиваю выводом, а не таблицей. «Главная течь — переход в корзину, 33%. Гипотеза: на карточке товара не хватает триггера. Проверил бы A/B-тестом кнопки.» Числа без интерпретации никому не нужны.
Такие вопросы — «объясни разницу agg и transform», «почему не iterrows», «как ты проверил результат» — я собрал в разделе вопросов для собеседования, их там 3900+. А сквозную практику продуктовых задач вроде воронки и сессий — в кейсах. Если только начинаете с SQL-части (её почти всегда просят до Python), есть бесплатный курс SQL и тренажёр SQL-задач.
Частые вопросы
Можно ли решать сессионизацию через merge_asof вместо cumsum?
Да, и в разборе Datalytics используется именно merge_asof: помечают начала сессий, затем «приклеивают» к каждому событию ближайший предыдущий старт. Это рабочий подход, но cumsum() по булеву флагу короче и понятнее: одна строка вместо отдельного датафрейма стартов и merge. На собеседовании я показываю cumsum-вариант как основной, а merge_asof упоминаю как альтернативу — это демонстрирует, что вы знаете оба.
Почему нельзя использовать iterrows или apply(axis=1) в тестовом?
Формально можно — код отработает. Но iterrows() и apply(axis=1) итерируют строки как Python-объекты, теряя векторизацию, и на реальных объёмах (сотни тысяч строк) работают в десятки раз медленнее. В тестовом это читается как «не владеет pandas на уровне middle». Векторные операции, groupby.transform и булева индексация — то, что ожидают увидеть. Циклы допустимы только там, где векторизация принципиально невозможна.
Что делать, если в логе есть дубли событий и пропуски времени?
Проверить это до расчётов — часть задания. Дубли (df.duplicated(["user_id", "ts", "event_name"]).sum()) чаще всего убираются drop_duplicates, но обязательно проговорите допущение: «считаю, что два одинаковых события в одну секунду — это дубль логирования». Строки с пропущенным ts из сессионизации исключаю, потому что diff() по NaT даст неверный интервал. Главное — не молча выкинуть данные, а зафиксировать решение в комментарии и посчитать, сколько строк потеряли.
Как выбрать таймаут сессии, если его не задали?
Отраслевой стандарт — 30 минут (пришёл из веб-аналитики). Если задание не уточняет, берите 30 минут и явно пишите это как допущение. Сильный ответ — добавить, что таймаут зависит от продукта: для новостного приложения 30 минут нормально, для банковского, где сессия по своей природе короткая, разумнее 10–15 минут. Показать, что число не взято с потолка, а привязано к поведению пользователей, — именно то, что отделяет аналитика от исполнителя.