Собесов

Aviasales — построение пользовательских сессий из событий A/B-теста

SQLОкна и сессионизацияСредняяMiddle

Условие

Дана таблица событий A/B-теста (task_1_events.csv):

  • events — установки приложения (тип события),
  • user_id — id юзера,
  • ab_group — группа A/B-теста,
  • ts — время совершения действия,
  • pdate — дата.

Правила сессии:

  1. Новая сессия начинается после 30 минут бездействия.
  2. Сессия прерывается при переходе между двумя датами (день — естественная граница).

Постройте таблицу сессий в формате:

  • user_id,
  • ab_group,
  • start_ts — время старта сессии,
  • end_ts — время окончания сессии,
  • pdate — дата сессии.

Решение

Подход

Классическая задача sessionization («gap-and-island»):

  1. Сортируем события юзера по времени.
  2. Считаем разность с предыдущим событием в минутах.
  3. Если разность > 30 минут или дата изменилась — это начало новой сессии (флаг 1, иначе 0).
  4. Кумулятивная сумма флага по юзеру → session_id (порядковый номер сессии).
  5. Группируем по (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.

Подводные камни

  1. Граница «дата». Если событие произошло в 23:55, а следующее в 00:05 — разница 10 минут, но pdate изменилась, и по условию это новая сессия. Без проверки даты получили бы единую сессию через полночь.
  2. Часовой пояс. Если ts — UTC, а pdate — local, граница «полночь» сместится. Договоритесь с командой о tz.
  3. 30 минут — стандарт GA, не догма. Для коротких приложений (push-уведомления) разумнее 5–10 минут.
  4. Один user_id с разными ab_group. Бывает багом сплита. Используйте MAX(ab_group) или фиксируйте по первой записи и сообщайте о SRM.
  5. UNBOUNDED PRECEDING в кумулятивной сумме — обязательно, иначе CUMSUM не накопится.
  6. Дубликаты событий с одинаковым ts — добавьте детерминированный tie-breaker (например, event_name или event_id), чтобы LAG был стабилен.
  7. Конец сессии «жёсткий». В реальности сессия должна «закрываться» спустя 30 минут после последнего события, а не в момент последнего. Это влияет на расчёт session_duration (если она нужна).

Эталонный ответ

Через LAG + флаг новой сессии (gap > 30 минут или смена даты) + кумулятивная сумма → session_num. Финальный GROUP BY (user_id, session_num) с MIN/MAX(ts). Это стандартный gap-and-island паттерн.

Хочешь увидеть разбор?

Зарегистрируйся бесплатно — откроется развёрнутое решение этой задачи и ещё 4 на выбор.

Зарегистрироваться и увидеть разбор
Уже есть аккаунт? Войти