new-lvl.pro · Статьи · pandas · Собес
Статья // 14 мин чтения

Задачи по pandas
на собеседовании
аналитика

Реальная задача с моего собеседования — три вопроса и три ловушки, на которых режут кандидатов. Потом 8 типовых форматов pandas-секции: groupby, pivot_table, DAU, сессии, ранжирование, когорты, воронка, anti-join. Каждое решение запущено на данных, числа в статье — настоящие.

Что мне дали на собеседовании

Эту задачу мне дали на собеседовании продуктового аналитика. Название компании не важно: такой формат — две таблицы, сгенерированные прямо в ноутбуке, и три коротких вопроса — встречается почти везде. Выглядит как разминка на 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' по платформам.

Вопрос 01
10 самых популярных запросов
value_counts

Решение

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().
Вопрос 02
Уникальные пользователи IOS по городам
merge · 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' делает эту проверку за вас.
Вопрос 03
Доля пользователей с запросом 'shoes' по платформам
знаменатель · nunique

Ловушка: доля от чего?

В формулировке нет знаменателя, и её можно понять двумя способами:

• Прочтение А. Какая доля пользователей платформы искала '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.

// Честная оговорка
На этих синтетических данных все 50 пользователей искали всё подряд, поэтому прочтения А и Б дали почти одинаковые числа. На реальных данных разница между ними обычно огромная.
⌬
Порешать такие задачи в браузере
В pandas-тренажёре 50 задач на данных маркетплейса с автопроверкой — без установки Python. А диагностика покажет, какие темы подтянуть перед собесом.
Открыть тренажёр

Пройти диагностику уровня →

8 задач, которые встречаются чаще всего

Ниже — форматы, из которых обычно собирают pandas-секцию. Задачи 1–6 решены на тех же search_data и df, что выше. Для воронки и anti-join нужна таблица событий — её код в задаче 7.

Задача 01
Несколько метрик по группе за один проход
groupby · agg
«По каждому запросу посчитай число поисков, уникальных пользователей и уникальных сессий.»
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 — только непустые значения в колонке. На данных с пропусками результаты разойдутся. Именованная агрегация имя=(колонка, функция) сразу даёт читаемые названия колонок — интервьюеры это замечают.

Задача 02
Сводная таблица: город × запрос
pivot_table
«Сделай таблицу: строки — города, колонки — запросы, в ячейках — уникальные пользователи.»
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 — бессмысленное число, которое выглядит как метрика. Ещё одна ловушка: сумма по строке не равна числу уникальных пользователей города, потому что один человек ищет несколько категорий.

Задача 03
DAU и «пользователи за неделю»
dt.date · resample
«Посчитай DAU по дням. А сколько уникальных пользователей было за всю неделю?»
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-задачах.

Задача 04
Сессии по паузе больше 30 минут
groupby.diff · cumsum
«Если между двумя поисками пользователя прошло больше 30 минут — это новая сессия. Пронумеруй сессии.»
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) — всегда новая сессия, это условие легко потерять.

Задача 05
Самый популярный запрос в каждом городе
rank · ничьи
«Найди топ-1 запрос по числу поисков в каждом городе.»
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.

Задача 06
Когорты и retention
join · pivot_table
«Раздели пользователей на когорты по дню первого поиска и посчитай, какая доля когорты возвращалась на 1-й, 2-й, 3-й день.»
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

Где режут

Забывают поделить на размер когорты и показывают абсолютные числа. Или берут когорту по дню текущего события, а не первого. Проговорите, что таблица треугольная: у свежих когорт меньше прожитых дней. На этих данных почти все пришли в первый же день, поэтому треугольника не видно — на реальных он будет.

Задача 07
Воронка: просмотр → корзина → покупка
множества · порядок шагов
«Есть события 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 человек, у которых есть покупка, но нет корзины или просмотра. В реальных логах так бывает (покупка из письма, потерянное событие). Спросите, строгая воронка нужна или нет, и учитывается ли порядок по времени. Здесь времени нет, поэтому порядок проверить нельзя — это тоже стоит сказать вслух.

Задача 08
Кто положил в корзину, но не купил
anti-join · indicator
«Найди пользователей, которые добавляли товар в корзину, но ни разу не купили.»
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, держите перевод основных операций:

SQLpandas
GROUP BY + COUNT(DISTINCT)groupby(...)[col].nunique()
JOINmerge(..., how=...)
LEFT JOIN ... WHERE b.id IS NULLmerge(..., 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-секции?
Логика та же: группировки, соединения, окна. Но в pandas проще ошибиться незаметно: merge молча размножает строки, а value_counts и size считают строки, а не пользователей. Поэтому на pandas-секции особенно ценят проверки данных до кода.
Нужно ли помнить синтаксис pandas наизусть?
Нет. Обычно разрешают подсмотреть документацию или назвать метод словами: «тут нужен merge с проверкой ключа». Важнее выбрать правильную операцию — nunique вместо count, явный знаменатель у доли, проверку строк после соединения.
Как проверить, что merge не размножил строки?
Сравнить число строк до и после, проверить уникальность ключа в правой таблице через is_unique или передать в merge параметр validate='many_to_one' — тогда pandas сам выбросит ошибку, если ключ не уникален.

Связанные материалы

Главное про pandas на собеседовании

Задачу из начала статьи можно решить за пять минут — и провалить. Интервьюеру важен не код, а три вещи: заметили ли вы, что запросов всего четыре; проверили ли ключ перед merge; спросили ли, от чего считать долю.

Запомните три привычки: (1) после каждого merge сравнивайте число строк; (2) «пользователи» — это nunique, а не size; (3) у любой доли называйте знаменатель вслух. С ними большинство ловушек pandas-секции перестаёт работать.

АТ
Андрей Тарасенко
// Продуктовый аналитик · Авито · Ментор

Когда я решал эту задачу на собеседовании, больше всего времени ушло не на код, а на вопросы к интервьюеру. Сейчас я понимаю, что именно за них и ставили оценку.

Написать в Telegram
// ЗАКРЕПИ НА ПРАКТИКЕ

Прочитали разбор? Теперь решите сами

В pandas-тренажёре — данные маркетплейса и задачи по мотивам собеседований. Решение проверяется сразу, Python ставить не нужно.

▶ Открыть pandas-тренажёр ★ 50 задач с условиями
Все материалы: База знаний · Telegram