Условие
Дана таблица событий A/B-теста (task_1_events.csv):
events— установки приложения (тип события),user_id— id юзера,ab_group— группа A/B-теста,ts— время совершения действия,pdate— дата.
Правила сессии:
- Новая сессия начинается после 30 минут бездействия.
- Сессия прерывается при переходе между двумя датами (день — естественная граница).
Постройте таблицу сессий в формате:
user_id,ab_group,start_ts— время старта сессии,end_ts— время окончания сессии,pdate— дата сессии.
Решение
Подход
Классическая задача sessionization («gap-and-island»):
- Сортируем события юзера по времени.
- Считаем разность с предыдущим событием в минутах.
- Если разность > 30 минут или дата изменилась — это начало новой сессии (флаг
1, иначе0). - Кумулятивная сумма флага по юзеру →
session_id(порядковый номер сессии). - Группируем по
(user_id, session_id)и берёмmin/maxпо времени.
Реализация — SQL (PostgreSQL/BigQuery-совместимый)
WITH events_ranked AS (
SELECT
user_id,
ab_group,
ts,
pdate,
LAG(ts) OVER (PARTITION BY user_id ORDER BY ts) AS prev_ts,
LAG(pdate) OVER (PARTITION BY user_id ORDER BY ts) AS prev_pdate
FROM events
),
flagged AS (
SELECT
user_id, ab_group, ts, pdate,
CASE
WHEN prev_ts IS NULL THEN 1
WHEN EXTRACT(EPOCH FROM (ts - prev_ts)) > 30 * 60 THEN 1
WHEN pdate <> prev_pdate THEN 1
ELSE 0
END AS is_new_session
FROM events_ranked
),
sessioned AS (
SELECT
user_id, ab_group, ts, pdate,
SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY ts
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS session_num
FROM flagged
)
SELECT
user_id,
MAX(ab_group) AS ab_group,
MIN(ts) AS start_ts,
MAX(ts) AS end_ts,
MIN(pdate) AS pdate
FROM sessioned
GROUP BY user_id, session_num
ORDER BY user_id, start_ts;Реализация — pandas (если в ноутбуке)
import pandas as pd
df = pd.read_csv("task_1_events.csv", parse_dates=["ts"])
df["pdate"] = pd.to_datetime(df["pdate"])
df = df.sort_values(["user_id", "ts"]).reset_index(drop=True)
df["prev_ts"] = df.groupby("user_id")["ts"].shift(1)
df["prev_pdate"] = df.groupby("user_id")["pdate"].shift(1)
gap_min = (df["ts"] - df["prev_ts"]).dt.total_seconds() / 60
df["is_new_session"] = (
df["prev_ts"].isna() | (gap_min > 30) | (df["pdate"] != df["prev_pdate"])
).astype(int)
df["session_num"] = df.groupby("user_id")["is_new_session"].cumsum()
sessions = (
df.groupby(["user_id", "session_num"], as_index=False)
.agg(ab_group=("ab_group", "first"),
start_ts=("ts", "min"),
end_ts=("ts", "max"),
pdate=("pdate", "first"))
.drop(columns=["session_num"])
)Анализ / интерпретация
Получаем 1 строку на сессию. Это та таблица, на основе которой строится конверсия в N-ю сессию — типичная продуктовая метрика для A/B-тестов на onboarding.
Подводные камни
- Граница «дата». Если событие произошло в 23:55, а следующее в 00:05 — разница 10 минут, но
pdateизменилась, и по условию это новая сессия. Без проверки даты получили бы единую сессию через полночь. - Часовой пояс. Если
ts— UTC, аpdate— local, граница «полночь» сместится. Договоритесь с командой о tz. - 30 минут — стандарт GA, не догма. Для коротких приложений (push-уведомления) разумнее 5–10 минут.
- Один user_id с разными
ab_group. Бывает багом сплита. ИспользуйтеMAX(ab_group)или фиксируйте по первой записи и сообщайте о SRM. UNBOUNDED PRECEDINGв кумулятивной сумме — обязательно, иначе CUMSUM не накопится.- Дубликаты событий с одинаковым
ts— добавьте детерминированный tie-breaker (например,event_nameилиevent_id), чтобы LAG был стабилен. - Конец сессии «жёсткий». В реальности сессия должна «закрываться» спустя 30 минут после последнего события, а не в момент последнего. Это влияет на расчёт
session_duration(если она нужна).
Эталонный ответ
Через LAG + флаг новой сессии (gap > 30 минут или смена даты) + кумулятивная сумма → session_num. Финальный GROUP BY (user_id, session_num) с MIN/MAX(ts). Это стандартный gap-and-island паттерн.