pandasтестовое заданиеаналитик данныхpythonсессионизациясобеседование

Разбор Python тестового задания аналитика

2026-07-05 11 мин

Коротко (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_iduser_idtsevent_name
110012026-06-01 08:21:04app_open
210012026-06-01 08:22:10view_item
310012026-06-01 08:23:55add_to_cart
410012026-06-01 09:40:12app_open
510012026-06-01 09:41:30purchase
610022026-06-01 10:02:00app_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()

Разберу, почему это работает и почему это красиво:

На нашем примере пользователь 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_namesessionsconv_from_topconv_step
app_open41 230100.0NaN
view_item28 91070.170.1
add_to_cart9 64023.433.3
purchase4 11810.042.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, потому что у частот много одинаковых значений и границы квантилей совпадают. Ранжирование разводит связки. Если поймали эту ошибку на собеседовании — объясните причину вслух, это плюс к оценке.

Как подать это на собеседовании

Само задание — половина дела. Вторая половина — рассказ. Что делаю я:

Такие вопросы — «объясни разницу 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 минут. Показать, что число не взято с потолка, а привязано к поведению пользователей, — именно то, что отделяет аналитика от исполнителя.

Разбирай реальные кейсы
618 продуктовых кейсов и 545 SQL-задач с автопроверкой. Первые — без регистрации.
Решать pandas-задачи с автопроверкой →