Что мне дали на собеседовании
Эту задачу мне дали на собеседовании продуктового аналитика. Название компании не важно: такой формат — две таблицы, сгенерированные прямо в ноутбуке, и три коротких вопроса — встречается почти везде. Выглядит как разминка на 10 минут. На деле в каждом вопросе спрятана ловушка, и проверяют именно их, а не знание методов.
Данные: поисковые запросы пользователей и таблица сессий с платформой. Код ниже можно скопировать и запустить. Я добавил только random.seed(42), чтобы ваши числа совпали с моими.
import pandas as pd import numpy as np import random from datetime import datetime, timedelta random.seed(42) # моё добавление — для воспроизводимости n = 1000 search_data = pd.DataFrame({ 'timestamp': [datetime(2024, 1, 1) + timedelta(minutes=random.randint(1, 10080)) for _ in range(n)], 'session_id': [random.randint(1, 100) for _ in range(n)], 'query': [random.choice(['shoes', 'electronics', 'clothing', 'home decor']) for _ in range(n)], 'location': [random.choice(['New York', 'Los Angeles', 'Chicago', 'San Francisco']) for _ in range(n)], 'user_id': [random.randint(1, 50) for _ in range(n)] }) session_data = pd.DataFrame({ 'session_id': [random.randint(1, 100) for _ in range(n)], 'platform': [random.choice(['IOS', 'ANDROID', 'WEB']) for _ in range(n)], })
Вопросы:
1. Выведи на экран 10 самых популярных запросов.
2. Выведи на экран количество уникальных пользователей IOS по городам.
3. Посчитай долю уникальных пользователей с запросом 'shoes' по платформам.
Решение
search_data['query'].value_counts().head(10) # home decor 273 # clothing 258 # electronics 246 # shoes 223
Ловушка
Просили топ-10, а вернулось 4 строки: разных запросов в данных всего четыре. Код правильный, но молча сдать «топ-10» из четырёх — слабый ответ. Сильный — заметить и сказать вслух: «Уникальных запросов 4, поэтому топ-10 совпадает со всем списком. Если бы их было больше, head(10) отрезал бы лишнее».
Второй вопрос, который стоит задать: популярность — это число поисков или число людей, которые искали? Если один пользователь ищет «shoes» 30 раз подряд, по поискам запрос вырастет, по людям — нет. Для продукта чаще важны люди: search_data.groupby('query')['user_id'].nunique().
search_data['query'].nunique().
Как решают почти все
df = search_data.merge(session_data, on='session_id') df[df['platform'] == 'IOS'].groupby('location')['user_id'].nunique() # Chicago 49 # Los Angeles 50 # New York 50 # San Francisco 49
Выглядит правдоподобно. Но всего пользователей в данных 50 — получается, почти каждый из них сидит с iOS в каждом из четырёх городов. Это первый звоночек.
Ловушка: merge размножил строки
Посмотрите на session_data: 1000 строк, а сессий в ней всего 100. У каждой сессии в среднем 10 строк — и разные платформы: в моём запуске у 89 сессий из 100 встречаются все три платформы сразу. Таблица «сессия → платформа» на деле не справочник.
session_data['session_id'].is_unique # False len(search_data) # 1000 len(df) # 10010 — в 10 раз больше
После merge каждый поиск склеился со всеми строками своей сессии: из 1000 поисков стало 10 010 строк, и один и тот же поиск оказался одновременно на iOS, Android и в вебе. Отсюда «почти все пользователи на iOS».
Это та же ошибка, что в SQL называют размножением строк в JOIN. В нашей SQL-песочнице эталон одной задачи из-за неё месяцами отдавал 3 миллиона отзывов при 10 тысячах в таблице — и все проверки были зелёными, потому что сверяли ответ с самим собой.
Как надо
Сначала проверить ключ и спросить интервьюера, как быть с конфликтом платформ. Вариантов несколько: взять самую частую платформу сессии, первую по времени (если бы было время), или признать данные битыми. Я бы предложил самую частую и явно это проговорил:
# одна платформа на сессию — самая частая sess = (session_data.groupby('session_id')['platform'] .agg(lambda s: s.value_counts().index[0]) .reset_index()) # validate упадёт с ошибкой, если ключ справа снова не уникален df = search_data.merge(sess, on='session_id', how='left', validate='many_to_one') len(df) # 1000 — строк столько же, сколько поисков df[df['platform'] == 'IOS'].groupby('location')['user_id'].nunique() # Chicago 36 # Los Angeles 38 # New York 34 # San Francisco 38
merge сравнивайте число строк до и после. Выросло, а вы этого не ждали — ключ не уникален. Параметр validate='many_to_one' делает эту проверку за вас.
Ловушка: доля от чего?
В формулировке нет знаменателя, и её можно понять двумя способами:
• Прочтение А. Какая доля пользователей платформы искала 'shoes'. Знаменатель — все пользователи этой платформы.
• Прочтение Б. Как пользователи, искавшие 'shoes', распределены по платформам. Знаменатель — все, кто искал 'shoes'.
Это разные вопросы для продукта. А отвечает «насколько обувь популярна у iOS-аудитории», Б — «откуда приходят те, кто ищет обувь». Правильный ход — спросить. Если интервьюер скажет «на ваше усмотрение», выберите одно и назовите знаменатель вслух.
Решение (на данных из вопроса 2, где одна платформа на сессию)
shoes = df[df['query'] == 'shoes'] # А: доля пользователей платформы, искавших shoes shoes.groupby('platform')['user_id'].nunique() / df.groupby('platform')['user_id'].nunique() # ANDROID 0.840 IOS 0.714 WEB 0.840 # Б: распределение искавших shoes по платформам shoes.groupby('platform')['user_id'].nunique() / shoes['user_id'].nunique() # ANDROID 0.84 IOS 0.70 WEB 0.84 — в сумме 2.38, а не 1
Во втором прочтении доли в сумме дают 238%. Это не ошибка: один человек пользуется несколькими платформами и попадает в каждую. Если от вас ждут распределения, которое складывается в 100%, нужно сначала закрепить пользователя за одной платформой — и снова спросить, по какому правилу.
Вторая ловушка: строки вместо людей
# так считать нельзя — это доля поисков, а не пользователей shoes.groupby('platform').size() / df.groupby('platform').size() # ANDROID 0.262 IOS 0.217 WEB 0.200
Разница в разы: 26% поисков против 84% пользователей. В вопросе прямо сказано «уникальных пользователей», значит нужен nunique по user_id, а не size или count.
8 задач, которые встречаются чаще всего
Ниже — форматы, из которых обычно собирают pandas-секцию. Задачи 1–6 решены на тех же search_data и df, что выше. Для воронки и anti-join нужна таблица событий — её код в задаче 7.
search_data.groupby('query').agg( searches=('user_id', 'size'), users=('user_id', 'nunique'), sessions=('session_id', 'nunique'), ).sort_values('searches', ascending=False) # searches users sessions # home decor 273 50 96 # clothing 258 50 95
Где режут
Путают size и count: size считает все строки, count — только непустые значения в колонке. На данных с пропусками результаты разойдутся. Именованная агрегация имя=(колонка, функция) сразу даёт читаемые названия колонок — интервьюеры это замечают.
search_data.pivot_table(index='location', columns='query', values='user_id', aggfunc='nunique', fill_value=0) # query clothing electronics home decor shoes # Chicago 40 34 37 36
Где режут
Без aggfunc pandas посчитает среднее по user_id — бессмысленное число, которое выглядит как метрика. Ещё одна ловушка: сумма по строке не равна числу уникальных пользователей города, потому что один человек ищет несколько категорий.
dau = search_data.groupby(search_data['timestamp'].dt.date)['user_id'].nunique() # 2024-01-01 49, 2024-01-02 46, ... 2024-01-07 47 # то же через resample — колонка должна быть datetime search_data.set_index('timestamp').resample('D')['user_id'].nunique() dau.sum() # 325 — НЕ пользователи за неделю search_data['user_id'].nunique() # 50 — а вот это они
Где режут
Складывают дневные DAU и называют результат недельной аудиторией: 325 вместо 50. Уникальные пользователи не складываются — их надо пересчитывать на нужном периоде. Это тот же подвох, что с rolling DAU в SQL-задачах.
s = search_data.sort_values(['user_id', 'timestamp']).copy() gap = s.groupby('user_id')['timestamp'].diff() s['new_session'] = gap.isna() | (gap > pd.Timedelta(minutes=30)) s['session_num'] = s.groupby('user_id')['new_session'].cumsum()
Где режут
Забывают отсортировать по времени — diff считает разницу между соседними строками в текущем порядке. Или делают diff без groupby, и первая строка нового пользователя получает разницу с последней строкой предыдущего. Первый поиск пользователя (NaN в gap) — всегда новая сессия, это условие легко потерять.
cnt = search_data.groupby(['location', 'query']).size().reset_index(name='n') cnt['rnk'] = cnt.groupby('location')['n'].rank(method='dense', ascending=False) cnt[cnt['rnk'] == 1] # Chicago clothing 69 # Los Angeles clothing 68 # New York home decor 84 # San Francisco clothing 62 # San Francisco home decor 62 ← ничья
Где режут
В Сан-Франциско ничья, и «топ-1» — это две строки. Решение через sort_values().groupby().head(1) молча выкинет одну из них. Спросите, что делать с ничьими: method='dense' оставит обе, method='first' — ровно одну. Это прямой аналог ROW_NUMBER против DENSE_RANK в SQL.
d = search_data.assign(day=search_data['timestamp'].dt.normalize()) d = d.join(d.groupby('user_id')['day'].min().rename('cohort'), on='user_id') d['day_n'] = (d['day'] - d['cohort']).dt.days ret = d.pivot_table(index='cohort', columns='day_n', values='user_id', aggfunc='nunique') ret.div(ret[0], axis=0) # делим на размер когорты — колонку дня 0
Где режут
Забывают поделить на размер когорты и показывают абсолютные числа. Или берут когорту по дню текущего события, а не первого. Проговорите, что таблица треугольная: у свежих когорт меньше прожитых дней. На этих данных почти все пришли в первый же день, поэтому треугольника не видно — на реальных он будет.
view, cart, purchase. Посчитай воронку: сколько пользователей дошло до каждого шага и конверсию между шагами.»random.seed(7) events = pd.DataFrame({ 'user_id': [random.randint(1, 300) for _ in range(2000)], 'event': [random.choices(['view', 'cart', 'purchase'], [70, 20, 10])[0] for _ in range(2000)], }) steps = ['view', 'cart', 'purchase'] reached, users = [], None for step in steps: step_users = set(events.loc[events['event'] == step, 'user_id']) users = step_users if users is None else users & step_users # прошёл все предыдущие шаги reached.append(len(users)) funnel = pd.DataFrame({'step': steps, 'users': reached}) funnel['conv_from_prev'] = funnel['users'] / funnel['users'].shift() # step users conv_from_prev # 0 view 299 NaN # 1 cart 223 0.746 # 2 purchase 100 0.448
Где режут
Считают каждый шаг независимо: «купивших» тогда 131, а не 100. Разница — 31 человек, у которых есть покупка, но нет корзины или просмотра. В реальных логах так бывает (покупка из письма, потерянное событие). Спросите, строгая воронка нужна или нет, и учитывается ли порядок по времени. Здесь времени нет, поэтому порядок проверить нельзя — это тоже стоит сказать вслух.
cart = events.loc[events['event'] == 'cart', ['user_id']].drop_duplicates() buy = events.loc[events['event'] == 'purchase', ['user_id']].drop_duplicates() # способ 1: merge с indicator — аналог LEFT JOIN ... WHERE b.id IS NULL x = cart.merge(buy, on='user_id', how='left', indicator=True) lost = x.loc[x['_merge'] == 'left_only', 'user_id'] # способ 2: короче lost = cart.loc[~cart['user_id'].isin(buy['user_id']), 'user_id'] len(cart), len(lost) # 224, 123
Где режут
Делают обычный merge (inner) и получают ровно тех, кто купил — противоположность нужного. Или забывают drop_duplicates, и один пользователь с пятью добавлениями в корзину появляется в ответе пять раз. indicator=True полезен и как отладка: показывает, сколько строк нашли пару, а сколько нет.
Что проверить до того, как писать код
Почти все ловушки выше ловятся пятью проверками, которые занимают минуту. Проговаривайте их вслух — интервьюер оценивает именно это.
| Проверка | Как | Что ловит |
|---|---|---|
| Уникален ли ключ | df['key'].is_unique, validate= в merge | Размножение строк (вопрос 2) |
| Сколько уникальных значений | nunique(), value_counts() | «Топ-10» из четырёх (вопрос 1) |
| Что в знаменателе | Назвать вслух до расчёта | Двусмысленную долю (вопрос 3) |
| Строки или люди | nunique по user_id против size | Доли поисков вместо долей пользователей |
| Типы и пропуски | df.dtypes, df.isna().sum() | Даты строкой, count против size |
Если SQL вы знаете лучше pandas, держите перевод основных операций:
| SQL | pandas |
|---|---|
| GROUP BY + COUNT(DISTINCT) | groupby(...)[col].nunique() |
| JOIN | merge(..., how=...) |
| LEFT JOIN ... WHERE b.id IS NULL | merge(..., indicator=True) или ~isin() |
| LAG / разность с предыдущей строкой | groupby(...)[col].diff() или shift() |
| DENSE_RANK() OVER (PARTITION BY ...) | groupby(...)[col].rank(method='dense') |
| SUM() OVER (... ORDER BY ...) | groupby(...)[col].cumsum() |
Частые вопросы
Какие задачи по pandas дают на собеседовании аналитика?
groupby и agg, сводные таблицы, DAU, объединение таблиц через merge, ранжирование внутри группы, когорты, воронки и поиск тех, кто не дошёл до шага. Обычно 2–4 задачи за 30–45 минут на одном наборе данных — как в задаче выше.Чем pandas-секция отличается от SQL-секции?
merge молча размножает строки, а value_counts и size считают строки, а не пользователей. Поэтому на pandas-секции особенно ценят проверки данных до кода.Нужно ли помнить синтаксис pandas наизусть?
nunique вместо count, явный знаменатель у доли, проверку строк после соединения.Как проверить, что merge не размножил строки?
is_unique или передать в merge параметр validate='many_to_one' — тогда pandas сам выбросит ошибку, если ключ не уникален.Связанные материалы
Главное про pandas на собеседовании
Задачу из начала статьи можно решить за пять минут — и провалить. Интервьюеру важен не код, а три вещи: заметили ли вы, что запросов всего четыре; проверили ли ключ перед merge; спросили ли, от чего считать долю.
Запомните три привычки: (1) после каждого merge сравнивайте число строк; (2) «пользователи» — это nunique, а не size; (3) у любой доли называйте знаменатель вслух. С ними большинство ловушек pandas-секции перестаёт работать.