Полная база вопросов с собеседований для Data-аналитиков и Data Scientist’ов.
254 из 254 вопросов
WB
SQLЛегкийвопрос
Опиши правильный порядок, по которому движок БД обрабатывает SQL-запрос.
Опиши правильный порядок, по которому движок БД обрабатывает SQL-запрос.
Порядок выполнения SQL-запроса движком базы данных:
FROM — определяются таблицы-источники, выполняются JOIN
WHERE — фильтрация строк по условию
GROUP BY — группировка строк
HAVING — фильтрация групп
SELECT — выбор столбцов, вычисление выражений
DISTINCT — удаление дубликатов
ORDER BY — сортировка результата
LIMIT / OFFSET — ограничение выборки
Важно: оконные функции (OVER()) вычисляются после WHERE, GROUP BY, HAVING, но до DISTINCT и ORDER BY.
WB
SQLСреднийзадача
Посчитать скользящее среднее по доле сервиса 'rec' в общей выручке всех серви…
Посчитать скользящее среднее по доле сервиса 'rec' в общей выручке всех сервисов в феврале 2023 года, в динамике по дням.
WITH daily AS (
SELECTdate,
SUM(CASEWHEN service ='rec'THEN revenue ELSE0END) AS rec_revenue,
SUM(revenue) AS total_revenue
FROM orders
WHEREdateBETWEEN'2023-02-01'AND'2023-02-28'GROUPBYdate
)
SELECTdate,
rec_revenue *1.0/NULLIF(total_revenue, 0) AS rec_share,
AVG(rec_revenue *1.0/NULLIF(total_revenue, 0))
OVER (ORDERBYdateROWSBETWEEN2 PRECEDING ANDCURRENTROW) AS moving_avg_share
FROM daily
ORDERBYdate;
Скользящее среднее рассчитывается по окну в 3 дня (текущий + 2 предыдущих). Сначала считаем дневную долю сервиса rec, затем применяем оконную функцию AVG с ROWS BETWEEN.
WB
PythonСреднийзадача
Необходимо произвести фильтрацию вложенного массива и извлечь элементы 1-го и…
Необходимо произвести фильтрацию вложенного массива и извлечь элементы 1-го индекса.
data = [[1, 'a', True], [2, 'b', False], [3, 'c', True]]
# Способ 1: list comprehension
result = [item[1] for item in data]
# ['a', 'b', 'c']# Способ 2: map + operator.itemgetterfrom operator import itemgetter
result = list(map(itemgetter(1), data))
Оба подхода извлекают элемент с индексом 1 из каждого вложенного списка. List comprehension — более читаемый и Pythonic способ.
WB
PythonСреднийзадача
Напиши метод, который в качестве параметра принимает строку и возвращает слов…
Напиши метод, который в качестве параметра принимает строку и возвращает словарь с количеством символов. Пример: вход 'abcabb' → выход: {'a': 2, 'b': 3, 'c': 1}.
# Способ 1: collections.Counterfrom collections import Counter
defcount_chars(s: str) -> dict:
returndict(Counter(s))
# Способ 2: вручнуюdefcount_chars(s: str) -> dict:
result = {}
for char in s:
result[char] = result.get(char, 0) + 1return result
print(count_chars('abcabb')) # {'a': 2, 'b': 3, 'c': 1}
Counter — самый лаконичный способ. Ручной вариант показывает понимание работы с dict.get() и итерацией по строке.
WB
PythonСреднийзадача
Напиши функцию, которая принимает на вход словарь вида d = {"Wildberries": 20…
Напиши функцию, которая принимает на вход словарь вида d = {"Wildberries": 20, "Ozon": 13, ...} (key — название маркетплейса, value — вес) и возвращает случайно сгенерированное название маркетплейса. Вероятность «выпадения» прямо пропорциональна весу. С какой сложностью работает алгоритм? Можно ли ускорить, если функцию нужно вызвать 10^6 раз?
import random
# Базовый вариант O(n) на каждый вызовdefweighted_random(d: dict) -> str:
names = list(d.keys())
weights = list(d.values())
return random.choices(names, weights=weights, k=1)[0]
# Оптимизированный вариант для 10^6 вызовов:# Предварительно строим кумулятивные веса O(n),# затем каждый вызов — бинарный поиск O(log n)import bisect
defmake_sampler(d: dict):
names = list(d.keys())
cum_weights = []
total = 0for w in d.values():
total += w
cum_weights.append(total)
defsample():
r = random.uniform(0, total)
idx = bisect.bisect_left(cum_weights, r)
return names[idx]
return sample
sampler = make_sampler(d)
for _ inrange(10**6):
sampler() # O(log n) каждый вызов
Базовый random.choices работает за O(n). С предвычислением кумулятивных весов + bisect получаем O(log n) на вызов и O(n) на подготовку.
WB
КейсыСложныйкейс
По итогам первого квартала продакт пришёл с ad-hoc задачей: «Нужно обосновани…
По итогам первого квартала продакт пришёл с ad-hoc задачей: «Нужно обоснование на данных, почему средний чек в сервисе rec ниже, чем у других сервисов?» Есть выгрузки orders (заказы) и events (просмотры и добавления в корзину). Как подойти к анализу?
Фреймворк анализа:
Декомпозиция среднего чека:Средний чек = GMV / Кол-во заказов. Что именно ниже — GMV на заказ или количество товаров в заказе?
Анализ ассортимента: Какие товары рекомендуются в rec? Средняя цена товара в рекомендациях vs. в других сервисах.
Поведение пользователей (events):
Конверсия: просмотр → добавление в корзину → покупка
Среднее количество просмотренных товаров перед покупкой
Доля пользователей, которые добавляют в корзину, но не покупают
Сегментация: Разбить по категориям товаров, по сегментам пользователей (новые/старые, частота покупок)
Гипотезы:
Рекомендации показывают дешёвые товары
Пользователи rec — другой сегмент (новички, casual buyers)
Рекомендации неточные → пользователь покупает меньше товаров из выдачи
WB
SQLСреднийзадача
Дана таблица сотрудников (employee_id, name, skill, salary). Бюджет на зарпла…
Дана таблица сотрудников (employee_id, name, skill, salary). Бюджет на зарплаты 500 тыс. руб. Напишите запрос, который выводит имена операторов для группы, если в приоритете операторы с наибольшим скилом.
WITH data AS (
SELECT
name,
SUM(salary) OVER (
ORDERBY skill DESC, salary, employee_id
) AS cum_sum
FROM employees
)
SELECT name
FROM data
WHERE cum_sum <=500;
Используем оконную функцию SUM() OVER() для нарастающего итога зарплат, отсортированных по убыванию скила. Берём всех сотрудников, пока кумулятивная сумма не превысит бюджет. Сортировка по salary, employee_id при одинаковом скиле обеспечивает детерминированный результат.
WB
SQLСреднийзадача
Посчитайте медианную зарплату выбранных сотрудников (из задачи с бюджетом 500…
Посчитайте медианную зарплату выбранных сотрудников (из задачи с бюджетом 500 тыс.), не используя встроенную функцию медианы.
WITH data AS (
SELECT
name, employee_id, salary,
SUM(salary) OVER (ORDERBY skill DESC, salary, employee_id) AS cum_sum
FROM employees
),
budget AS (
SELECT name, employee_id, salary
FROM data
WHERE cum_sum <=500
),
emp_rank AS (
SELECT
salary,
ROW_NUMBER() OVER (ORDERBY salary) AS rn,
COUNT(*) OVER () AS cnt
FROM budget
)
SELECTAVG(salary) AS median_salary
FROM emp_rank
WHERE-- нечётное: берём средний элемент
(cnt %2=1AND rn = (cnt +1) /2)
OR-- чётное: среднее двух центральных
(cnt %2=0AND rn IN (cnt /2, cnt /2+1));
Идея: нумеруем строки по зарплате, считаем общее количество. Для нечётного количества берём элемент посередине, для чётного — среднее двух центральных.
WB
PythonСреднийзадача
Дана строка (возможно пустая) из букв A-Z, например AAAABBBCCXYZDDDDEEEFFFAAA…
Дана строка (возможно пустая) из букв A-Z, например AAAABBBCCXYZDDDDEEEFFFAAAAAABBBB…C. Нужно написать функцию RLE (Run-Length Encoding), которая вернёт строку вида A4B3C2XYZD4E3F3A6B28. Если символ один раз — без числа, если больше — с числом повторений. Выдать ошибку при недопустимой строке.
defrle(s: str) -> str:
ifnot s:
return''ifnot s.isalpha() ornot s.isupper():
raise ValueError('Строка должна содержать только A-Z')
result = []
count = 1for i inrange(1, len(s)):
if s[i] == s[i - 1]:
count += 1else:
result.append(s[i - 1])
if count > 1:
result.append(str(count))
count = 1# Последняя группа
result.append(s[-1])
if count > 1:
result.append(str(count))
return''.join(result)
print(rle('AAAABBBCCXYZ')) # A4B3C2XYZ
Алгоритм за O(n): проходим по строке, считаем подряд идущие одинаковые символы. При смене символа записываем символ и счётчик (если > 1).
WB
КейсыСложныйкейс
Открыли новую поддержку в Грузии. ПВЗ работают 2 месяца, заказы растут. Подде…
Открыли новую поддержку в Грузии. ПВЗ работают 2 месяца, заказы растут. Поддержка запущена месяц назад. Напиши список метрик, которые отражали бы эффективность работы поддержки.
Метрики эффективности поддержки:
Объём:
Общее количество обращений
Доля обращений от всех заказов
Доля обращений от активных пользователей в Грузии
Качество:
NPS / среднее количество звёзд в отзывах
SLA — доля обращений, закрытых в рамках целевого времени
Доля положительных отзывов
Влияние на бизнес:
Доля и количество купивших пользователей после общения с поддержкой
Retention пользователей, обратившихся в поддержку vs. не обращавшихся
Операционные:
Среднее количество сообщений внутри одного обращения
Среднее время ответа (first response time)
Доля вернувшихся с повторным обращением в течение дня
Фильтры для анализа: тема обращения, канал (чат/звонок), тип проблемы.
Магнит
A/B тестыСреднийзадача
Ты аналитик в сервисе доставки продуктов. Команда тестирует блок с историей з…
Ты аналитик в сервисе доставки продуктов. Команда тестирует блок с историей заказов на главной странице приложения (группа B видит блок, группа A — нет). Даны таблицы df_ab_groups (user_id, ab_group) и df_financial (date, user_id, revenue). Задача 1: найти количество пользователей, попавших более чем в одну группу эксперимента.
import pandas as pd
# Находим пользователей в нескольких группах
user_groups = df_ab_groups.groupby('user_id')['ab_group'].nunique().reset_index()
user_groups.columns = ['user_id', 'group_count']
multi_group = user_groups[user_groups['group_count'] > 1]
print(f'Пользователей в нескольких группах: {len(multi_group)}')
Если таких пользователей много — это признак SRM (Sample Ratio Mismatch). Их нужно исключить из анализа или разобраться в причинах перетекания между группами.
Магнит
A/B тестыСреднийзадача
Посчитай суммарную выручку на клиента за период эксперимента и определи его г…
Посчитай суммарную выручку на клиента за период эксперимента и определи его группу. Данные: df_ab_groups (user_id, ab_group), df_financial (date, user_id, revenue).
# Суммарная выручка на клиента
user_revenue = df_financial.groupby('user_id')['revenue'].sum().reset_index()
user_revenue.columns = ['user_id', 'total_revenue']
# Присоединяем группу эксперимента
result = pd.merge(user_revenue, df_ab_groups, on='user_id', how='inner')
# Средняя выручка по группамprint(result.groupby('ab_group')['total_revenue'].mean())
Важно: используем inner join, чтобы исключить пользователей без определённой группы. Если пользователь был в нескольких группах — предварительно исключаем таких (см. задачу 1).
Магнит
A/B тестыСреднийзадача
Напиши функцию, которая возвращает бокс-плот по выручке в разрезе A/B групп и…
Напиши функцию, которая возвращает бокс-плот по выручке в разрезе A/B групп и выводит итоги эксперимента: Uplift = X, p_value = Y, аудитория = N. Добавь параметр управления выбросами (перцентиль для порогового значения).
import matplotlib.pyplot as plt
from scipy import stats
import numpy as np
defab_results(df, remove_outliers=False, percentile=99):
if remove_outliers:
threshold = np.percentile(df['total_revenue'], percentile)
df = df[df['total_revenue'] <= threshold]
a = df[df['ab_group'] == 'a']['total_revenue']
b = df[df['ab_group'] == 'b']['total_revenue']
# Бокс-плот
fig, ax = plt.subplots()
df.boxplot(column='total_revenue', by='ab_group', ax=ax)
ax.set_title('Выручка по группам')
plt.suptitle('')
plt.show()
# Статистика
uplift = (b.mean() - a.mean()) / a.mean() * 100
_, p_value = stats.ttest_ind(a, b)
n = len(df)
print(f'Uplift = {uplift:.2f}%')
print(f'p_value = {p_value:.4f}')
print(f'Аудитория эксперимента = {n}')
Функция принимает параметры remove_outliers и percentile для управления выбросами. T-test проверяет разницу средних между группами.
Магнит
SQLСреднийзадача
Таблица watch_content (user_id, video_id, date). Вывести список пользователей…
Таблица watch_content (user_id, video_id, date). Вывести список пользователей, смотревших video_id 1 и 3 (оба видео), но не смотревших video_id 2.
Задача на фильтрацию по набору признаков пользователя, а не отдельных строк. Ключевая сложность: условия «смотрел 1 и 3» и «не смотрел 2» относятся к разным строкам одного пользователя, поэтому обычным WHERE их не выразить.
Читается по строчкам: «есть хотя бы один просмотр видео 1», «есть хотя бы один просмотр видео 3», «нет ни одного просмотра видео 2». MAX здесь работает как логическое ИЛИ по строкам группы, поэтому повторные просмотры одного видео результат не искажают.
Главная ловушка задачи. Напрашивается сначала сузить выборку через WHERE, а условие про видео 2 дописать в HAVING:
-- НЕВЕРНОSELECT user_id
FROM watch_content
WHERE video_id IN (1, 3)
GROUPBY user_id
HAVINGCOUNT(DISTINCT video_id) =2ANDSUM(CASEWHEN video_id =2THEN1ELSE0END) =0;
WHERE video_id IN (1, 3) выбрасывает строки с видео 2 до группировки, поэтому внутри группы информации о просмотре видео 2 уже нет. Слагаемое SUM(CASE WHEN video_id = 2 ...) тождественно равно нулю для всех групп — условие всегда истинно и не фильтрует ничего, лишь создавая иллюзию проверки.
Проверка на данных:
user_id
что смотрел
должен попасть
1
1, 3
да
2
1, 3, 2
нет
3
1
нет
4
2, 3
нет
Неверный запрос возвращает 1 и 2 — пользователь 2 просачивается, хотя видео 2 он смотрел. Корректный запрос возвращает только 1.
Другие рабочие варианты:
Если хочется сохранить WHERE, в выборку нужно включить и видео 2, а разделение перенести в агрегаты:
SELECT user_id
FROM watch_content
WHERE video_id IN (1, 2, 3)
GROUPBY user_id
HAVINGCOUNT(DISTINCTCASEWHEN video_id IN (1, 3) THEN video_id END) =2ANDCOUNT(CASEWHEN video_id =2THEN1END) =0;
В PostgreSQL то же самое компактнее через FILTER:
SELECT user_id
FROM watch_content
GROUPBY user_id
HAVINGCOUNT(DISTINCT video_id) FILTER (WHERE video_id IN (1, 3)) =2ANDCOUNT(*) FILTER (WHERE video_id =2) =0;
Вариант через EXISTS / NOT EXISTS — самый читаемый и обычно хорошо оптимизируется при наличии индекса по (user_id, video_id):
SELECTDISTINCT w.user_id
FROM watch_content w
WHEREEXISTS (SELECT1FROM watch_content x WHERE x.user_id = w.user_id AND x.video_id =1)
ANDEXISTS (SELECT1FROM watch_content x WHERE x.user_id = w.user_id AND x.video_id =3)
ANDNOTEXISTS (SELECT1FROM watch_content x WHERE x.user_id = w.user_id AND x.video_id =2);
Почему NOT EXISTS, а не NOT IN. Формулировка с user_id NOT IN (SELECT user_id FROM watch_content WHERE video_id = 2) даёт правильный ответ на чистых данных, но молча возвращает пустой результат, если подзапрос вернёт хотя бы один NULL: сравнение с NULL даёт UNKNOWN, и условие NOT IN не выполняется ни для кого. Проверено: достаточно одной строки с user_id IS NULL — и корректный до того запрос выдаёт ноль строк. NOT EXISTS к этому устойчив.
Что проверяет задача: понимание того, что WHERE фильтрует строки до агрегации и безвозвратно удаляет информацию, а HAVING работает уже с группами. Признак «пользователь чего-то не делал» невозможно проверить, если строки с этим действием отфильтрованы заранее.
Магнит
SQLСреднийзадача
Таблица raw.orders (user_id, transaction_datetime, item_id, order_id) и dicts…
Таблица raw.orders (user_id, transaction_datetime, item_id, order_id) и dicts.items (item_id, brand, name, price). Для каждого пользователя вывести наименование и цену самого дорогого товара в его первой транзакции.
WITH first_order AS (
SELECT
user_id,
MIN(transaction_datetime) AS first_dt
FROM raw.orders
GROUPBY user_id
),
first_items AS (
SELECT
o.user_id,
i.name,
i.price,
ROW_NUMBER() OVER (
PARTITIONBY o.user_id ORDERBY i.price DESC
) AS rn
FROM raw.orders o
JOIN first_order fo
ON o.user_id = fo.user_id
AND o.transaction_datetime = fo.first_dt
JOIN dicts.items i ON o.item_id = i.item_id
)
SELECT user_id, name, price
FROM first_items
WHERE rn =1;
Сначала находим дату первой транзакции пользователя, затем из товаров этой транзакции выбираем самый дорогой через ROW_NUMBER().
Магнит
SQLСреднийзадача
На основе таблицы raw.orders (user_id, transaction_datetime, item_id, order_i…
На основе таблицы raw.orders (user_id, transaction_datetime, item_id, order_id) вывести: 1) размер когорт по месяцам, 2) ретеншен 2-го месяца. Когорта — множество пользователей, объединённых месяцем первой покупки.
WITH cohorts AS (
SELECT
user_id,
DATE_TRUNC('month', MIN(transaction_datetime)) AS cohort_month
FROM raw.orders
GROUPBY user_id
),
activity AS (
SELECTDISTINCT
o.user_id,
c.cohort_month,
DATE_TRUNC('month', o.transaction_datetime) AS activity_month
FROM raw.orders o
JOIN cohorts c ON o.user_id = c.user_id
),
cohort_size AS (
SELECT cohort_month, COUNT(*) AS size
FROM cohorts
GROUPBY cohort_month
)
SELECT
a.cohort_month,
cs.size AS cohort_size,
COUNT(DISTINCTCASEWHEN DATEDIFF('month', a.cohort_month, a.activity_month) =2THEN a.user_id END
) AS retained_m2,
ROUND(
COUNT(DISTINCTCASEWHEN DATEDIFF('month', a.cohort_month, a.activity_month) =2THEN a.user_id END
) *100.0/ cs.size, 2
) AS retention_m2_pct
FROM activity a
JOIN cohort_size cs ON a.cohort_month = cs.cohort_month
GROUPBY a.cohort_month, cs.size
ORDERBY a.cohort_month;
Строим когорты по первой покупке, затем считаем уникальных пользователей, вернувшихся через 2 месяца.
Магнит
A/B тестыСложныйкейс
Магнит запускает категорию кэшбэка совместно с банком-партнёром. Группа A (10…
Магнит запускает категорию кэшбэка совместно с банком-партнёром. Провели A/B тест на двух группах одинакового размера: группа A кэшбэк не видит, группа B видит.
Результат: в группе A покупку совершили 1000 человек, в группе B — 1100 человек, при этом 300 из них воспользовались кэшбэком впервые. Тест прокрасился в зелёный — доля покупателей в B значимо выше.
Как оценить экономический эффект программы?
Тест показал, что эффект есть. Но «значимо выше» отвечает только на вопрос «работает ли», а решение о запуске принимается по деньгам.
Шаг 1. Инкрементальный эффект — это разница между группами, а не число воспользовавшихся.
При равных размерах групп прирост покупателей:
1100 − 1000 = 100 дополнительных покупателей
Именно эти 100 человек — весь результат программы. Число 300 (воспользовались кэшбэком) эффектом не является: большинство из них купили бы и без кэшбэка.
Шаг 2. Каннибализация — ключевая мысль всей задачи.
Из 300 получивших кэшбэк инкрементальных только 100:
100 человек (33%) пришли благодаря кэшбэку — это новая выручка;
200 человек (67%) купили бы и так — им мы просто подарили скидку, снизив маржу с продаж, которые и без того состоялись бы.
Отсюда главный принцип расчёта: выгода считается только от 100 инкрементальных покупателей, а затраты — по всем 300. Ошибка «300 воспользовались, значит программа привела 300 клиентов» завышает эффект втрое и типична для отчётов по промо-механикам.
Шаг 3. Экономика.
Инкрементальная выручка = 100 × средний чек
Инкрементальная маржа = 100 × средний чек × маржинальность
Затраты на кэшбэк = 300 × средний чек × ставка кэшбэка ← платим всем
Прочие затраты = интеграция с банком, комиссия партнёра, поддержка
Эффект = Инкрементальная маржа − Затраты на кэшбэк − Прочие затраты
CAC программы = все затраты / 100 привлечённых. Программа окупается, если LTV инкрементального покупателя > CAC, а не если «выручка выросла».
Числовой пример: при среднем чеке 2000 ₽, марже 20% и кэшбэке 5% инкрементальная маржа = 100 × 2000 × 0,2 = 40 000 ₽, а затраты на кэшбэк = 300 × 2000 × 0,05 = 30 000 ₽. Плюс постоянные затраты на интеграцию — и эффект легко уходит в минус, хотя тест «зелёный».
Шаг 4. Что уточнить перед расчётом.
Равны ли группы по размеру. Если нет — сравнивать абсолютные 1000 и 1100 нельзя, нужно считать разницу конверсий и умножать на базу: (CR_B − CR_A) × N_B.
Горизонт. Разовый всплеск или устойчивый эффект? Кэшбэк часто просто сдвигает покупку во времени: человек купил бы через неделю, но купил сейчас. Проверяется тем, сохраняется ли разрыв между группами через 2–3 периода.
LTV, а не разовая покупка. Если привлечённые кэшбэком возвращаются, эффект больше разовой маржи; если это охотники за скидками — они уйдут вместе с промо.
Защитные метрики: средний чек (не упал ли), доля возвратов, каннибализация других категорий и промо-механик.
Доверительный интервал эффекта. 100 покупателей — точечная оценка; для решения о раскатке нужен интервал, чтобы понимать худший сценарий окупаемости.
Вывод: считаем unit-экономику на инкрементальных покупателях, затраты — на всех получателях кэшбэка. Раскатываем, если LTV инкрементальных превышает CAC с запасом, покрывающим неопределённость оценки.
Вкусвилл
SQLСреднийзадача
Дана таблица visits (Department, FIO, Date, Status). Status может быть: на ра…
Дана таблица visits (Department, FIO, Date, Status). Status может быть: на работе / на больничном / в отпуске (оплачиваемом) / в отпуске (за свой счет). Нужно вывести интервалы непрерывного нахождения сотрудника в определённом статусе (DateFrom, DateTo, Status).
Это задача Gaps & Islands. Решение:
WITH ranked AS (
SELECT
Department, FIO, Date, Status,
ROW_NUMBER() OVER (PARTITIONBY FIO ORDERBYDate) AS rn_all,
ROW_NUMBER() OVER (PARTITIONBY FIO, Status ORDERBYDate) AS rn_status
FROM visits
),
islands AS (
SELECT
Department, FIO, Status,
MIN(Date) AS DateFrom,
MAX(Date) AS DateTo,
rn_all - rn_status AS grp
FROM ranked
GROUPBY Department, FIO, Status, rn_all - rn_status
)
SELECT Department, FIO, DateFrom, DateTo, Status
FROM islands
ORDERBY FIO, DateFrom;
Идея: Разница между двумя нумерациями (rn_all - rn_status) остаётся постоянной для последовательных строк с одинаковым статусом, но меняется при смене статуса. Это позволяет группировать «острова» непрерывного статуса.
Альтернативный простой вариант — через LEAD:
SELECT
FIO,
DateAS DateFrom,
LEAD(Date) OVER (PARTITIONBY FIO ORDERBYDate) AS DateTo,
Status
FROM visits;
Вкусвилл
PythonСреднийзадача
Написать функцию, которая будет выводить товары, не прошедшие минимальный пор…
Написать функцию, которая будет выводить товары, не прошедшие минимальный порог по продажам за месяц. На вход: минимальный порог выручки и массив продаж вида [[{"product_id": 1, "quantity": 2, "price": 100}], ...].
defthreshold_filter(bench: int, sales: list) -> list:
failed_products = []
for sale_group in sales:
for item in sale_group:
gmv = item['quantity'] * item['price']
if gmv < bench:
failed_products.append(item['product_id'])
return failed_products
# Пример
sales = [
[{"product_id": 1, "quantity": 2, "price": 100}],
[{"product_id": 2, "quantity": 3, "price": 100}],
[{"product_id": 3, "quantity": 1, "price": 50}]
]
print(threshold_filter(150, sales)) # [3]
Важно: вложенный массив — список списков, поэтому нужен двойной цикл. Распространённая ошибка — обращаться как i[0] вместо итерации по элементам.
Вкусвилл
СтатистикаСреднийзадача
Мы промоутируем товары наших брендов в поисковой выдаче. Опция 1 — показывать…
Мы промоутируем товары наших брендов в поисковой выдаче. Опция 1 — показывать один товар на каждые 25 выданных. Опция 2 — заменить товар с шансом 4%. Сколько показов мы сделаем на 100 товаров? Каков шанс показать только 1 товар на 100 выданных при второй стратегии?
Опция 1 (детерминированная):
100 / 25 = 4 показа на каждые 100 товаров
Опция 2 (вероятностная):
Математическое ожидание: 100 × 0.04 = 4 показа (в среднем)
Также можно записать: вероятность = (99/100) × 0.96⁹⁹ + (1/100) — но формально это не совсем корректно, правильнее через биномиальное распределение.
Вкусвилл
КейсыСложныйкейс
На корзине есть блок с товарами Зелёного Ценника (40% скидки на товары с подх…
На корзине есть блок с товарами Зелёного Ценника (40% скидки на товары с подходящим сроком годности). Планируем сделать фичу, подсвечивающую эти ценники. Какие риски оценивать и как с ними бороться?
Риски и контрмеры:
Снижение CR в переход на карточку товара — дизайн подсветки может отвлекать от основных товаров. Контрмера: A/B тест, следить за CR в карточку всех товаров.
Снижение среднего чека / гросс-прибыли — пользователи переключаются на скидочные товары. Контрмера: мониторить средний чек и маржинальность.
Минимальный чек заказа — если пользователи набирают корзину из дешёвых скидочных товаров, чек может не дотянуть до минимума.
Каннибализация основного ассортимента — пользователи перестают покупать товары без скидки.
Предложения по усилению эффекта:
Комбинировать скидочные товары с рекомендациями похожих товаров без скидки
Пуш-уведомления о попадании любимого товара в акцию
Геймификация и система лояльности
Вкусвилл
КейсыСложныйкейс
Мы внедрили баннер «рецепты» в поиске приложения. Пользователи кликают, но ср…
Мы внедрили баннер «рецепты» в поиске приложения. Пользователи кликают, но средний чек и выручка не растут. Какие рекомендации?
Диагностика:
Проверить конверсию воронки: клик на баннер → просмотр рецепта → добавление ингредиентов в корзину → покупка
Где обрыв? Пользователи смотрят рецепт, но не добавляют товары?
Возможные причины:
Нет кнопки «Добавить все ингредиенты в корзину» — слишком много шагов
Ингредиентов нет в наличии
Рецепты нерелевантны текущему поиску
Каннибализация: пользователь уходит в рецепты вместо покупки того, что искал
Рекомендации:
Добавить кнопку «Купить все ингредиенты» на странице рецепта
Показывать рецепты на основе того, что уже в корзине (дополнительные товары)
Персонализировать рецепты под историю покупок
Замерить отдельно: покупатели рецептов покупают больше товаров? Если да — проблема в цене/марже
Вкусвилл
КейсыСложныйкейс
Разработали новую логику построения маршрута в 2гис. Как понять, что она рабо…
Разработали новую логику построения маршрута в 2гис. Как понять, что она работает лучше?
Подход: A/B тест
Метрики:
Целевые: время в пути, длина маршрута, доля пользователей, следовавших маршруту до конца
Защитные: количество перестроений маршрута, удовлетворённость пользователей (рейтинг), crash rate
Информационные: среднее количество манёвров, доля маршрутов через платные дороги
Дизайн:
Сплит по пользователям (не по сессиям — избежать перетекания)
Учесть географию (разные регионы — разные карты)
Достаточная длительность для разных сценариев (будни/выходные)
Анализ:
Сравнить среднее время в пути (t-test)
Контролировать SRM
Сегментация: город vs. межгород, утро/вечер, короткие/длинные маршруты
Вкусвилл
КейсыСложныйкейс
SaaS-продукт по подписке. Бесплатный период — 1 месяц. Пользователи жалуются,…
SaaS-продукт по подписке. Бесплатный период — 1 месяц. Пользователи жалуются, что не успевают всё настроить за месяц. Стоит ли продлевать бесплатный период до 3 месяцев?
Анализ:
Текущие данные: какой % пользователей конвертируется в платных за 1 месяц? Какой % завершает настройку?
Сегментация: кто жалуется? Если это крупные клиенты с долгим onboarding — возможно стоит. Если мелкие — это может быть предлог не платить.
Риски продления до 3 месяцев:
Снижение выручки: 2 дополнительных месяца бесплатно
Пользователи могут пользоваться бесплатно и уходить
Размытие urgency — нет мотивации настраивать быстро
Альтернативы:
Улучшить onboarding (мастера настройки, шаблоны)
Персональный менеджер для крупных клиентов
Гибкий trial: продлить на 2 недели по запросу
Ограниченный функционал в free, полный — в trial (freemium)
Как проверить: A/B тест с разной длительностью trial. Метрика: конверсия в платную подписку и LTV за 6 месяцев.
Вкусвилл
A/B тестыСложныйкейс
A/B тест серый — увеличилась только одна прокси-метрика (не основная). Какие …
A/B тест серый — увеличилась только одна прокси-метрика (не основная). Какие будут рекомендации?
Рекомендации:
Проверить достоверность:
Нет ли SRM (Sample Ratio Mismatch)?
Достаточно ли наблюдений для MDE основной метрики?
Не слишком ли короткий тест?
Анализ прокси-метрики:
Насколько она коррелирует с основной?
Возможно, эффект проявится позже (lag effect)
Сегментный анализ:
Есть ли сегменты, где основная метрика выросла?
Новые vs. старые пользователи
Решение:
Если прокси-метрика значимо коррелирует с основной и нет негативного влияния на защитные метрики — можно раскатать с мониторингом
Если нет — продлить тест или отказаться от изменения
Не принимать решение только по прокси-метрике
Т-Банк
СтатистикаСреднийзадача
Кубик бросают три раза. Какова вероятность, что выпадет хотя бы 2 одинаковых …
Кубик бросают три раза. Какова вероятность, что выпадет хотя бы 2 одинаковых числа?
Используем подход «от противного» — считаем вероятность того, что все числа разные, и вычитаем из 1.
Когда медиана > среднего, это означает, что длинный хвост распределения тянется влево (в сторону меньших значений). Среднее «утягивается» за выбросами в левом хвосте.
Мнемоника:
Медиана > Среднего → Left-skewed (левый хвост длиннее)
Медиана < Среднего → Right-skewed (правый хвост длиннее)
Медиана ≈ Среднего → Симметричное распределение
Т-Банк
A/B тестыЛегкийвопрос
Как изменится длительность A/B теста, если трафик увеличится
Как изменится длительность A/B теста, если трафик увеличится?
Длительность уменьшится, так как скорость набора выборок станет выше.
Формула размера выборки: n = (Z_α + Z_β)² × 2σ² / δ²
Размер выборки зависит от α, β, σ², δ (MDE) — но не от трафика. Трафик влияет только на то, за сколько дней мы наберём нужный размер выборки.
Если трафик × 2 → длительность теста ≈ / 2 (при прочих равных).
Важно: нельзя останавливать тест раньше, чем набран минимальный размер выборки, даже если p-value уже низкий (проблема peeking).
Т-Банк
SQLЛегкийвопрос
Какая операция в SQL соединяет две таблицы и удаляет дубликаты
Какая операция в SQL соединяет две таблицы и удаляет дубликаты?
UNION — объединяет результаты двух SELECT-запросов и автоматически удаляет дубликаты.
Важные отличия:
UNION — удаляет дубликаты (выполняет сортировку / хеширование для дедупликации)
UNION ALL — оставляет все строки, включая дубликаты (быстрее, так как нет дедупликации)
Требования:
Оба запроса должны возвращать одинаковое количество столбцов
Типы данных соответствующих столбцов должны быть совместимы
UNION ALL предпочтительнее, если дубликаты невозможны или допустимы — он работает быстрее.
Т-Банк
СтатистикаСреднийзадача
В мае было 100к пользователей, в июне из них зашло 20к, в июле — 30к. Какой м…
В мае было 100к пользователей, в июне из них зашло 20к, в июле — 30к. Какой минимальный и максимальный Rolling Retention?
Rolling Retention считает пользователя вернувшимся, если он вернулся в любой из последующих месяцев.
Считаем от майской когорты (100к):
Минимальный RR:
Если пользователи июня и июля полностью пересекаются (все 20к июня вошли в 30к июля)
Вернулось: max(20к, 30к) = 30к
Min RR = 30к / 100к = 30%
Максимальный RR:
Если пользователи июня и июля не пересекаются вообще
Вернулось: 20к + 30к = 50к
Max RR = 50к / 100к = 50%
Ответ: RR ∈ [30%, 50%]
Т-Банк
СтатистикаЛегкийвопрос
Можно ли считать отличия в A/B тесте статистически значимыми, если p_value = …
Можно ли считать отличия в A/B тесте статистически значимыми, если p_value = 0.07 при уровне значимости alpha = 0.05?
Нет, отличия нельзя считать статистически значимыми.
p-value = 0.07 > α = 0.05 → мы не отвергаем нулевую гипотезу.
Это означает: наблюдаемая разница между группами могла получиться случайно с вероятностью 7%, что выше допустимого порога в 5%.
Важно:
p-value ≠ вероятность того, что нулевая гипотеза верна
p-value — это вероятность получить такой же или более экстремальный результат при условии, что H0 верна
p = 0.07 не означает «почти значимо» — порог α выбирается до начала теста и не меняется
ММосБилет
SQLСреднийзадача
Посчитать количество визитов разработчиков в репозиторий n8n-io/n8n за сентяб…
Посчитать количество визитов разработчиков в репозиторий n8n-io/n8n за сентябрь 2025. Таблица github.events (created_at, actor_login, repo_name). Визит — последовательность событий одного пользователя, где перерыв между событиями не больше 30 минут.
WITH data AS (
SELECT
actor_login,
created_at,
LAG(created_at) OVER (
PARTITIONBY actor_login ORDERBY created_at
) AS prev_time,
CASEWHEN DATEDIFF('minute',
LAG(created_at) OVER (PARTITIONBY actor_login ORDERBY created_at),
created_at
) >=30ORLAG(created_at) OVER (PARTITIONBY actor_login ORDERBY created_at) ISNULLTHEN1ELSE0ENDAS session_flag
FROM github.events
WHERE
created_at BETWEEN'2025-09-01'AND'2025-10-01'AND repo_name ='n8n-io/n8n'
)
SELECTSUM(session_flag) AS total_sessions
FROM data;
Идея: помечаем флагом 1 каждое событие, если пауза от предыдущего ≥ 30 минут (или это первое событие). Сумма флагов = количество сессий (визитов). Используем LAG для доступа к предыдущему времени в окне по пользователю.
СберЗдоровье
PythonСреднийзадача
Дан датафрейм продаж (event_dt, store, item, amount, price, customer). Провес…
Дан датафрейм продаж (event_dt, store, item, amount, price, customer). Провести агрегированный анализ: для каждого магазина вывести общую сумму продаж, среднюю цену товара, количество уникальных товаров.
import pandas as pd
df['item_sum'] = df['amount'] * df['price']
result = df.groupby('store').agg(
total_sales=('item_sum', 'sum'),
avg_price=('price', 'mean'),
unique_items=('item', 'nunique')
).reset_index()
print(result)
Используем groupby + agg с именованными агрегациями (named aggregation) — это более читаемый синтаксис, чем передача словаря. Сначала создаём столбец item_sum (выручка по позиции), затем агрегируем по магазинам.
СберЗдоровье
PythonСреднийзадача
Рассчитать средний чек для каждого клиента и вывести топ-5 клиентов с наиболь…
Рассчитать средний чек для каждого клиента и вывести топ-5 клиентов с наибольшим средним чеком.
import pandas as pd
df['item_sum'] = df['amount'] * df['price']
# Считаем сумму заказа (группируем по клиенту, дате и магазину)
order_totals = (
df.groupby(['customer', 'event_dt', 'store'])
.agg(order_sum=('item_sum', 'sum'))
.reset_index()
)
# Средний чек на клиента
avg_check = (
order_totals.groupby('customer')
.agg(avg_check=('order_sum', 'mean'))
.reset_index()
.sort_values('avg_check', ascending=False)
)
print(avg_check.head(5))
Важно: «чек» = один заказ (уникальная комбинация клиент + дата + магазин). Сначала считаем сумму каждого заказа, потом среднее по клиенту.
СберЗдоровье
SQLСреднийзадача
Таблицы country (country_name, city) и sales (date, city, income). Вывести ра…
Таблицы country (country_name, city) и sales (date, city, income). Вывести разницу income в % по каждой стране YoY, сравнивая октябрь 2022 с октябрём 2023.
WITH data AS (
SELECTYEAR(s.date) AS yr,
c.country_name,
SUM(s.income) AS country_income
FROM sales s
LEFTJOIN country c ON s.city = c.city
WHERE DATE_TRUNC('month', s.date) IN ('2022-10-01', '2023-10-01')
GROUPBY yr, c.country_name
)
SELECT
country_name,
MAX(CASEWHEN yr =2022THEN country_income END) AS income_22,
MAX(CASEWHEN yr =2023THEN country_income END) AS income_23,
(MAX(CASEWHEN yr =2023THEN country_income END)
-MAX(CASEWHEN yr =2022THEN country_income END))
/NULLIF(MAX(CASEWHEN yr =2022THEN country_income END), 0)
*100AS delta_pct
FROM data
GROUPBY country_name;
Используем условную агрегацию (MAX(CASE WHEN ...)) для pivot из строк в столбцы. NULLIF защищает от деления на ноль.
Таблица event_log (event_uuid, ts, user_id, event_name). Подготовить запрос для retention анализа: неделя первой установки (Install), количество недель до Consultation, размер когорты, количество уников с Consultation.
WITH cohorts AS (
SELECT
user_id,
MIN(DATE_TRUNC('week', ts)) AS install_week
FROM event_log
WHERE event_name ='Install'GROUPBY user_id
),
diff AS (
SELECT
ev.user_id,
c.install_week,
DATE_TRUNC('week', ev.ts) AS consultation_week,
DATEDIFF('week', c.install_week, DATE_TRUNC('week', ev.ts)) AS week_diff
FROM cohorts c
LEFTJOIN event_log ev
ON ev.user_id = c.user_id
AND ev.event_name ='Consultation'
),
cohort_size AS (
SELECT install_week, COUNT(DISTINCT user_id) AS size
FROM cohorts
GROUPBY install_week
)
SELECT
d.install_week,
d.week_diff,
cs.size AS cohort_size,
COUNT(DISTINCT d.user_id) AS consultation_users
FROM diff d
JOIN cohort_size cs ON d.install_week = cs.install_week
GROUPBY d.install_week, d.week_diff, cs.size
ORDERBY d.install_week, d.week_diff;
Три CTE: когорты (неделя первого Install), интервалы (разница в неделях до Consultation), размер когорт. Финальный SELECT объединяет всё.
СберЗдоровье
КейсыСложныйкейс
DAU вырос, а выручка упала. Что может быть причиной
DAU вырос, а выручка упала. Что может быть причиной?
Декомпозиция:
GMV = Количество заказов × Средний чек
DAU ↑ + GMV ↓ → значит CR в покупку ↓ или средний чек ↓ (или оба)
Возможные причины:
Трафик:
Нерелевантный трафик из рекламы (много новых пользователей, которые не покупают)
Боты / фродовый трафик
Конверсия (CR):
Техническая ошибка на каком-то шаге воронки
Ошибка интеграций (платёжный шлюз, авторизация)
Плохой релиз (UI/UX регрессия)
Средний чек:
Ушли акции/скидки → пользователи покупают меньше
Проблема с рекомендатором → меньше допродаж
Перетекание пользователей в более дешёвые категории
Сезонность
Проверить: технические метрики (error rate, время загрузки), источники трафика, воронку конверсии по шагам.
СберЗдоровье
A/B тестыСложныйкейс
A/B тест: на выдаче врачей в тестовой группе половине врачей повесили шильдик…
A/B тест: на выдаче врачей в тестовой группе половине врачей повесили шильдик «лучший врач». Конверсия в запись на выдаче к любому врачу упала. Предположи, почему.
Первым делом проверить технику:
Нет SRM (соотношение групп правильное)
Ивенты в обеих группах доходят корректно
Ранжирование одинаковое
Других изменений, кроме UI, нет
Продуктовые причины падения:
Парадокс выбора: появление шильдика создаёт «лучших» и «худших» врачей. Пользователи хотят к «лучшему», но у него нет свободных слотов → уходят.
Нерелевантное распределение метки: если шильдик получили не те врачи, пользователи теряют доверие к выдаче.
Эффект сравнения: пользователи дольше выбирают, сравнивая «с шильдиком» vs. «без», и откладывают запись.
Дефицит у «лучших»: все хотят к врачам с меткой → мест нет → пользователь уходит совсем, а не записывается к другому.
HH.ru
SQLСреднийзадача
Нужно отправить рассылку всем работодателям hh, у которых не более 5 активных…
Нужно отправить рассылку всем работодателям hh, у которых не более 5 активных вакансий. Таблицы: employer (employer_id, name) и vacancy (vacancy_id, active, employer_id). Вывести имена таких работодателей.
SELECT e.name
FROM employer e
LEFTJOIN vacancy v ON e.employer_id = v.employer_id
GROUPBY e.name
HAVINGCOUNT(CASEWHEN v.active =TRUETHEN1END) <=5;
Важно использовать LEFT JOIN, чтобы включить работодателей с 0 вакансий. HAVING COUNTIF(active) <= 5 (ClickHouse-синтаксис) или COUNT(CASE WHEN ...) в стандартном SQL.
Альтернатива с подзапросом:
SELECT e.name
FROM employer e
WHERE (
SELECTCOUNT(*)
FROM vacancy v
WHERE v.employer_id = e.employer_id AND v.active =TRUE
) <=5;
HH.ru
SQLСреднийзадача
Таблицы: search (user_id, search_number, vacancy_id, vacancy_position) и clic…
Таблицы: search (user_id, search_number, vacancy_id, vacancy_position) и clicks (ts, user_id, search_number, vacancy_id). Подсчитать среднюю позицию первых трёх кликов пользователей в каждом поиске. Если кликов < 3, поиск не учитывать.
WITH base AS (
SELECT
s.user_id,
s.search_number,
s.vacancy_position,
ROW_NUMBER() OVER (
PARTITIONBY s.user_id, s.search_number
ORDERBY c.ts
) AS click_number
FROMsearch s
INNERJOIN clicks c
ON s.user_id = c.user_id
AND s.search_number = c.search_number
AND s.vacancy_id = c.vacancy_id
)
SELECT
user_id,
search_number,
AVG(vacancy_position) AS avg_position
FROM base
WHERE click_number <=3GROUPBY user_id, search_number
HAVINGCOUNT(*) =3;
JOIN search с clicks по трём ключам. ROW_NUMBER() нумерует клики по времени внутри каждого поиска. Берём первые 3, фильтруем поиски с < 3 кликами через HAVING COUNT(*) = 3.
HH.ru
PythonСреднийзадача
Написать функцию, которая считает факториал от целого неотрицательного числа.…
Написать функцию, которая считает факториал от целого неотрицательного числа. n! = 1 × ... × n, 0! = 1.
# Итеративный подходdeffactorial(n: int) -> int:
if n < 0:
raise ValueError('n должно быть >= 0')
result = 1for i inrange(1, n + 1):
result *= i
return result
# Рекурсивный подходdeffactorial_rec(n: int) -> int:
if n < 0:
raise ValueError('n должно быть >= 0')
if n <= 1:
return1return n * factorial_rec(n - 1)
# Встроенныйfrom math import factorial
print(factorial(5)) # 120print(factorial(0)) # 1
Итеративный вариант предпочтительнее — нет риска переполнения стека при больших n. В Python есть math.factorial(), но на собеседовании обычно просят написать вручную.
HH.ru
PythonЛегкийвопрос
a = [3, 1, 2]. Чем отличается b = a[::-1] от a.reverse()
a = [3, 1, 2]. Чем отличается b = a[::-1] от a.reverse()?
a = [3, 1, 2]
b = a[::-1] # Создаёт НОВЫЙ перевёрнутый список# a = [3, 1, 2] — не изменился# b = [2, 1, 3] — новый объект
a.reverse() # Переворачивает список IN-PLACE# a = [2, 1, 3] — изменился# Возвращает None
Ключевые отличия:
a[::-1] — создаёт новый список, a не меняется. Возвращает новый список.
a.reverse() — изменяет aна месте (in-place). Возвращает None.
a[::-1] использует больше памяти (O(n) — копия), reverse() — O(1).
Также есть reversed(a) — возвращает итератор (ленивый, не копирует).
HH.ru
PythonЛегкийвопрос
В чём разница между list, tuple и set в Python
В чём разница между list, tuple и set в Python?
Свойство
list
tuple
set
Мутабельность
Мутабельный
Иммутабельный
Мутабельный
Порядок
Упорядоченный
Упорядоченный
Неупорядоченный
Дубликаты
Допускает
Допускает
Нет дубликатов
Индексация
Да [i]
Да [i]
Нет
Синтаксис
[1, 2, 3]
(1, 2, 3)
{1, 2, 3}
Хешируемость
Нет
Да (если элементы хешируемы)
Нет
Когда что использовать:
list — изменяемая коллекция с порядком (массив данных, стек)
tuple — неизменяемые данные (координаты, ключи словаря, возврат нескольких значений)
set — уникальные элементы, быстрый поиск O(1), операции пересечения/объединения
HH.ru
КейсыСложныйкейс
Метрика: среднее от «Отклики» на вакансию. Что с ней может быть не так, как е…
Метрика: среднее от «Отклики» на вакансию. Что с ней может быть не так, как её можно улучшить, что можно предложить вместо неё?
Проблемы метрики «среднее откликов»:
Чувствительна к выбросам: одна вакансия с 10000 откликов сильно сдвигает среднее.
Не учитывает качество откликов — много нерелевантных откликов ≠ хорошо.
Не учитывает тип вакансий — массовые vs. экспертные позиции несопоставимы.
Среднее скрывает распределение — 50% вакансий могут иметь 0 откликов.
Улучшения:
Использовать медиану вместо среднего (устойчива к выбросам)
Сегментировать по типу вакансии (категория, уровень, регион)
Считать долю вакансий с хотя бы N откликами (>= 1, >= 5)
Альтернативные метрики:
CR отклик → приглашение (качество откликов)
Время до первого отклика (скорость)
Доля закрытых вакансий (финальный результат)
Время до найма (time to hire)
HH.ru
СтатистикаЛегкийвопрос
X распределена по N(0, 1). Чему равна P(X = 0)
X распределена по N(0, 1). Чему равна P(X = 0)?
P(X = 0) = 0
Для непрерывного распределения вероятность попадания в любую конкретную точку равна нулю.
Нормальное распределение — непрерывное: случайная величина может принять любое значение на числовой прямой. Вероятность определена только для интервалов, а не для отдельных точек.
P(X = a) = 0 для любого a, но P(a ≤ X ≤ b) > 0 при a < b.
Это следствие того, что вероятность для непрерывного распределения — это интеграл от плотности, а интеграл по точке (интервалу нулевой длины) равен нулю.
Детский мир
КейсыЛегкийвопрос
Расскажи про стек аналитика в крупном e-commerce: какие инструменты и платфор…
Расскажи про стек аналитика в крупном e-commerce: какие инструменты и платформы используются?
Типичный стек в крупном e-commerce (на примере Детского мира):
Хранение и обработка:
Hive + Hadoop — хранение больших объёмов данных
ClickHouse — быстрые аналитические запросы
PySpark — обработка данных на кластере
Сбор данных:
Snowplow — сбор событий (event tracking)
Data Hub — каталог данных (data discovery)
Оркестрация:
Airflow — планирование и мониторинг ETL-пайплайнов
Визуализация:
Superset — дашборды и BI
Эксперименты:
Sigma или собственная AB-платформа
Особенности e-commerce: наличие офлайн-тестов (в магазинах), маркетплейс-модель, мультиканальность (онлайн + офлайн).
ВсеИнструменты
КейсыСложныйкейс
Как посчитать долю риелторов от рынка риелторов России на площадке объявлений
Как посчитать долю риелторов от рынка риелторов России на площадке объявлений? Данные: внутренние объявления + данные конкурентов (Циан, Суточно и др.).
Подход:
1. Внутри площадки:
Считаем общее количество объявлений с квартирами
Определяем риелторов через:
Метки на объявлениях (статус «агентство»)
Количество объявлений с одного аккаунта (> N → агент)
Анализ текста описания (LLM/NLP для выявления агентского стиля)
2. Для конкурентов:
Парсинг данных с других площадок
Применяем аналогичную логику определения риелторов
3. Дедупликация:
Мэтчинг объявлений между площадками по: имени пользователя, адресу, стоимости, площади, описанию
Убираем дубликаты
4. Итог:
Доля квартир с риелторами = квартиры_с_риелторами / все_квартиры
Количество уникальных риелторов = уникальные аккаунты с метками
ВсеИнструменты
A/B тестыСложныйкейс
Акция в Казани для B2C: промо на первую покупку 30%. A/B теста нет — на 100%.…
Акция в Казани для B2C: промо на первую покупку 30%. A/B теста нет — на 100%. Начало акции совпало с индексацией цен. CR упал в Казани на 5%, в других городах — на 10%. Как оценить эффект акции?
Проблема: нет контрольной группы, эффект акции смешан с индексацией цен.
Подход — Difference-in-Differences (DiD):
Используем другие города как контроль:
Казань: CR упал на 5%
Другие города (без акции): CR упал на 10%
Инкрементальный эффект акции: 10% − 5% = +5% (акция компенсировала часть падения)
Расчёт привлечённых клиентов:
Прогноз без акции и без индексации: 10000 клиентов
Коэффициент падения от индексации: −10% → 9000
Фактическое количество: 9999
Аплифт от акции: 9999 − 9000 = 999 клиентов
Сегментация по флагам:
Перешли по рекламе + купили с промокодом → точно привлечённые
Перешли по рекламе + купили без промокода → частично
Не видели рекламу + использовали промокод → органическое распространение
ВсеИнструменты
A/B тестыСложныйкейс
Маркетплейс запускает программу лояльности для продавцов (уровни на основе ме…
Маркетплейс запускает программу лояльности для продавцов (уровни на основе метрик, плюшки: скидка на аналитику, продвижение, вес в ранжировании). Нужно оценить эффект через A/B. Как спроектировать?
Дизайн A/B теста:
Группы:
A (контроль): не видят программу лояльности (но уровни ведём внутренне)
B (тест): видят программу и плюшки
Начальные уровни рассчитаны на основе исторических данных
Метрики:
Целевые: выручка на магазин, количество заказов
Вторичные: средний чек, количество поднятий уровня
Защитные: качество товаров (возвраты), скорость доставки
Сложности:
Network effects: продавцы B получают преимущество в ранжировании → это влияет на продавцов A
Длительность: эффект лояльности проявляется не сразу
Сплит: по продавцам, не по пользователям
Решение network effects:
Можно сплитить по регионам или категориям
Switchback design (попеременное включение)
Или сравнивать когорты (до/после) с DiD
ВсеИнструменты
A/B тестыСреднийзадача
Спроектируй дизайн A/B теста для новой модели ранжирования товаров: какие мет…
Спроектируй дизайн A/B теста для новой модели ранжирования товаров: какие метрики, где проводить, как определить длительность.
Метрики:
Целевые:
Доля нажатий на первые N карточек товара
CR в карточку товара в целом
Защитные:
CR в покупку
Скорость загрузки страницы
Доля пустых выдач
Информационные:
Средний чек
Gross / Net revenue
Количество заказов
Платформа: определить веб / приложение (или оба).
Длительность:
Определить текущий CR и желаемый MDE (минимальный детектируемый эффект)
Рассчитать размер выборки: n = (Z_α + Z_β)² × 2p(1−p) / δ²
Длительность = n / (дневной трафик × доля в тесте)
Минимум 1-2 полных недели (учёт сезонности будни/выходные)
Проверки: SRM, отсутствие пересечений между группами, z-test / t-test для оценки разницы.
ВсеИнструменты
SQLСреднийзадача
Таблица employee (id, skill, salary). Написать запрос, который выберет сотруд…
Таблица employee (id, skill, salary). Написать запрос, который выберет сотрудников для бюджета в 500 тыс. руб. с приоритетом на их навыки (чем выше skill, тем приоритетнее).
WITH data AS (
SELECT
id,
skill,
salary,
SUM(salary) OVER (ORDERBY skill DESC, salary) AS cum_sum
FROM employee
)
SELECT id, skill, salary
FROM data
WHERE cum_sum <=500000;
Накапливающая сумма зарплат по убыванию скила. Берём всех сотрудников, пока кумулятивная сумма не превысит бюджет. При одинаковом скиле сначала берём с меньшей зарплатой (чтобы вместить больше людей).
ВсеИнструменты
SQLСреднийзадача
Таблица employee (id, skill, salary). Написать запрос, который выведет медиан…
Таблица employee (id, skill, salary). Написать запрос, который выведет медиану зарплаты без использования встроенной функции медианы.
WITH data AS (
SELECT
salary,
ROW_NUMBER() OVER (ORDERBY salary, id) AS rn
FROM employee
),
rowsAS (
SELECTCOUNT(*) AS cnt FROM employee
)
SELECTAVG(d.salary) AS median_salary
FROM data d
CROSSJOINrows r
WHERE-- нечётное количество: средний элемент
(r.cnt %2=1AND d.rn = (r.cnt +1) /2)
OR-- чётное количество: два центральных
(r.cnt %2=0AND d.rn IN (r.cnt /2, r.cnt /2+1));
Пример:
[1, 2, 3, 4, 5] → rn=3 → медиана = 3
[1, 2, 3, 4] → rn=2,3 → медиана = (2+3)/2 = 2.5
ВсеИнструменты
PythonСреднийзадача
Таблица items (item_id, name, price, update_date) — история изменений товаров…
Таблица items (item_id, name, price, update_date) — история изменений товаров. Написать код, который выведет актуальное состояние товаров на дату 01-06-2025.
import pandas as pd
# Фильтруем записи до нужной даты
df['update_date'] = pd.to_datetime(df['update_date'])
df_filtered = df[df['update_date'] <= '2025-06-01']
# Находим последнюю дату обновления для каждого товара
last_dates = (
df_filtered.groupby('item_id')['update_date']
.max()
.reset_index()
)
# Джойним обратно для получения актуальных строк
result = pd.merge(
df_filtered,
last_dates,
on=['item_id', 'update_date'],
how='inner'
)
print(result[['item_id', 'name', 'price', 'update_date']])
Альтернативный подход через sort_values + drop_duplicates:
result = (
df_filtered
.sort_values('update_date')
.drop_duplicates('item_id', keep='last')
)
ННе определено
SQLСреднийзадача
Объясни разницу между ROW_NUMBER(), RANK() и DENSE_RANK(). Напиши запрос, дем…
Объясни разницу между ROW_NUMBER(), RANK() и DENSE_RANK(). Напиши запрос, демонстрирующий отличие на примере таблицы с дубликатами.
WITH sample AS (
SELECT*FROM (VALUES
('Анна', 90), ('Борис', 90), ('Вера', 85), ('Глеб', 80)
) AS t(name, score)
)
SELECT
name,
score,
ROW_NUMBER() OVER (ORDERBY score DESC) AS row_num,
RANK() OVER (ORDERBY score DESC) AS rnk,
DENSE_RANK() OVER (ORDERBY score DESC) AS dense_rnk
FROM sample;
Результат:
name
score
row_num
rnk
dense_rnk
Анна
90
1
1
1
Борис
90
2
1
1
Вера
85
3
3
2
Глеб
80
4
4
3
ROW_NUMBER — уникальный номер для каждой строки (порядок дубликатов недетерминирован)
DENSE_RANK — одинаковый ранг, не пропускает (1, 1, 2, 3)
ННе определено
SQLСреднийзадача
Напиши SQL-запрос для вычисления медианы через PERCENTILE_CONT. В чём отличие…
Напиши SQL-запрос для вычисления медианы через PERCENTILE_CONT. В чём отличие от PERCENTILE_DISC?
-- PERCENTILE_CONT: интерполирует между значениямиSELECTPERCENTILE_CONT(0.5) WITHINGROUP (ORDERBY salary) AS median_cont
FROM employees;
-- PERCENTILE_DISC: берёт ближайшее реальное значениеSELECTPERCENTILE_DISC(0.5) WITHINGROUP (ORDERBY salary) AS median_disc
FROM employees;
Отличия:
PERCENTILE_CONT(0.5) — непрерывная медиана. Если количество строк чётное, интерполирует между двумя центральными значениями. Результат может не существовать в данных.
PERCENTILE_DISC(0.5) — дискретная медиана. Возвращает реальное значение из данных (ближайшее к позиции 50%).
Пример: [10, 20, 30, 40]
CONT: (20 + 30) / 2 = 25
DISC: 20 (или 30, зависит от реализации)
ННе определено
SQLСреднийзадача
Что такое задача Gaps & Islands
Что такое задача Gaps & Islands? Напиши запрос, который находит непрерывные интервалы дат активности пользователя (если пользователь заходил подряд несколько дней — это один «остров»).
Gaps & Islands — классическая задача SQL: найти непрерывные последовательности (islands) и пропуски (gaps) в данных.
WITH numbered AS (
SELECT
user_id,
activity_date,
activity_date -INTERVAL'1 day'*ROW_NUMBER() OVER (
PARTITIONBY user_id ORDERBY activity_date
) AS grp
FROM user_activity
)
SELECT
user_id,
MIN(activity_date) AS island_start,
MAX(activity_date) AS island_end,
COUNT(*) AS consecutive_days
FROM numbered
GROUPBY user_id, grp
ORDERBY user_id, island_start;
Идея: вычитаем из даты её порядковый номер. Для последовательных дат (01, 02, 03) разница будет одинаковой (01−1, 02−2, 03−3 = одна и та же дата). Это позволяет группировать «острова».
ННе определено
SQLЛегкийвопрос
Что такое SCD Type 2 (Slowly Changing Dimension)
Что такое SCD Type 2 (Slowly Changing Dimension)? Как реализовать запрос актуального состояния из таблицы с версионностью?
SCD Type 2 — подход к хранению истории изменений в аналитических хранилищах. Каждая строка имеет:
valid_from — дата начала действия
valid_to — дата окончания (NULL или '9999-12-31' для актуальной)
Type 2: новая строка с версионностью (полная история)
Type 3: дополнительный столбец (previous_value)
ННе определено
SQLСреднийзадача
Напиши запрос с использованием LATERAL JOIN (или CROSS APPLY). Когда он полез…
Напиши запрос с использованием LATERAL JOIN (или CROSS APPLY). Когда он полезен и чем отличается от обычного JOIN?
LATERAL JOIN позволяет подзапросу ссылаться на столбцы из предыдущих таблиц в FROM.
-- Топ-3 заказа для каждого клиентаSELECT c.name, o.order_date, o.amount
FROM customers c
CROSSJOINLATERAL (
SELECT order_date, amount
FROM orders
WHERE customer_id = c.id -- ссылка на внешнюю таблицу!ORDERBY amount DESC
LIMIT 3
) o;
Отличие от обычного JOIN:
Обычный JOIN: оба источника независимы, фильтрация через ON
LATERAL: правая часть может ссылаться на левую (как коррелированный подзапрос, но в FROM)
Когда полезен:
Топ-N для каждой группы (без оконных функций)
Раскрытие массивов / JSON
Вызов табличных функций с параметрами из другой таблицы
В SQL Server аналог — CROSS APPLY / OUTER APPLY.
ННе определено
SQLСреднийзадача
Напиши рекурсивный CTE: построй последовательность дат (date spine) за январь…
Напиши рекурсивный CTE: построй последовательность дат (date spine) за январь 2025 года.
WITHRECURSIVE date_spine AS (
-- Базовый случайSELECTDATE'2025-01-01'AS dt
UNIONALL-- Рекурсивный шагSELECT dt +INTERVAL'1 day'FROM date_spine
WHERE dt <DATE'2025-01-31'
)
SELECT dt FROM date_spine;
Результат: 31 строка с датами от 2025-01-01 до 2025-01-31.
Зачем нужен date spine:
LEFT JOIN с данными по дням — показать дни без событий (пропуски)
Напиши запрос для Pivot (строки в столбцы): таблица sales (month, product, re…
Напиши запрос для Pivot (строки в столбцы): таблица sales (month, product, revenue) → вывести продукты как столбцы с выручкой по месяцам.
-- Через условную агрегацию (универсально)SELECTmonth,
SUM(CASEWHEN product ='A'THEN revenue ELSE0END) AS product_a,
SUM(CASEWHEN product ='B'THEN revenue ELSE0END) AS product_b,
SUM(CASEWHEN product ='C'THEN revenue ELSE0END) AS product_c
FROM sales
GROUPBYmonthORDERBYmonth;
Unpivot (обратная операция):
SELECTmonth, 'A'AS product, product_a AS revenue FROM pivoted
UNIONALLSELECTmonth, 'B', product_b FROM pivoted
UNIONALLSELECTmonth, 'C', product_c FROM pivoted;
Варианты в разных СУБД:
PostgreSQL: crosstab() из tablefunc
SQL Server: PIVOT / UNPIVOT операторы
ClickHouse: условная агрегация или arrayJoin
ННе определено
SQLСреднийзадача
Напиши запрос с условной агрегацией через CASE WHEN и через FILTER (WHERE). В…
Напиши запрос с условной агрегацией через CASE WHEN и через FILTER (WHERE). В чём разница?
-- Вариант 1: CASE WHEN (работает везде)SELECT
department,
COUNT(CASEWHEN status ='active'THEN1END) AS active_count,
AVG(CASEWHEN status ='active'THEN salary END) AS active_avg_salary
FROM employees
GROUPBY department;
-- Вариант 2: FILTER (PostgreSQL, ClickHouse)SELECT
department,
COUNT(*) FILTER (WHERE status ='active') AS active_count,
AVG(salary) FILTER (WHERE status ='active') AS active_avg_salary
FROM employees
GROUPBY department;
Разница:
CASE WHEN — стандартный SQL, работает во всех СУБД
FILTER (WHERE) — более читаемый синтаксис, поддерживается в PostgreSQL 9.4+, ClickHouse (countIf, sumIf)
Семантически эквивалентны, но FILTER может быть чуть эффективнее (оптимизатор знает, что это фильтрация)
ClickHouse-вариант:countIf(status = 'active'), avgIf(salary, status = 'active')
ННе определено
SQLЛегкийвопрос
В чём разница между EXISTS и IN в SQL
В чём разница между EXISTS и IN в SQL? Когда что использовать и как это влияет на производительность?
-- IN: проверяет вхождение значения в списокSELECT*FROM orders
WHERE customer_id IN (SELECT id FROM vip_customers);
-- EXISTS: проверяет существование хотя бы одной строкиSELECT*FROM orders o
WHEREEXISTS (
SELECT1FROM vip_customers v WHERE v.id = o.customer_id
);
Ключевые различия:
IN
EXISTS
NULL-обработка
IN (NULL, 1, 2) — NULL не матчится, NOT IN (NULL, ...) — ловушка, вернёт пусто
EXISTS корректно работает с NULL
Производительность
Хорош при малом подзапросе
Хорош при большом внешнем запросе
Оптимизация
Подзапрос выполняется один раз
Коррелированный — может выполняться для каждой строки
Правило:
Малый подзапрос → IN
NOT IN с возможными NULL → всегда NOT EXISTS
Современные оптимизаторы часто делают одно и то же
ННе определено
SQLСреднийзадача
Напиши запрос для генерации date spine (календарной таблицы) и используй его …
Напиши запрос для генерации date spine (календарной таблицы) и используй его для заполнения пропусков в данных — покажи дни с нулевыми продажами.
WITHRECURSIVE date_spine AS (
SELECTMIN(order_date) AS dt FROM orders
UNIONALLSELECT dt +INTERVAL'1 day'FROM date_spine
WHERE dt < (SELECTMAX(order_date) FROM orders)
),
daily_sales AS (
SELECT
order_date,
SUM(amount) AS total_sales
FROM orders
GROUPBY order_date
)
SELECT
ds.dt ASdate,
COALESCE(s.total_sales, 0) AS sales
FROM date_spine ds
LEFTJOIN daily_sales s ON ds.dt = s.order_date
ORDERBY ds.dt;
Date spine генерирует все даты в диапазоне. LEFT JOIN с данными продаж показывает 0 для дней без транзакций. Это критично для:
Графиков без пропусков
Корректного расчёта скользящих средних
Обнаружения аномалий (дни без продаж)
ННе определено
PythonЛегкийвопрос
Объясни типы merge в Pandas: left, right, outer, inner, cross. Когда какой ис…
Объясни типы merge в Pandas: left, right, outer, inner, cross. Когда какой использовать?
import pandas as pd
df1 = pd.DataFrame({'key': [1, 2, 3], 'val_a': ['a', 'b', 'c']})
df2 = pd.DataFrame({'key': [2, 3, 4], 'val_b': ['x', 'y', 'z']})
# inner — только совпадающие ключи (пересечение)
pd.merge(df1, df2, on='key', how='inner') # key: 2, 3# left — все из левой + совпадения из правой
pd.merge(df1, df2, on='key', how='left') # key: 1, 2, 3# right — все из правой + совпадения из левой
pd.merge(df1, df2, on='key', how='right') # key: 2, 3, 4# outer — все из обеих (объединение)
pd.merge(df1, df2, on='key', how='outer') # key: 1, 2, 3, 4# cross — декартово произведение (каждый с каждым)
pd.merge(df1, df2, how='cross') # 3 × 3 = 9 строк
Когда использовать:
inner — нужны только записи с совпадениями в обеих таблицах
left — сохранить все записи основной таблицы, дополнив данными
outer — полная картина, включая записи без пары
cross — генерация комбинаций (например, все пары товар × магазин)
ННе определено
PythonЛегкийвопрос
В чём разница между groupby().transform() и groupby().apply() в Pandas
В чём разница между groupby().transform() и groupby().apply() в Pandas? Когда что использовать?
import pandas as pd
df = pd.DataFrame({
'group': ['A', 'A', 'B', 'B'],
'value': [10, 20, 30, 40]
})
# transform — возвращает Series того же размера, что и исходный df
df['group_mean'] = df.groupby('group')['value'].transform('mean')
# [15, 15, 35, 35] — каждой строке приписано среднее её группы# apply — возвращает результат произвольной формы
result = df.groupby('group')['value'].apply(list)
# A: [10, 20], B: [30, 40]
Ключевые различия:
transform
apply
Размер результата
Такой же, как вход
Произвольный
Применение
Поэлементные агрегаты
Произвольные функции
Скорость
Быстрее (оптимизирован)
Медленнее (Python loop)
Используй transform для: нормализации, z-score, заполнения пропусков средним по группе, процента от группы.
Используй apply для: сложных функций, возвращающих несколько столбцов или агрегат.
ННе определено
PythonСреднийзадача
Что такое декоратор в Python
Что такое декоратор в Python? Напиши декоратор с аргументами, который повторяет вызов функции N раз и возвращает список результатов.
pivot_table — когда данные уже в DataFrame и нужна агрегация по значениям
ННе определено
PythonСреднийзадача
Какие стратегии обработки пропусков (NaN) существуют в Pandas
Какие стратегии обработки пропусков (NaN) существуют в Pandas? Покажи fillna с разными подходами.
import pandas as pd
import numpy as np
df = pd.DataFrame({
'value': [1, np.nan, 3, np.nan, 5],
'group': ['A', 'A', 'B', 'B', 'B']
})
# 1. Заполнить константой
df['value'].fillna(0)
# 2. Заполнить средним / медианой
df['value'].fillna(df['value'].mean())
df['value'].fillna(df['value'].median())
# 3. Заполнить средним по группе
df['value'] = df.groupby('group')['value'].transform(
lambda x: x.fillna(x.mean())
)
# 4. Forward fill / backward fill (временные ряды)
df['value'].ffill() # предыдущим значением
df['value'].bfill() # следующим значением# 5. Интерполяция
df['value'].interpolate(method='linear')
# 6. Удаление строк с пропусками
df.dropna(subset=['value'])
Выбор стратегии:ffill/bfill — для временных рядов; среднее по группе — для категориальных данных; удаление — если пропусков < 5% и они случайны (MCAR).
ННе определено
PythonЛегкийвопрос
Объясни lambda-функции и встроенные map, filter, reduce. Чем они отличаются о…
Объясни lambda-функции и встроенные map, filter, reduce. Чем они отличаются от list comprehension?
from functools import reduce
numbers = [1, 2, 3, 4, 5]
# lambda — анонимная функция
square = lambda x: x ** 2# map — применяет функцию к каждому элементуlist(map(lambda x: x ** 2, numbers)) # [1, 4, 9, 16, 25]# Эквивалент: [x ** 2 for x in numbers]# filter — оставляет элементы, для которых функция Truelist(filter(lambda x: x > 3, numbers)) # [4, 5]# Эквивалент: [x for x in numbers if x > 3]# reduce — последовательно применяет функцию к парам
reduce(lambda a, b: a + b, numbers) # 15 (сумма)
reduce(lambda a, b: a * b, numbers) # 120 (произведение)
List comprehension vs map/filter:
Comprehension — более Pythonic и читаемый
map/filter — бывают удобнее с готовыми функциями (map(str, numbers))
reduce — нет прямого аналога в comprehension; для суммы/произведения лучше sum()/math.prod()
ННе определено
PythonСреднийзадача
Покажи возможности f-строк в Python: базовое форматирование, выравнивание, чи…
Покажи возможности f-строк в Python: базовое форматирование, выравнивание, числа с разделителями, отладочный вывод.
Сформулируй центральную предельную теорему (ЦПТ). Почему она важна для аналитика
Сформулируй центральную предельную теорему (ЦПТ). Почему она важна для аналитика?
Центральная предельная теорема (ЦПТ):
При достаточно большом размере выборки (n → ∞), распределение выборочного среднего стремится к нормальному, независимо от распределения исходной генеральной совокупности.
X̄ ~ N(μ, σ²/n), где μ и σ² — параметры генеральной совокупности.
Почему это важно:
A/B тесты: даже если метрика (выручка, конверсия) распределена ненормально, среднее по большой выборке будет ~ нормальным → можно применять t-test.
Доверительные интервалы: формула X̄ ± Z × σ/√n работает благодаря ЦПТ.
Практический порог: обычно n ≥ 30 достаточно для «нормальности» среднего. Для сильно скошенных распределений может потребоваться n ≥ 100+.
Ограничения: ЦПТ требует конечной дисперсии. Для распределений с «тяжёлыми хвостами» (Cauchy) не работает.
ННе определено
СтатистикаЛегкийвопрос
Как правильно интерпретировать доверительный интервал
Как правильно интерпретировать доверительный интервал? Какие распространённые ошибки в интерпретации?
Корректная интерпретация:
95% доверительный интервал [a, b] означает: если мы повторим эксперимент много раз и построим ДИ каждый раз, то 95% таких интервалов будут содержать истинное значение параметра.
Распространённые ОШИБКИ:
❌ «Вероятность 95%, что истинное значение лежит в [a, b]»
→ Истинное значение — фиксированное число, а не случайная величина. Оно либо в интервале, либо нет.
❌ «95% данных попадают в этот интервал»
→ ДИ — про параметр (среднее), а не про отдельные наблюдения.
❌ «Если расширить ДИ, увеличится точность»
→ Расширение увеличивает уверенность (confidence), но снижает точность (precision).
Формула: X̄ ± Z_α/2 × σ/√n
Увеличить n → сузить ДИ (точнее)
Увеличить уровень доверия → расширить ДИ (надёжнее, но менее точно)
ННе определено
СтатистикаЛегкийвопрос
Что такое p-value
Что такое p-value? Что оно НЕ означает? Назови три распространённых заблуждения.
p-value — вероятность получить такой же или более экстремальный результат при условии, что нулевая гипотеза верна.
Три главных заблуждения:
❌ p-value = вероятность того, что H0 верна
✅ p-value — вероятность данных при H0, а не вероятность H0 при данных. Это P(data | H0), а не P(H0 | data).
❌ p = 0.03 значит, что эффект есть с вероятностью 97%
✅ p-value ничего не говорит о вероятности альтернативной гипотезы.
❌ p = 0.049 (значимо) и p = 0.051 (незначимо) — принципиально разные результаты
✅ Порог α произволен. Разница между 0.049 и 0.051 ничтожна. Важен размер эффекта и доверительный интервал.
Что делать вместо бинарного «значимо/незначимо»:
Смотреть на доверительный интервал для размера эффекта
Учитывать практическую значимость (MDE)
Отчитываться о p-value как числе, не только как да/нет
ННе определено
СтатистикаЛегкийвопрос
Объясни ошибки I и II рода. Приведи примеры из A/B тестирования.
Объясни ошибки I и II рода. Приведи примеры из A/B тестирования.
Ошибка I рода (α — False Positive):
Отвергли H0, когда она верна. Решили, что эффект есть, хотя его нет.
Пример: A/B тест показал значимый рост конверсии, мы раскатали изменение, но на самом деле роста не было — результат случайный.
Ошибка II рода (β — False Negative):
Не отвергли H0, когда она ложна. Не заметили реальный эффект.
Пример: Новая фича реально повышает конверсию на 2%, но тест не набрал достаточно данных и показал p > 0.05. Мы отклонили хорошую идею.
Связь:
α обычно = 0.05 (5% ложных срабатываний)
β обычно = 0.20 → мощность = 1 − β = 0.80 (80% шанс найти реальный эффект)
Уменьшение α → увеличение β (и наоборот)
Увеличение выборки → уменьшение обеих ошибок
H0 верна
H0 ложна
Отвергли H0
Ошибка I (α)
Верное решение
Не отвергли
Верное решение
Ошибка II (β)
ННе определено
СтатистикаЛегкийвопрос
Сформулируй закон больших чисел. Чем он отличается от ЦПТ
Сформулируй закон больших чисел. Чем он отличается от ЦПТ?
Закон больших чисел (ЗБЧ):
При увеличении числа наблюдений выборочное среднее сходится к математическому ожиданию генеральной совокупности.
X̄_n → μ при n → ∞
Отличие от ЦПТ:
ЗБЧ
ЦПТ
Говорит о
Сходимости к значению
Форме распределения
Утверждает
X̄ → μ
X̄ ~ N(μ, σ²/n)
Вопрос
«К чему сходится?»
«Как распределено?»
Пример: Бросаем монетку.
ЗБЧ: после 10000 бросков доля орлов ≈ 0.5
ЦПТ: если повторить серию из 100 бросков много раз, среднее количество орлов ~ N(50, 25)
Практическое следствие для аналитика: чем больше выборка, тем точнее наша оценка среднего. Но ЗБЧ не говорит, с какой скоростью идёт сходимость (это даёт ЦПТ через σ/√n).
ННе определено
СтатистикаЛегкийвопрос
Байесовский vs. частотный подход к статистике — в чём разница
Байесовский vs. частотный подход к статистике — в чём разница? Когда какой применяется?
Частотный (frequentist):
Параметр — фиксированное число
Вероятность — частота событий при многократном повторении
«Корреляция не означает причинность». Приведи 3 примера ложных корреляций и о…
«Корреляция не означает причинность». Приведи 3 примера ложных корреляций и объясни, почему они возникают.
Примеры ложных корреляций:
Продажи мороженого и утопления: оба растут летом. Причина — скрытая переменная (confounding variable): температура воздуха.
Количество пожарных и ущерб от пожара: чем больше пожарных на месте, тем больше ущерб. Причина — обратная причинность: большой пожар → вызывают больше пожарных.
Потребление сыра на душу населения и количество смертей от запутывания в простынях: spurious correlation — случайное совпадение трендов при большом количестве переменных.
Почему возникают:
Confounding (скрытая переменная) — Z влияет и на X, и на Y
Reverse causation — Y → X, а не X → Y
Spurious — случайное совпадение при множественных сравнениях
Selection bias — выборка не репрезентативна
Как установить причинность: рандомизированный контролируемый эксперимент (A/B тест).
ННе определено
СтатистикаЛегкийвопрос
Назови ключевые свойства нормального распределения. Что такое правило 68-95-99.7
Назови ключевые свойства нормального распределения. Что такое правило 68-95-99.7?
Нормальное распределение N(μ, σ²):
Свойства:
Симметрично относительно μ (среднее = медиана = мода)
Колоколообразная форма (bell curve)
Полностью определяется двумя параметрами: μ (среднее) и σ (стд. отклонение)
Сумма нормальных величин — тоже нормальная
Хвосты уходят в бесконечность, но быстро убывают
Правило 68-95-99.7 (эмпирическое правило):
68% данных в пределах μ ± 1σ
95% данных в пределах μ ± 2σ
99.7% данных в пределах μ ± 3σ
Примеры в аналитике:
Рост людей ~ N(170, 10²) → 95% людей ростом 150-190 см
Среднее время на сайте (после логарифмирования)
Ошибки измерений
Когда НЕ нормальное: выручка (right-skewed), время между событиями (экспоненциальное), количество событий (Пуассон).
ННе определено
СтатистикаСреднийзадача
Какие методы обнаружения выбросов существуют
Какие методы обнаружения выбросов существуют? Покажи IQR-метод и z-score на примере.
Бонферрони: α_adj = α / m (m — количество сравнений)
20 метрик: α_adj = 0.05 / 20 = 0.0025
Плюс: простой, консервативный
Минус: слишком строгий при большом m
Holm-Bonferroni: step-down процедура
Менее консервативный, чем Бонферрони
Сортируем p-value, сравниваем с α/(m−k+1)
FDR (Benjamini-Hochberg): контролирует долю ложных среди всех отвергнутых
Менее строгий, больше мощности
Подходит для exploratory analysis
Разделение метрик:
1-2 основные (primary) — полная коррекция
Защитные (guardrail) — без коррекции, но только для мониторинга
Информационные — для анализа, без принятия решений
ННе определено
A/B тестыСреднийзадача
Как определить необходимую длительность A/B теста
Как определить необходимую длительность A/B теста? Какие факторы учитывать?
Алгоритм определения длительности:
Определить метрики и baseline (текущее значение)
Определить MDE — минимальный эффект, который хотим поймать
Рассчитать размер выборки:
n = f(α, β, σ², MDE)
Длительность = n × 2 / дневной трафик
Факторы, влияющие на длительность:
Фактор
Влияние
Дисперсия метрики ↑
Длительность ↑
MDE ↓ (ловим мелкий эффект)
Длительность ↑
Трафик ↑
Длительность ↓
Мощность ↑ (1−β)
Длительность ↑
α ↓ (строже)
Длительность ↑
Дополнительные ограничения:
Минимум 1-2 полных недели (сезонность будни/выходные)
Novelty/primacy эффекты — первые 1-2 недели результат может быть нерепрезентативен
Праздники и акции — исключить или увеличить длительность
Нельзя останавливать досрочно при peeking (если не используется sequential testing)
ННе определено
A/B тестыЛегкийвопрос
Что такое метрики-гвардрейлы (guardrail metrics)
Что такое метрики-гвардрейлы (guardrail metrics)? Приведи примеры для e-commerce.
Guardrail metrics — защитные метрики, которые не должны значимо ухудшиться при раскатке изменения. Мы не оптимизируем их, а мониторим.
Примеры для e-commerce:
Техническике:
Скорость загрузки страницы (Core Web Vitals)
Error rate (500-ки, JS-ошибки)
Crash rate (мобильные приложения)
Пользовательские:
Bounce rate
Количество жалоб в поддержку
Доля возвратов
Churn rate
Бизнесовые:
Средний чек (если оптимизируем конверсию)
Конверсия (если оптимизируем средний чек)
Revenue per user (общая выручка)
Как использовать:
Гвардрейлы не участвуют в принятии решения о раскатке (если основная метрика выросла)
Но если гвардрейл значимо упал — стоп, разбираемся
Не применяем коррекцию множественного тестирования к гвардрейлам
ННе определено
A/B тестыЛегкийвопрос
Что такое стратификация в A/B тестах
Что такое стратификация в A/B тестах? Когда она полезна?
Стратификация — разделение пользователей на однородные подгруппы (страты) перед рандомизацией, чтобы обеспечить баланс между группами.
Как работает:
Делим пользователей по важному признаку (платформа, страна, сегмент)
Внутри каждой страты рандомизируем 50/50
Гарантируем одинаковое распределение признака в группах
Зачем:
Уменьшает дисперсию → уменьшает необходимый размер выборки
Гарантирует баланс по ключевым признакам
Особенно полезно при малом трафике
Пример:
Платформа (iOS/Android) сильно влияет на конверсию.
Без стратификации: может случайно попасть 60% iOS в контроль.
Со стратификацией: ровно 50/50 внутри каждой платформы.
Когда полезна:
Есть признак с сильным влиянием на метрику
Малый трафик (< 10000 в группе)
Высокая гетерогенность аудитории
Когда не нужна:
Большой трафик (рандомизация и так сбалансирует)
Нет явных сегментов с разным поведением
ННе определено
A/B тестыЛегкийвопрос
Что такое novelty effect и primacy effect в A/B тестах
Что такое novelty effect и primacy effect в A/B тестах? Как с ними бороться?
Novelty effect (эффект новизны):
Пользователи активнее взаимодействуют с новым вариантом просто потому, что он новый. Метрика B завышена в начале теста.
Пример: Новая кнопка яркого цвета → больше кликов в первые дни, потом привыкают.
Primacy effect (эффект привычки):
Пользователи продолжают вести себя по-старому, игнорируя изменения. Метрика B занижена в начале.
Пример: Переместили навигацию → пользователи не замечают, ходят по старым путям.
Как бороться:
Достаточная длительность — минимум 2-3 недели, чтобы эффекты «устаканились».
Анализ по когортам:
Новые пользователи (не видели старый вариант) — нет primacy/novelty
Старые пользователи — подвержены эффектам
Если метрика разная для когорт — эффект присутствует
Динамика по дням: построить график метрики по дням теста. Если тренд виден — эффект не «устоялся».
Тестировать только на новых пользователях (если возможно).
ННе определено
A/B тестыЛегкийвопрос
Зачем проводят AA-тест перед запуском A/B
Зачем проводят AA-тест перед запуском A/B? Какие проверки он включает?
AA-тест — эксперимент без изменений: обе группы видят одинаковый вариант.
Цель: убедиться, что инфраструктура A/B тестирования работает корректно.
Что проверяем:
SRM (Sample Ratio Mismatch):
Соотношение групп = ожидаемому (50/50)?
χ²-тест или binomial test
Баланс ковариат:
Возраст, платформа, регион — одинаково распределены?
DAU упал на 15% за неделю. Опиши фреймворк анализа: какие шаги предпримешь
DAU упал на 15% за неделю. Опиши фреймворк анализа: какие шаги предпримешь?
Фреймворк анализа падения DAU:
1. Определить масштаб:
Когда именно началось падение? (день, час)
Все платформы или одна (web/iOS/Android)?
Все регионы или один?
2. Внешние факторы:
Сезонность (праздники, каникулы)
Действия конкурентов
Изменения в App Store / Google Play (обновление рейтинга)
3. Технические проблемы:
Аварии серверов, деплои, увеличение латенси
Баги в трекинге (данные не доходят)
CDN/DNS проблемы
4. Продуктовые изменения:
Новые релизы (регрессия UX)
Изменения в онбординге
A/B тесты с негативным эффектом
5. Маркетинг:
Остановка рекламных кампаний
Изменение каналов привлечения
Пуш-уведомления (отключили/поменяли)
6. Декомпозиция DAU:
DAU = Новые + Вернувшиеся + Удержанные
Какая составляющая упала?
Если новые → проблема привлечения
Если удержанные → проблема продукта
ННе определено
КейсыСложныйкейс
Тебе нужно спроектировать систему метрик для новой фичи — рекомендации товаро…
Тебе нужно спроектировать систему метрик для новой фичи — рекомендации товаров на главной странице. Какие метрики выберешь?
Иерархия метрик для рекомендаций:
North Star (бизнес-цель):
Revenue per user (выручка на пользователя)
Целевые (primary):
CR просмотр рекомендации → клик на товар
CR клик → добавление в корзину
CR корзина → покупка
Доля выручки от рекомендованных товаров
Качество рекомендаций:
CTR блока рекомендаций
Разнообразие (% уникальных категорий в рекомендациях)
Покрытие каталога (% товаров, которые попадают в рекомендации)
Средняя позиция клика (ближе к 1 — лучше)
Защитные (guardrails):
Общая конверсия сайта (не упала ли?)
Bounce rate главной страницы
Скорость загрузки (рекомендации не замедляют страницу?)
Доля возвратов рекомендованных товаров
Долгосрочные:
7-day / 30-day retention
LTV пользователей, кликавших на рекомендации
ННе определено
КейсыСложныйкейс
Что такое North Star Metric
Что такое North Star Metric? Как выбрать правильную NSM для продукта?
North Star Metric (NSM) — единственная метрика, которая лучше всего отражает ценность, которую продукт даёт пользователям, и коррелирует с долгосрочным ростом бизнеса.
Критерии хорошей NSM:
Отражает ценность для пользователя (не только для бизнеса)
Является leading indicator (предсказывает будущий рост)
Все команды могут на неё влиять
Понятна всей компании
Примеры:
Продукт
NSM
Spotify
Время прослушивания
Airbnb
Количество забронированных ночей
Facebook
Количество DAU
Slack
Количество отправленных сообщений
E-commerce
Количество заказов (или частота покупок)
Антипаттерны:
Revenue как NSM (lagging, не отражает ценность для пользователя)
Vanity metrics (регистрации без активации)
Несколько NSM (теряется фокус)
Как выбрать:
Определить ключевое действие, показывающее, что пользователь получил ценность
Проверить корреляцию с retention и revenue
Убедиться, что метрика растёт при росте бизнеса
ННе определено
КейсыСложныйкейс
Конверсия воронки покупки упала. Как найти проблемный шаг
Конверсия воронки покупки упала. Как найти проблемный шаг? Опиши подход к анализу воронки.
Фреймворк анализа воронки:
1. Определить шаги воронки:
Главная → Каталог → Карточка товара → Корзина → Оформление → Оплата → Подтверждение
2. Найти проблемный шаг:
Построить конверсию каждого шага: сейчас vs. неделю/месяц назад
Где наибольшее абсолютное падение?
Учесть сезонность (сравнивать с аналогичным периодом)
3. Сегментировать проблемный шаг:
По платформе (web/iOS/Android)
По устройству (desktop/mobile)
По источнику трафика
По сегменту пользователей (новые/старые)
По категории товаров
4. Искать причину:
Технические: ошибки, увеличение времени загрузки, баги
Продуктовые: изменения UI, A/B тесты
Внешние: конкуренты, сезонность, цены
5. Дополнительные инструменты:
Session replay (Hotjar, FullStory)
Heatmaps (клики, скролл)
Error logs
Правило: начинать с самого широкого среза, сужать к конкретному сегменту + шагу.
ННе определено
КейсыСложныйкейс
Как построить retention analysis
Как построить retention analysis? Какие виды retention существуют?
Виды Retention:
Classic (N-day) Retention: вернулся именно в день N
Day 1, Day 7, Day 30
Самый строгий, может быть «шумным»
Rolling Retention: вернулся в день N или позже
Всегда ≥ Classic Retention
Показывает «живых» пользователей
Bracket Retention: вернулся в интервале [N, M]
Week 1 (день 1-7), Week 2 (день 8-14)
Сглаживает шум Classic Retention
Построение:
WITH cohorts AS (
SELECT user_id,
DATE_TRUNC('week', MIN(first_event)) AS cohort
FROM events GROUPBY user_id
),
activity AS (
SELECTDISTINCT user_id,
DATE_TRUNC('week', event_date) AS active_week
FROM events
)
SELECT
c.cohort,
COUNT(DISTINCT c.user_id) AS cohort_size,
COUNT(DISTINCTCASEWHEN a.active_week = c.cohort +INTERVAL'1 week'THEN a.user_id END) AS retained_w1
FROM cohorts c
LEFTJOIN activity a ON c.user_id = a.user_id
GROUPBY c.cohort;
Benchmarks: Day 1: 25-40%, Day 7: 10-20%, Day 30: 5-10% (зависит от продукта).
ННе определено
КейсыСложныйкейс
Как рассчитать unit-экономику продукта
Как рассчитать unit-экономику продукта? Когда продукт становится прибыльным?
За 5 секунд должно быть понятно: «всё хорошо» или «есть проблема»
Цветовая индикация: зелёный/жёлтый/красный
Антипаттерны:
❌ 50 графиков на одном экране
❌ Дашборд, который никто не смотрит
❌ Только абсолютные числа без контекста (без сравнения)
❌ Круговые диаграммы с 15 сегментами
❌ 3D-графики
Чеклист:
Есть заголовок и описание
Указан период данных и время обновления
Ключевые метрики видны без скролла
Есть фильтры (период, сегмент)
Графики подписаны (оси, легенды)
ННе определено
ПоведенческиеЛегкийвопрос
Как работать со стейкхолдерами: PM, разработчиками, руководством
Как работать со стейкхолдерами: PM, разработчиками, руководством? Какие типичные ошибки?
Работа с разными стейкхолдерами:
С Product Manager:
Помогай формулировать гипотезы на данных
Предлагай метрики, а не просто считай запрошенные
Предупреждай о подводных камнях AB-тестов
Ошибка: делать всё, что просят, без вопроса «зачем?»
С разработчиками:
Чётко формулируй требования к данным (какие ивенты нужны, какие поля)
Помогай с ТЗ на аналитические ивенты
Ошибка: «мне нужны данные» без спецификации
С руководством:
Выводы, не процесс: «Нужно делать X, потому что Y»
Один слайд > 10 таблиц
Риски и неопределённость — честно
Ошибка: 40-слайдовая презентация с подробным SQL
Типичные ошибки аналитика:
Отвечать на запрос, не поняв настоящую задачу
Перфекционизм: 2 недели на отчёт, который нужен был вчера
Не уточнять deadline и приоритет
Не документировать решения и предпосылки
Молчать, если задача невыполнима или бессмысленна
ННе определено
SQLЛегкийвопрос
Что такое технический долг в данных (data tech debt)
Что такое технический долг в данных (data tech debt)? Приведи примеры и способы борьбы.
Технический долг в данных — накопленные проблемы в данных, пайплайнах и аналитической инфраструктуре, которые замедляют работу и создают риски.
Примеры:
Недокументированные пайплайны: никто не знает, что делает скрипт 3-летней давности, но от него зависят 5 дашбордов.
Дубликаты метрик: 3 таблицы считают revenue по-разному. Какая правильная?
Сломанные тесты: проверки качества отключены, потому что «часто падали».
Legacy-код: SQL-запросы на 500 строк без комментариев.
Прямые запросы к production DB: аналитики ходят в прод вместо хранилища.
Способы борьбы:
Документация: каждая таблица/метрика — описание + владелец
Data contracts: соглашение с разработчиками о формате данных
Мониторинг: алерты на аномалии, freshness checks
Рефакторинг: выделять время (20%) на улучшение инфраструктуры
Code review: для SQL/dbt моделей так же, как для кода
Deprecation: явно помечать устаревшие таблицы и удалять через N месяцев
ННе определено
SQLЛегкийвопрос
Зачем и как документировать аналитические пайплайны
Зачем и как документировать аналитические пайплайны? Какие инструменты помогают?
Зачем документировать:
Новый аналитик должен за день понять, как устроена система
При инциденте быстро найти владельца и логику расчёта
Избежать «bus factor = 1» (один человек знает всё)
Что документировать:
Таблицы/модели:
Описание: что хранит, зачем, кто владелец
Описание полей: тип, значения, бизнес-смысл
Связи: откуда данные, куда используются
Пайплайны:
DAG: визуальная схема зависимостей
Расписание: когда запускается
SLA: когда данные должны быть готовы
Метрики:
Определение: точная формула
Источник: из какой таблицы
Ограничения: что не учитывает
Инструменты:
dbt docs — автогенерация документации из YAML-описаний
DataHub / Amundsen — каталог данных с lineage
Notion / Confluence — для бизнес-контекста
README в репозитории — для технических деталей
Правило: документация рядом с кодом (dbt YAML, SQL-комментарии) > отдельный документ (быстро устаревает).
Avito
КейсыСложныйкейс
Дизайн A/B теста для рекомендательной системы
Продакт просит запустить A/B тест для нового алгоритма рекомендаций. Как ты спроектируешь эксперимент? Опиши метрики, контрольную/тестовую группы, длительность теста и критерии остановки.
Шаги проектирования A/B теста:
1. Определить метрику успеха:
Первичная: CTR (клики по рекомендациям), конверсия в покупку
Guardrail: GMV, retention, DAU — убедиться, что не роняем
2. Сегментация и рандомизация:
Единица рандомизации: user_id (не сессия — избегаем перекрёстного загрязнения)
Стратифицированная выборка по активности пользователей
3. Размер выборки:
Через калькулятор мощности: MDE = 5%, α = 0.05, power = 0.8
Минимум 2 недели для учёта недельной сезонности
4. Критерии остановки:
Досрочная остановка только при guardrail-нарушении (SRM или значимое падение GMV)
Не пикать p-value каждый день — нужен sequential testing или фиксированный срок
5. Анализ после теста:
Проверка SRM (Sample Ratio Mismatch) перед интерпретацией
t-test или bootstrap для метрики
Анализ по сегментам (новые vs. старые пользователи)
Avito
КейсыСложныйкейс
Диагностика падения ключевой метрики
Ты замечаешь, что DAU упал на 15% за последние 3 дня. Опиши, как диагностируешь проблему: с чего начнёшь, какие гипотезы проверишь и в каком порядке.
Фреймворк диагностики:
1. Проверить данные, не бизнес:
Нет ли проблем в пайплайне сбора данных? (задержки, потеря событий)
Корректно ли работает трекинг?
2. Декомпозиция метрики:
DAU = новые + вернувшиеся + реактивированные
Что именно упало? Новые пользователи или удержание?
3. Сегментация:
По платформе (iOS / Android / web)
По географии, каналу привлечения, версии приложения
Позволяет локализовать источник
4. Внешние факторы:
Был ли релиз? Изменения в пуш-уведомлениях? Маркетинговые кампании?
Праздники, сезонность?
5. Гипотезы по приоритету:
Технический сбой (быстро проверяется)
Деградация UX после релиза
Изменение в привлечении (снизился бюджет / ухудшились объявления)
Органическое снижение (тренд)
Avito
КейсыСложныйкейс
Метрики качества поиска на маркетплейсе
Ты аналитик на маркетплейсе. Задача: оценить качество алгоритма поиска. Предложи набор метрик и поясни, как бы ты их собирал.
Ключ: связать запрос → клик → покупку через session_id
Avito
КейсыСложныйкейс
Влияние промокодов на LTV клиента
Маркетинг хочет понять, влияют ли промокоды положительно на LTV или просто переключают уже лояльных пользователей на скидку. Как подойдёшь к анализу?
Проблема: промокоды могут быть инкрементальными (привлекают новых / удерживают уходящих) или каннибализирующими (дают скидку тем, кто купил бы и так).
Подход 1 — Анализ без эксперимента:
Сегментировать пользователей по первому типу покупки (с промо / без)
Сравнить LTV-кривые (retention × средний чек) по когортам
Слабость: selection bias — промо могли получить изначально более ценные сегменты
Подход 2 — A/B тест (предпочтительный):
Случайно разделить аудиторию: получают промо (B) vs. не получают (A)
Единица рандомизации: user_id
Метрики: LTV за 30/60/90 дней, retention, GMV, частота покупок
Длительность: минимум 4-8 недель для LTV-сигнала
Дополнительный анализ:
Сегментировать по «ценности» пользователя до промо — каннибализация выше среди лояльных
Incremental Revenue = Revenue(B) - Revenue(A) — окупает ли скидку?
Avito
КейсыСложныйкейс
Приоритизация продуктовых фич на основе данных
У команды 10 идей для улучшения приложения, но спринт только на 3 фичи. Продакт просит тебя помочь приоритизировать на основе данных. Какой подход предложишь?
Фреймворк ICE-score с данными:
ICE = Impact × Confidence × Ease (каждый 1–10)
Impact (влияние):
Оценить по историческим данным: сколько пользователей затрагивает фича?
Если есть похожие прецеденты — смотри на delta-метрику из тех A/B тестов
Если нет — экспертная оценка + сравнение с бенчмарками
Confidence (уверенность):
Есть ли данные, что пользователи испытывают эту боль? (опросы, heat maps, funnel drops)
Качество гипотезы: основана на данных или на мнении?
Ease (сложность):
Оценка от разработки (story points или T-shirt size)
Процесс:
Собрать ICE-таблицу вместе с командой
Нормировать оценки (избежать «всё 10»)
Отсортировать по ICE-score
Проверить: нет ли зависимостей между фичами?
Топ-3 взять в спринт
Дополнительно: учитывать strategic alignment — даже низко-оценённая фича может быть важна для OKR.
Базовые вопросы
SQLЛегкийзадача
Пользователи без покупок
Есть таблицы `users(id, name)` и `orders(id, user_id, amount)`. Напиши запрос, который вернёт всех пользователей, у которых ещё не было ни одного заказа.
SELECT u.id, u.name
FROM users u
LEFTJOIN orders o ON o.user_id = u.id
WHERE o.id ISNULL;
Альтернатива через NOT EXISTS:
SELECT id, name
FROM users u
WHERENOTEXISTS (
SELECT1FROM orders o WHERE o.user_id = u.id
);
LEFT JOIN + IS NULL и NOT EXISTS эквивалентны по результату, но оптимизатор может выбирать разные планы. На больших таблицах NOT EXISTS часто работает быстрее.
Базовые вопросы
SQLЛегкийзадача
Процент от общей суммы
Есть таблица `sales(category, revenue)`. Выведи каждую категорию, её выручку и долю от общей выручки (в процентах, округлённую до 2 знаков).
SELECT
category,
revenue,
ROUND(revenue *100.0/SUM(revenue) OVER (), 2) AS pct
FROM sales
ORDERBY revenue DESC;
SUM(revenue) OVER () — оконная функция без секции PARTITION: суммирует по всем строкам. Результат можно также получить через CTE с предварительным подсчётом итога, но оконная функция короче.
Базовые вопросы
SQLСреднийзадача
Топ-3 товара в каждой категории
Таблица `products(id, category, name, revenue)`. Выведи топ-3 товара по выручке в каждой категории. При одинаковой выручке порядок произвольный.
SELECT category, name, revenue
FROM (
SELECT
category, name, revenue,
ROW_NUMBER() OVER (PARTITIONBY category ORDERBY revenue DESC) AS rn
FROM products
) ranked
WHERE rn <=3;
ROW_NUMBER() даёт уникальный номер внутри каждой группы. Если нужно учесть ничьи (вернуть всех с одинаковой выручкой), используй RANK() или DENSE_RANK().
Базовые вопросы
SQLСреднийзадача
Нарастающий итог по дням
Таблица `daily_sales(sale_date DATE, amount NUMERIC)`. Выведи для каждого дня нарастающий итог выручки (с начала периода). Дни отсортированы по возрастанию.
SELECT
sale_date,
amount,
SUM(amount) OVER (ORDERBY sale_date ROWSBETWEEN UNBOUNDED PRECEDING ANDCURRENTROW) AS running_total
FROM daily_sales
ORDERBY sale_date;
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — явное указание рамки окна (по умолчанию для ORDER BY то же самое, но явность помогает избежать ошибок).
Базовые вопросы
SQLСреднийзадача
Разница между текущей и предыдущей строкой (LAG)
Таблица `metrics(dt DATE, value INT)` содержит ежедневные значения метрики. Выведи дату, значение и изменение по сравнению с предыдущим днём (NULL, если предыдущей строки нет).
SELECT
dt,
value,
value-LAG(value) OVER (ORDERBY dt) AS delta
FROM metrics
ORDERBY dt;
LAG(value) возвращает значение из предыдущей строки в заданном порядке. Для первой строки возвращает NULL — разность тоже NULL, что корректно.
Базовые вопросы
SQLСреднийзадача
Сотрудники с зарплатой выше менеджера
Таблица `employees(id, name, salary, manager_id)`. `manager_id` ссылается на `id` в той же таблице. Найди всех сотрудников, чья зарплата выше зарплаты их непосредственного менеджера.
SELECT e.name AS employee, e.salary, m.name AS manager, m.salary AS manager_salary
FROM employees e
JOIN employees m ON e.manager_id = m.id
WHERE e.salary > m.salary;
Self-join: таблица джойнится сама с собой — e (сотрудник) и m (менеджер). Условие соединения — e.manager_id = m.id.
Базовые вопросы
SQLСреднийзадача
Дубликаты в таблице
Таблица `emails(id, email)`. Найди все email-адреса, которые встречаются более одного раза, и покажи, сколько раз каждый встречается.
SELECT email, COUNT(*) AS cnt
FROM emails
GROUPBY email
HAVINGCOUNT(*) >1ORDERBY cnt DESC;
Чтобы удалить дубликаты, оставив только первую запись:
DELETEFROM emails
WHERE id NOTIN (
SELECTMIN(id) FROM emails GROUPBY email
);
Базовые вопросы
SQLСложныйзадача
Медиана без функции MEDIAN
Таблица `salaries(id, amount)`. Найди медианную зарплату, не используя встроенную функцию MEDIAN (она есть не во всех СУБД).
SELECTAVG(amount) AS median
FROM (
SELECT amount,
ROW_NUMBER() OVER (ORDERBY amount) AS rn,
COUNT(*) OVER () AS total
FROM salaries
) t
WHERE rn IN (FLOOR((total +1) /2.0), CEIL((total +1) /2.0));
Логика: для нечётного total оба FLOOR/CEIL дают одно и то же — средний элемент. Для чётного — два средних; AVG от них и есть медиана. PERCENTILE_CONT(0.5) — встроенный вариант в PostgreSQL.
Базовые вопросы
SQLСложныйзадача
Последовательные дни активности
Таблица `logins(user_id INT, login_date DATE)`. Найди пользователей, которые заходили 7 и более дней подряд. Верни `user_id` и максимальную длину непрерывной серии.
WITH deduped AS (
SELECTDISTINCT user_id, login_date FROM logins
),
grouped AS (
SELECT
user_id,
login_date,
login_date -ROW_NUMBER() OVER (PARTITIONBY user_id ORDERBY login_date)::int*INTERVAL'1 day'AS grp
FROM deduped
),
streaks AS (
SELECT user_id, COUNT(*) AS streak_len
FROM grouped
GROUPBY user_id, grp
)
SELECT user_id, MAX(streak_len) AS max_streak
FROM streaks
GROUPBY user_id
HAVINGMAX(streak_len) >=7;
Трюк: если вычесть порядковый номер строки из даты, все даты одной непрерывной серии получат одинаковую «группу». Затем группируем и считаем длину.
Базовые вопросы
SQLСложныйзадача
Вторая по величине зарплата
Таблица `employees(id, salary)`. Напиши запрос, который вернёт вторую по величине уникальную зарплату. Если такой нет — верни NULL.
SELECTMAX(salary) AS second_highest
FROM employees
WHERE salary < (SELECTMAX(salary) FROM employees);
Через OFFSET:
SELECTDISTINCT salary
FROM employees
ORDERBY salary DESC
LIMIT 1OFFSET1;
Через DENSE_RANK:
SELECT salary
FROM (
SELECT salary, DENSE_RANK() OVER (ORDERBY salary DESC) AS rnk
FROM employees
) t
WHERE rnk =2
LIMIT 1;
Первый вариант возвращает NULL автоматически (MAX пустого множества). OFFSET-вариант не вернёт строку, если второй зарплаты нет — нужна обёртка.
Базовые вопросы
PythonЛегкийзадача
FizzBuzz
Напиши функцию `fizzbuzz(n: int) -> list[str]`, которая возвращает список строк от 1 до n:
- "Fizz" если число делится на 3
- "Buzz" если делится на 5
- "FizzBuzz" если делится на 15
- иначе строку с самим числом
deffizzbuzz(n: int) -> list[str]:
result = []
for i inrange(1, n + 1):
if i % 15 == 0:
result.append("FizzBuzz")
elif i % 3 == 0:
result.append("Fizz")
elif i % 5 == 0:
result.append("Buzz")
else:
result.append(str(i))
return result
Компактный вариант:
deffizzbuzz(n):
return [
"FizzBuzz"if i % 15 == 0else"Fizz"if i % 3 == 0else"Buzz"if i % 5 == 0elsestr(i)
for i inrange(1, n + 1)
]
Важно проверять делимость на 15 первым — иначе "FizzBuzz" не попадёт.
Базовые вопросы
PythonЛегкийзадача
Анаграммы
Напиши функцию `are_anagrams(s1: str, s2: str) -> bool`, которая возвращает True, если строки являются анаграммами (игнорируя регистр и пробелы).
Counter — O(n), sorted — O(n log n). Для интервью оба варианта приемлемы, но упомяни разницу в сложности.
Базовые вопросы
PythonЛегкийзадача
Генератор Фибоначчи
Напиши генератор `fib()`, который бесконечно генерирует числа Фибоначчи: 0, 1, 1, 2, 3, 5, 8, …
deffib():
a, b = 0, 1whileTrue:
yield a
a, b = b, a + b
# Использование:
gen = fib()
print([next(gen) for _ inrange(10)]) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
yield превращает функцию в генератор — значения вычисляются лениво, не хранятся в памяти. a, b = b, a + b — атомарное присваивание, временная переменная не нужна.
Базовые вопросы
PythonСреднийзадача
Two Sum
Напиши функцию `two_sum(nums: list[int], target: int) -> tuple[int, int]`, которая возвращает индексы двух чисел из списка, дающих в сумме `target`. Гарантируется, что ровно одно решение существует.
deftwo_sum(nums: list[int], target: int) -> tuple[int, int]:
seen = {} # value -> indexfor i, num inenumerate(nums):
complement = target - num
if complement in seen:
return (seen[complement], i)
seen[num] = i
raise ValueError("No solution")
Сложность: O(n) по времени и памяти. Наивный вариант с двумя циклами — O(n²). За один проход сохраняем «нужный» дополняющий элемент и сразу проверяем, встречали ли мы его раньше.
Базовые вопросы
PythonСреднийзадача
Flatten вложенного списка
Напиши функцию `flatten(lst)`, которая рекурсивно разворачивает произвольно вложенный список в плоский. Например: `flatten([1, [2, [3, 4]], 5])` → `[1, 2, 3, 4, 5]`.
defflatten(lst):
result = []
for item in lst:
ifisinstance(item, list):
result.extend(flatten(item))
else:
result.append(item)
return result
Генераторный вариант не строит промежуточные списки — лучше по памяти при глубокой вложенности.
Базовые вопросы
PythonСреднийзадача
Подстрока без повторяющихся символов
Напиши функцию `length_of_longest_substring(s: str) -> int`, возвращающую длину самой длинной подстроки без повторяющихся символов.
deflength_of_longest_substring(s: str) -> int:
char_index = {} # char -> last seen index
max_len = 0
left = 0for right, char inenumerate(s):
if char in char_index and char_index[char] >= left:
left = char_index[char] + 1
char_index[char] = right
max_len = max(max_len, right - left + 1)
return max_len
Паттерн sliding window: left — левая граница окна без дублей. При встрече повтора сдвигаем left вправо. Сложность O(n).
Базовые вопросы
PythonСреднийзадача
Декоратор для кэширования (memorize)
Напиши декоратор `memorize`, который кэширует результаты вызова функции по её аргументам.
from functools import wraps
defmemoize(func):
cache = {}
@wraps(func)defwrapper(*args):
if args notin cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@memoizedeffib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(50)) # быстро
@wraps(func) сохраняет __name__ и __doc__ оборачиваемой функции. В стандартной библиотеке есть functools.lru_cache — более полная реализация с ограничением размера кэша.
Базовые вопросы
PythonСреднийзадача
Подсчёт вхождений в вложенной структуре
Напиши функцию `count_occurrences(data, target)`, которая считает, сколько раз значение `target` встречается в произвольно вложенной структуре из списков и словарей.
defcount_occurrences(data, target):
if data == target:
return1
count = 0ifisinstance(data, dict):
for v in data.values():
count += count_occurrences(v, target)
elifisinstance(data, (list, tuple)):
for item in data:
count += count_occurrences(item, target)
return count
# Пример:print(count_occurrences({"a": [1, 2, 1], "b": {"c": 1}}, 1)) # 3
Базовые вопросы
PythonСложныйзадача
LRU Cache
Реализуй класс `LRUCache(capacity: int)` с методами:
- `get(key) -> int` — вернуть значение или -1
- `put(key, value)` — добавить; при превышении `capacity` удалить наименее недавно использованный элемент.
from collections import OrderedDict
classLRUCache:
def__init__(self, capacity: int):
self.capacity = capacity
self.cache = OrderedDict()
defget(self, key: int) -> int:
if key notinself.cache:
return -1self.cache.move_to_end(key) # отмечаем как недавно использованныйreturnself.cache[key]
defput(self, key: int, value: int) -> None:
if key inself.cache:
self.cache.move_to_end(key)
self.cache[key] = value
iflen(self.cache) > self.capacity:
self.cache.popitem(last=False) # удаляем самый старый
Сложность: O(1) для обоих методов. OrderedDict хранит порядок вставки; move_to_end + popitem(last=False) — ключ к эффективности. Без OrderedDict нужен двусвязный список + хэш-таблица.
Базовые вопросы
PythonСложныйзадача
Группировка анаграмм
Напиши функцию `group_anagrams(words: list[str]) -> list[list[str]]`, которая группирует слова-анаграммы вместе.
from collections import defaultdict
defgroup_anagrams(words: list[str]) -> list[list[str]]:
groups = defaultdict(list)
for word in words:
key = tuple(sorted(word))
groups[key].append(word)
returnlist(groups.values())
# Пример:print(group_anagrams(["eat","tea","tan","ate","nat","bat"]))
# [["eat","tea","ate"], ["tan","nat"], ["bat"]]
Сложность: O(n · k log k), где k — длина слова. Ключ — отсортированный кортеж символов: у всех анаграмм он одинаков.
Базовые вопросы
СтатистикаЛегкийвопрос
Что такое p-value
Объясни, что такое p-value. Как правильно его интерпретировать, и что является распространённой ошибкой при интерпретации?
p-value — это вероятность получить наблюдаемый результат (или более экстремальный), при условии что нулевая гипотеза H₀ верна.
Корректная интерпретация:
Малый p-value (< α) означает, что данные маловероятны при H₀ — повод отвергнуть H₀.
Распространённые ошибки:
«p-value — это вероятность того, что H₀ верна» — неверно. Это вероятность данных при H₀, не вероятность H₀.
«p < 0.05 значит эффект большой» — неверно. При большой выборке даже ничтожный эффект даёт p < 0.05.
«p > 0.05 значит H₀ верна» — неверно. Мы просто не нашли достаточно доказательств против неё.
Практически: всегда смотри на размер эффекта вместе с p-value.
Базовые вопросы
СтатистикаЛегкийвопрос
Среднее vs. медиана: когда что использовать
В каких случаях медиана предпочтительнее среднего, а в каких — наоборот? Приведи практические примеры из аналитики.
Среднее чувствительно к выбросам, медиана — устойчива к ним.
Медиана предпочтительнее:
Доходы пользователей: несколько очень богатых завышают среднее (медиана зарплат по стране, LTV)
Время ответа сервера: один тормозящий запрос искажает среднее → используют p95/p99
Любые метрики с тяжёлым хвостом
Среднее предпочтительнее:
Оптимизация по сумме: если нужно суммировать (GMV = n × avg_check), среднее напрямую связано с итогом
Симметричные распределения без выбросов: нормально распределённые данные
При агрегации метрик: avg легко считается инкрементально, медиана — нет
Практический совет: смотреть на оба показателя и распределение целиком.
Базовые вопросы
СтатистикаЛегкийвопрос
Ошибки I и II рода
Что такое ошибки первого и второго рода в статистике? Объясни на примере A/B теста.
Ошибка I рода (α, ложноположительная):
Отвергаем H₀, когда она на самом деле верна.
→ В A/B тесте: объявляем фичу эффективной, хотя разницы нет.
→ Контролируется уровнем значимости α (обычно 0.05).
Ошибка II рода (β, ложноотрицательная):
Не отвергаем H₀, когда она ложна.
→ В A/B тесте: не замечаем реального улучшения от фичи.
→ Контролируется мощностью теста (1 − β), обычно задают 0.8.
Компромисс:
Снижая α (меньше ложных срабатываний), мы повышаем β (чаще пропускаем эффект). Увеличение размера выборки снижает обе ошибки одновременно.
Мнемоника: I рода — «казнить невиновного», II рода — «отпустить виновного».
Базовые вопросы
СтатистикаСреднийвопрос
Центральная предельная теорема (ЦПТ)
Сформулируй центральную предельную теорему. Почему она важна для A/B тестирования и анализа данных?
ЦПТ: При достаточно большом n выборочное среднее из независимых одинаково распределённых случайных величин (с конечной дисперсией) стремится к нормальному распределению, независимо от формы исходного распределения.
В A/B тестах метрики редко нормально распределены (выручка, время на сайте — скошенные). ЦПТ позволяет применять t-тест к средним даже для таких данных.
Чем больше выборка, тем лучше приближение — обычно n > 30 считается достаточным для грубых расчётов.
Доверительные интервалы и p-values в t-тесте основаны на нормальности средних.
Ограничения: не работает для тяжёлых хвостов (Коши), очень маленьких выборок.
Базовые вопросы
СтатистикаСреднийвопрос
Теорема Байеса и условная вероятность
Объясни теорему Байеса. Задача: тест на болезнь даёт ложноположительный результат в 5% случаев и ложноотрицательный в 1%. Болеет 0.1% населения. Какова вероятность болезни при положительном тесте?
Повторить 10 000 раз → получить эмпирическое распределение.
95% доверительный интервал: перцентили 2.5% и 97.5%.
Преимущества:
Не требует предположений о форме распределения
Работает для любой статистики (медиана, R², …)
Особенно полезен при малых выборках
Ограничения: вычислительно дорогой; не работает для зависимых данных (временные ряды — нужен block bootstrap).
Базовые вопросы
СтатистикаСреднийвопрос
Множественное тестирование
Что такое проблема множественного тестирования? Назови два способа корректировки и объясни разницу между ними.
Проблема: при одновременной проверке k гипотез вероятность хотя бы одного ложноположительного результата резко растёт. При 20 тестах с α=0.05 вероятность хотя бы одной ошибки I рода ≈ 64%.
Поправка Бонферрони:
α_adjusted = α / k
Контролирует FWER (Family-Wise Error Rate) — вероятность хотя бы одной ошибки.
Консервативна — при большом k мощность падает.
FDR (False Discovery Rate), поправка Бенджамини-Хохберга:
Контролирует долю ложных отклонений среди всех отклонённых H₀.
Менее строга, больше мощность — подходит для исследовательского анализа и омиксных данных.
Практика:
В A/B тестах с несколькими метриками → FDR или Bonferroni для первичной метрики
В геномике / многомерных анализах → Benjamini-Hochberg стандарт
Базовые вопросы
СтатистикаСреднийвопрос
Проверка нормальности распределения
Как проверить, нормально ли распределены данные? Назови минимум два теста и один визуальный метод.
Визуальные методы:
Q-Q plot (Quantile-Quantile): сравнивает квантили выборки с теоретическими квантилями нормального распределения. При нормальности точки лежат на диагонали.
Shapiro-Wilk (n < 5000): H₀ — данные нормально распределены. p > 0.05 → не отвергаем нормальность. Один из мощнейших.
Kolmogorov-Smirnov: сравнивает эмпирическое распределение с теоретическим. Менее мощный, чем Shapiro-Wilk.
D'Agostino-Pearson: тест на асимметрию и эксцесс.
Практически:
При большом n (> 1000) тесты почти всегда найдут отклонение — из-за ЦПТ это часто не важно. Важнее Q-Q plot и понимание, зачем нужна нормальность.
Базовые вопросы
СтатистикаСложныйвопрос
ANOVA: дисперсионный анализ
Что такое ANOVA (дисперсионный анализ) и когда её применяют? Чем однофакторная ANOVA отличается от t-теста?
ANOVA (Analysis of Variance) проверяет, различаются ли средние трёх и более групп.
H₀: μ₁ = μ₂ = … = μk (все средние равны)
H₁: хотя бы одна пара различается
Ключевая идея:
Сравнивает дисперсию между группами (Between-group) с дисперсией внутри групп (Within-group). F-статистика = MS_between / MS_within. Если F велико → группы отличаются.
Vs. t-тест:
t-тест: только 2 группы
ANOVA: 3+ группы без раздувания α (несколько t-тестов надо корректировать Бонферрони)
ANOVA говорит «есть различие», но не «где именно» → post-hoc тесты (Tukey, Bonferroni)
Применение в аналитике:
Сравнение нескольких вариантов (A/B/C/D тест)
Влияние географии/платформы на метрику
Но при нарушении предположений (гомоскедастичность, нормальность) → Kruskal-Wallis (непараметрический аналог)
Базовые вопросы
СтатистикаСложныйвопрос
Доверительный интервал: что он означает
Объясни, что означает 95% доверительный интервал. Какая интерпретация корректна, а какая — ошибочна?
Корректная интерпретация:
Если повторять эксперимент бесконечно и каждый раз строить 95% ДИ, то 95% из этих интервалов будут содержать истинный параметр.
Ошибочные интерпретации:
«Вероятность 95%, что истинное значение внутри интервала» — неверно (в частотной статистике истинный параметр фиксирован, не случаен)
«95% данных лежат в этом интервале» — неверно (это перцентильный диапазон данных, не ДИ)
Практически:
Ширина ДИ показывает точность оценки:
Узкий ДИ → много данных, высокая точность
Широкий ДИ → мало данных, неопределённость
Для интерпретации результатов A/B теста: если ДИ для разности эффектов не включает 0 — результат значим. Ширина ДИ говорит об экономической значимости эффекта.
Базовые вопросы
A/B тестыЛегкийвопрос
Что такое MDE (минимальный детектируемый эффект)
Что такое MDE в A/B тестировании? Как его задают и от чего он зависит?
MDE (Minimum Detectable Effect) — наименьший эффект, который тест способен обнаружить с заданной мощностью.
Задают исходя из бизнес-значимости: «нам не интересен эффект меньше +1% конверсии».
Зависит от:
Размера выборки (n ↑ → MDE ↓)
Уровня значимости α (строже α → MDE ↑)
Мощности теста (1−β) (выше мощность → MDE ↓, нужно больше данных)
Baseline метрики и её дисперсии
Формула (приближённо для двух групп):
$n = \frac{(z_{\alpha/2} + z_\beta)^2 \cdot 2\sigma^2}{\delta^2}$
Где δ — MDE, σ² — дисперсия метрики.
Практика: использовать калькулятор размера выборки. Стандарт: α=0.05, мощность=0.8.
Базовые вопросы
A/B тестыЛегкийвопрос
Единица рандомизации в A/B тесте
Что такое единица рандомизации в A/B тесте? Какие варианты бывают и как выбрать правильный?
Единица рандомизации — объект, по которому разделяют контрольную и тестовую группы.
Основные варианты:
user_id — наиболее распространён. Один пользователь всегда в одной группе. Нет загрязнения между сессиями.
session_id — один пользователь может попасть в обе группы. Нарушает независимость наблюдений (SUTVA). Использовать с осторожностью.
cookie — промежуточный вариант; теряется при смене браузера/устройства.
географический кластер / магазин — для экспериментов с сетевыми эффектами или физических точек.
Принципы выбора:
Единица должна быть стабильной на протяжении теста
Нет «перетекания» между группами (SUTVA)
Как можно ниже уровень агрегации — больше статистической мощности
Базовые вопросы
A/B тестыСреднийвопрос
Peeking problem в A/B тестах
Что такое peeking problem (проблема «подглядывания») в A/B тестировании? К каким последствиям приводит?
Проблема: исследователь регулярно смотрит на p-value в процессе теста и останавливает его, как только p < 0.05. Это резко завышает реальный уровень ошибки I рода.
Почему: при каждом промежуточном взгляде мы проводим дополнительный статистический тест. При достаточном числе «взглядов» p-value обязательно упадёт ниже α случайно.
Симуляция: если смотреть каждый день в течение 20 дней, вероятность хотя бы раз увидеть p < 0.05 у нулевого эффекта ≈ 30-40% (вместо 5%).
Решения:
Фиксированный срок — определить n заранее, смотреть результат один раз
Bayesian подход — можно останавливать при достаточной вероятности эффекта
Always-valid p-values (Optimizely, Eppo) — промышленные решения
Базовые вопросы
A/B тестыСреднийвопрос
SRM (Sample Ratio Mismatch)
Что такое SRM в A/B тесте? Как его обнаружить и каковы основные причины?
SRM (Sample Ratio Mismatch) — несоответствие фактического соотношения пользователей в группах ожидаемому.
Пример: ожидали 50/50, получили 48/52.
Обнаружение: тест χ² (chi-squared) на соответствие долей:
H₀: соотношение соответствует ожидаемому
p < 0.01 → SRM есть, результаты теста ненадёжны
Причины:
Ошибки в логировании (один вариант теряет события)
Redirect-тест: бот-трафик отфильтровывается иначе для разных вариантов
Кэширование на стороне клиента
Ошибка в рандомизаторе (hash collision)
Сбой в доставке фичи
Важно: при обнаружении SRM → не интерпретировать результаты теста. Сначала разобраться с источником смещения.
Базовые вопросы
A/B тестыСреднийвопрос
Эффект новизны в A/B тестах
Что такое эффект новизны (novelty effect) в A/B тестировании? Как с ним бороться?
Эффект новизны — пользователи взаимодействуют с новым вариантом активнее просто потому, что он новый, а не потому что он лучше. Через какое-то время поведение нормализуется.
Обратный эффект (change aversion): пользователи, привыкшие к старому интерфейсу, хуже взаимодействуют с новым в начале.
Признаки: значимое улучшение в первые дни, затем сходится к нулю.
Как бороться:
Сегментация: смотреть отдельно на новых пользователей (не подвержены novelty) и на вернувшихся
Увеличить срок теста — запустить на 3–4 недели, смотреть на поведение в конце периода
Cohort analysis: анализировать поведение пользователей по дням после попадания в тест
Holdout groups: долгосрочные тесты с длинным горизонтом
Базовые вопросы
A/B тестыСреднийвопрос
Guardrail-метрики
Что такое guardrail-метрики в A/B тестировании? Приведи примеры и объясни зачем они нужны.
Guardrail-метрики — показатели, которые не должны значимо ухудшиться при запуске фичи. Они защищают бизнес от непреднамеренного вреда.
Зачем нужны:
Оптимизируя одну метрику (CTR на рекомендации), можно ненароком ухудшить другую (время сессии, retention). Guardrails — страховочная сетка.
Типичные guardrail-метрики:
Revenue / GMV (не роняем выручку)
DAU / Retention (не теряем пользователей)
Latency (не ухудшаем скорость)
Crash rate / Error rate (не ломаем стабильность)
Customer support tickets
Логика принятия решений:
Первичная метрика ↑, guardrails в норме → запускаем
Первичная ↑, но guardrail ↓ → нужен дополнительный анализ
Guardrail существенно ухудшился → не запускаем, даже если первичная выросла
Базовые вопросы
A/B тестыСреднийвопрос
Байесовский vs. частотный подход в A/B
В чём разница между байесовским и частотным (frequentist) подходами к A/B тестированию? Назови плюсы и минусы каждого.
Частотный (frequentist):
Понятие: p-value, доверительный интервал
Интерпретация: «вероятность данных при H₀»
Нельзя остановить тест досрочно без коррекции (peeking problem)
Широко принят, легко объяснить
Интуитивно сложен для нестатистиков («что значит p=0.03?»)
Байесовский:
Понятие: posterior probability, доверительная область (credible interval)
Интерпретация: «вероятность, что вариант B лучше, составляет 97%»
Понятен стейкхолдерам
Можно останавливать при достижении нужной вероятности
Естественно включает prior-знания
Чувствителен к выбору prior; при неинформативном prior может требовать больше данных
Вычислительно дороже
На практике: многие крупные компании (Booking, Netflix) используют байесовский подход для интерпретируемости.
Базовые вопросы
A/B тестыСложныйвопрос
CUPED: снижение дисперсии в A/B тестах
Что такое CUPED (Controlled-experiment Using Pre-Experiment Data) и как он повышает мощность A/B теста?
CUPED — метод снижения дисперсии метрики за счёт использования ковариаты (чаще всего — предтестовых данных пользователя).
Идея:
Большая часть вариации метрики объясняется постоянными свойствами пользователя. Если вычесть предсказуемую часть, остаток (residual) будет иметь меньшую дисперсию → тест мощнее.
Формула:
$Y_{cuped} = Y - \theta \cdot X$
Где X — предтестовое значение той же метрики, θ = Cov(X,Y)/Var(X).
Результат: при высокой корреляции X и Y дисперсия уменьшается на (1 − ρ²) × 100%. При ρ=0.7 → экономия ~51% объёма выборки.
Требования:
X должна быть собрана до рандомизации
X независима от назначения в группу
Применение: Microsoft (изобрели метод), Booking.com, Netflix используют по умолчанию.
Базовые вопросы
A/B тестыСложныйвопрос
Сетевые эффекты в A/B тестах
Почему стандартный A/B тест ненадёжен при наличии сетевых эффектов? Как адаптировать дизайн эксперимента?
Проблема:
При сетевых эффектах действия пользователя влияют на других пользователей. Контрольная и тестовая группы «заражают» друг друга → нарушается условие независимости (SUTVA).
Пример: тест фичи «Отправить приглашение другу». Пользователь из группы B приглашает друга из группы A → группа A «загрязнена».
Методы:
Cluster randomization (географические кластеры): разделить не пользователей, а города/регионы. Дороже (нужно больше кластеров), снижает мощность.
Graph-based clustering: разбить социальный граф на плотные сообщества, тестировать целые сообщества.
Ego-network experiments (LinkedIn): рандомизация по «эго-сетям» пользователей.
Two-sided marketplace experiments: тестировать отдельно supply и demand.
Компании: LinkedIn, Facebook, Airbnb публикуют кейсы своих подходов.
Базовые вопросы
A/B тестыСложныйвопрос
Тестирование редких событий
Первичная метрика — конверсия в покупку (0.5%). Как провести A/B тест при таком низком baseline? Какие альтернативные подходы существуют?
Проблема: малый baseline → нужна огромная выборка для заданного MDE.
При baseline=0.5%, MDE=10% (т.е. 0.55%), α=0.05, мощность=0.8 → нужно ~1.6 млн пользователей на группу.
Подходы:
Суррогатные метрики (proxy metrics): использовать более частое событие на воронке (клик, добавление в корзину), которое коррелирует с покупкой. Экономит выборку, но нужно валидировать суррогат.
Увеличить MDE: протестировать более сильное изменение (не +10%, а +25%). Минус — нет информации о малых эффектах.
Байесовский подход: более эффективен при малых выборках за счёт prior-а.
Sequential design / SPRT: останавливать тест при накоплении доказательств (экономит время).
Сегментация: тестировать только на аудитории с высоким conversion propensity (например, пользователи, добавившие в корзину).
Базовые вопросы
КейсыЛегкийкейс
Рост отказов на шаге оплаты
Конверсия со страницы оплаты упала на 10% за последние 3 дня. Как будешь диагностировать проблему?
Шаг 1 — Проверить данные:
Нет ли пропусков в логах или сбоя трекинга оплаты
Сравнить событие «страница открыта» с «оплата нажата» — где именно отвал
Шаг 2 — Разбить по сегментам:
Устройство: мобайл vs. десктоп (мобильная верстка сломана?)
Платёжная система: одна из систем (карта, SberPay) упала
Браузер/ОС: обновление приложения с багом
Тип пользователя: новые vs. старые
Шаг 3 — Внешние изменения:
Был ли деплой в эти дни? Изменения в форме оплаты?
Выросли ли ошибки от платёжного провайдера?
Шаг 4 — Качественные сигналы:
Записи сессий (Hotjar, Fullstory): куда кликают перед уходом
Тикеты в поддержку: жалобы на ошибки при оплате
Вывод и действие: определить сегмент → откатить деплой / исправить ошибку / заэскалировать в команду платёжного шлюза.
Базовые вопросы
КейсыСреднийкейс
Падение retention у новых пользователей
7-дневный Retention новых пользователей упал с 35% до 28% за последний месяц. При этом DAU стабилен. Как подойдёшь к анализу?
Почему DAU стабилен при падении Ret? Скорее всего, вырос поток новых пользователей — они компенсируют отток. Важно разделить потоки.
Декомпозиция:
Ret падает только у новых? Проверить вернувшихся и реактивированных отдельно
В каком канале привлечения упал Ret? (платный трафик, органика, рефералы)
Новая когорта хуже по качеству (боты, нерелевантная аудитория)?
Анализ онбординга:
Построить воронку онбординга: регистрация → первое ключевое действие → возврат
Где падает воронка по сравнению с прошлыми когортами?
Гипотезы:
Изменился продукт (деплой, UX): новые пользователи попадают в ухудшенный опыт
Изменился маркетинг: привлекаем менее целевую аудиторию
Сезонность / праздники (активность ниже в выходные месяца)
Изменилась конкуренция
Рекомендации: сравнить cohort-based воронку новых когорт с когортами 2–3 месяца назад.
Базовые вопросы
КейсыСреднийкейс
Выбор North Star метрики
Ты аналитик в стриминговом сервисе. Продакт просит помочь выбрать North Star метрику. Предложи кандидатов и обоснуй выбор.
North Star метрика — единственный показатель, лучше всего отражающий долгосрочную ценность для пользователей и связанный с выручкой.
Кандидаты для стриминга:
Часы просмотра на пользователя в месяц — отражает вовлечённость, предсказывает удержание
% активных пользователей, просмотревших >X часов — качественный порог активности
Monthly Active Subscribers — бизнесовая метрика, но не отражает ценность
Завершённые просмотры — качество: досмотрел ли пользователь
Рекомендация: Часы просмотра / активный пользователь — хорошо предсказывает отток (меньше смотришь → уходишь) и рост (больше контента → больше смотришь → выше готовность платить). Netflix использует близкую метрику.
Критерии хорошей NSM:
Ведущий индикатор (не запаздывает)
Понятен всей команде
Действенный (на него можно влиять)
Базовые вопросы
КейсыСреднийкейс
Оценка экономического эффекта фичи
Фича «Быстрая повторная покупка» была запущена по результатам A/B теста: +3% конверсии в повторную покупку. Как перевести это в денежный эффект?
Шаг 1 — Понять охват:
Сколько пользователей видят эту фичу в месяц? (n_eligible)
Если повторные покупки повышают Retention → дополнительный LTV-эффект
Шаг 5 — Доверительный интервал:
Построить CI вокруг оценки (из ДИ A/B теста для конверсии)
Базовые вопросы
КейсыСреднийкейс
Метрики жалуются пользователи, но данные в норме
В поддержку поступают жалобы на медленную загрузку приложения. Но средняя скорость в мониторинге в норме — 1.2 сек. Как объяснишь расхождение и что проверишь?
Почему среднее в норме при жалобах:
Хвост распределения: медленно только у части (p95 или p99 высокий). Среднее не показывает выбросы.
Сегмент пользователей: медленно только на старых устройствах / слабом интернете / конкретной платформе
Проблема в конкретном регионе: CDN упал в Сибири
Ошибка метрики: не учитываются тайм-ауты или failed-запросы
Что проверить:
Построить перцентили: p50, p75, p95, p99 по скорости
Сегментировать: ОС, версия, география, тип соединения
Посмотреть на error rate: ошибки ≠ медленные ответы в обычных метриках
Проверить инфраструктурный мониторинг: CDN, mobile backend
Вывод: использовать перцентильные метрики (p95 latency) вместо среднего для мониторинга пользовательского опыта.
Базовые вопросы
КейсыСреднийкейс
Аномальный рост одной когорты
Одна когорта пользователей показывает в 2 раза выше Retention, чем остальные. Как это объяснить и что делать?
Первая мысль: не спешить радоваться — нужно разобраться в причинах.
Гипотезы:
Артефакт данных: ошибка в логировании для этой когорты
Изменение в продукте: в момент привлечения этой когорты запустили фичу, которая улучшила онбординг
Качество аудитории: когорта из другого канала (органика vs. платный), другая демография
Специальная акция: промокод или бонус при регистрации создал «искусственный» Retention
Сезонность: привлекали в период высокого product-market fit (праздники, новый контент)
Действия:
Проверить данные: нет ли дублей, проблем с трекингом
Посмотреть каналы и источники трафика когорты
Восстановить таймлайн: что менялось в продукте и маркетинге в тот период
Если изменение в продукте — запустить A/B тест, чтобы проверить гипотезу
Базовые вопросы
КейсыСложныйкейс
Каннибализация: новая фича ест старую
Новый блок рекомендаций «Для вас» показал в A/B тесте +5% к общему CTR. Но команда контента заметила, что просмотры из каталога упали на 7%. Что произошло и как принять решение?
Диагноз: каннибализация.
Пользователи переключились с каталога на персональные рекомендации — рекомендации «съели» часть трафика каталога.
Ключевые вопросы:
Изменился ли общий объём потребления контента (суммарные просмотры), или просто перераспределился?
Одинакова ли ценность контента через каталог и через рекомендации? (CTR → покупка, LTV)
Есть ли долгосрочный эффект: рекомендации могут снижать разнообразие потребления (filter bubble)
Анализ:
Считать net-суммарный CTR + downstream метрики (retention, revenue per user)
Сравнить качество просмотров: завершение, оценки, покупки
Принятие решения:
Если суммарная вовлечённость и бизнес-метрики выросли → запускать
Если перераспределение с каннибализацией ключевого KPI команды контента → обсуждать компромисс на уровне продуктового комитета
Вывод: без анализа сквозных метрик выводы преждевременны.
Базовые вопросы
КейсыСложныйкейс
Построение модели предсказания оттока
Тебе нужно построить модель предсказания оттока пользователей. Опиши процесс: от постановки задачи до внедрения.
1. Постановка задачи:
Определить «отток»: не логинился 30 дней? Отменил подписку?
Горизонт предсказания: за сколько дней до оттока хотим предупредить?
Целевая аудитория: все пользователи или только активные?
2. Данные и фичи:
Поведенческие: частота, давность, глубина активности (RFM)
Транзакционные: сумма, частота покупок, снижение активности
Не accuracy (классы несбалансированы)! Precision, Recall, F1, ROC-AUC
Бизнес-метрика: стоимость ложноположительного (ненужная акция) vs. ложноотрицательного (потерянный пользователь)
4. Модель:
Baseline: логистическая регрессия
Лучше: Gradient Boosting (XGBoost/LightGBM) — хорошо на табличных данных
5. Внедрение:
Дата-пайплайн: ежедневный расчёт score
Интеграция с CRM: триггер на retention-акцию для top-N риск-пользователей
Мониторинг: дрейф данных (PSI), качество модели во времени
Базовые вопросы
КейсыСложныйкейс
Долгосрочный эффект A/B теста на LTV
A/B тест показал нейтральный результат за 2 недели. Но команда подозревает, что эффект фичи проявляется в долгосрочной перспективе. Как оценить долгосрочный эффект?
Проблема краткосрочных тестов:
Двухнедельный тест не улавливает изменения в retention, LTV, частоте покупок — они накапливаются месяцами.
Подходы:
1. Holdout groups (долгосрочные группы):
Оставить небольшой holdout (5-10%) без фичи на 3–6 месяцев. Дорого, но надёжно. Используют Netflix, Google.
2. Surrogate metrics:
Найти краткосрочный показатель, коррелирующий с LTV (например, 7-дневный Retention → 6-месячный LTV). Требует исторической валидации связи.
3. Cohort analysis:
Сравнивать когорты пользователей, получивших фичу, с аналогичными когортами без неё — через 1, 3, 6 месяцев. Проблема: cohort selection bias.
4. Causal inference методы:
Difference-in-Differences, Synthetic Control, если нельзя держать постоянный holdout.
Рекомендация: договориться с бизнесом о метрике и горизонте до запуска, а не после. «Нейтральный через 2 недели» — не финальный ответ.
Базовые вопросы
КейсыСложныйкейс
Система рекомендаций: с чего начать
Стартап просит тебя с нуля построить систему рекомендаций для маркетплейса. Опиши архитектуру и как двигаться итерационно.
Принцип: начать с простого, усложнять по данным.
Итерация 0 — Baseline (правила):
Популярные товары (bestsellers)
«С этим часто берут» (co-purchase)
Новинки категории
→ Быстро, не требует ML, даёт первый сигнал
Итерация 1 — Коллаборативная фильтрация:
User-item матрица: кто купил / просмотрел что
Item-based CF: «если купил A — рекомендуем B (похожие пользователи брали B)
Проблема: cold start для новых пользователей / товаров
Итерация 2 — Контентная фильтрация:
Embedding товаров по атрибутам (категория, цена, описание)
Решает cold start для новых товаров
Итерация 3 — Двухуровневая архитектура:
Retrieval (кандидаты): ANN-поиск в embedding-пространстве, 100–1000 кандидатов
Ranking: ML-модель с учётом контекста (устройство, время, история)
Метрики: precision@K, recall@K, nDCG, Coverage (разнообразие), + A/B по конверсии
Инфраструктура: feature store, serving layer с кэшем, monitoring дрейфа.
Базовые вопросы
SQLЛегкийвопрос
Data Warehouse vs. Data Lake
В чём разница между Data Warehouse и Data Lake? Когда что использовать?
Data Warehouse (DWH):
Структурированные данные, схема задаётся при загрузке (schema-on-write)
Оптимизирован для аналитических SQL-запросов
Высокое качество данных, медленная загрузка сырых данных
Примеры: Snowflake, BigQuery, Redshift
Data Lake:
Любые данные (структурированные, полуструктурированные, бинарные)
Схема задаётся при чтении (schema-on-read)
Дёшев для хранения, но требует обработки при использовании
Примеры: S3 + Spark, Azure Data Lake
Data Lakehouse (современный тренд): Delta Lake, Iceberg — объединяет плюсы обоих.
Когда что:
DWH → бизнес-отчётность, BI, регулярные запросы
Data Lake → ML-эксперименты, сырые логи, большие объёмы неструктурированных данных
Базовые вопросы
SQLЛегкийвопрос
ETL vs. ELT: в чём разница
Объясни разницу между ETL и ELT-подходами. Когда применяется каждый?
ETL (Extract → Transform → Load):
Данные трансформируются до загрузки в хранилище
Логика преобразования — в пайплайне (Python, Spark)
Применяется при ограниченных ресурсах хранилища или строгих требованиях к качеству
Традиционный подход для DWH
ELT (Extract → Load → Transform):
Сырые данные грузятся как есть, трансформация — внутри хранилища (SQL)
Применяется в облачных DWH (BigQuery, Snowflake) с дешёвыми ресурсами
Инструменты: dbt, Dataform
Преимущества ELT:
Вся логика в SQL — понятно аналитикам
Исходные данные сохраняются (можно пересчитать)
Быстрее загрузка (нет предобработки)
Практика: современный стек → ELT с dbt поверх BigQuery/Snowflake.
Базовые вопросы
SQLЛегкийвопрос
Нормализация баз данных
Что такое нормализация базы данных? Объясни 1NF, 2NF, 3NF на простых примерах.
Нормализация — процесс структурирования БД для минимизации избыточности и аномалий.
1NF (Первая нормальная форма):
Все значения атомарны (нет списков в ячейке)
Есть первичный ключ
Нарушение: {"телефоны": "79001, 79002"} в одной ячейке
2NF (Вторая нормальная форма):
1NF + каждый не-ключевой атрибут зависит от всего ключа (не от его части)
Актуально при составных ключах
Нарушение: (order_id, product_id) → product_name — product_name зависит только от product_id, выносим в отдельную таблицу
3NF (Третья нормальная форма):
2NF + нет транзитивных зависимостей (A → B → C, где A — ключ)
Нарушение: employee_id → department_id → department_name. Выносим отдел в отдельную таблицу.
Практика: OLTP-системы нормализуют (меньше аномалий при записи). DWH денормализуют ради скорости чтения.
Базовые вопросы
СтатистикаСреднийвопрос
Precision и Recall
Объясни precision и recall. Когда важнее одно, а когда другое? Приведи примеры из data-аналитики.
Precision = TP / (TP + FP)
Из всего, что модель назвала положительным, какая доля действительно положительная?
→ «Насколько точны предсказания?»
Recall = TP / (TP + FN)
Из всего, что действительно положительное, какую долю модель нашла?
→ «Насколько полно находим?»
Спам-фильтр: лучше пропустить спам, чем потерять важное письмо
Рекомендации: плохая рекомендация раздражает
Когда важнее Recall:
Детектор рака: лучше ложная тревога, чем пропущенная болезнь
Fraud detection: лучше заблокировать честного, чем пропустить мошенника
Предсказание оттока: лучше предложить скидку лишнему пользователю
Баланс: F1 = 2 × (P × R) / (P + R)
Базовые вопросы
PythonСреднийвопрос
Обработка пропущенных значений
Какие есть подходы к обработке пропущенных значений? Как выбрать правильный?
Типы пропусков:
MCAR (Missing Completely At Random): пропуск случаен — можно удалять строки
MAR (Missing At Random): пропуск зависит от других переменных — нужна импутация
MNAR (Missing Not At Random): пропуск зависит от самого значения — сложнее всего
Подходы:
Удаление строк/столбцов — только при MCAR и малом % пропусков (< 5%)
Заполнение константой — медиана/среднее для числовых, мода для категориальных. Быстро, не учитывает контекст.
Заполнение по группе — медиана по сегменту (age группа, регион). Лучше, чем глобальная медиана.
KNN Imputation — находит похожие записи и берёт их значение
Model-based (MICE) — итерационная модельная импутация, лучший результат при MAR
Добавить флаг — бинарная переменная «был пропуск» — иногда само по себе ценный признак (MNAR)
Правило: сначала разобраться в причине пропуска, потом выбирать метод.
Базовые вопросы
КейсыСреднийвопрос
Метрики для оценки рекомендательной системы
Какие метрики используют для оценки качества рекомендательных систем? Объясни разницу между offline и online оценкой.
Offline метрики (на исторических данных):
Precision@K — доля релевантных среди первых K рекомендаций
Recall@K — доля всех релевантных элементов, попавших в топ-K
nDCG@K — учитывает позицию: релевантное выше → выше оценка
MAP (Mean Average Precision) — среднее AP по всем запросам
Coverage — какую долю каталога модель вообще рекомендует
Diversity/Serendipity — разнообразие и неожиданность рекомендаций
Online метрики (A/B тест):
CTR по рекомендациям
Конверсия: клик → покупка
Revenue через рекомендательный блок
Session depth (сколько страниц посетил после рекомендации)
Почему нельзя ограничиться offline:
Offline метрики не учитывают ответ пользователя в реальном контексте. Улучшение nDCG не всегда коррелирует с ростом CTR или выручки.
Базовые вопросы
КейсыСреднийвопрос
Как строить дашборд для стейкхолдеров
Продакт просит тебя сделать дашборд для еженедельного совещания. Как подойдёшь к его построению?
Принципы хорошего дашборда:
1. Прояснить аудиторию и цель:
Кто будет смотреть: CEO, продакт, маркетинг?
Какие решения на основе дашборда? (инвестировать в канал, остановить фичу)
2. Выбрать 3–7 ключевых метрик:
North Star + 2–3 leading indicators + guardrails
Меньше метрик → быстрее понимание
3. Структура дашборда:
Верх: итоговые метрики (KPI за период)
Середина: тренды (линейные графики с периодом сравнения)
Низ: breakdown (по сегментам, каналам, платформам)
Accuracy = 97% ничего не говорит. Модель может просто предсказывать «норма» для всех — получит 97% accuracy, не обнаружив ни одного мошенника.
Правильные метрики для несбалансированных задач:
1. Precision и Recall:
Recall (Sensitivity) для класса мошенников: доля реальных мошенников, пойманных моделью
Precision: из тех, кого назвали мошенниками, сколько действительно ими были
2. F1-score (гармоническое среднее P и R)
3. ROC-AUC:
Площадь под ROC-кривой. Не зависит от порога, хорошо при несбалансированных классах. Идеал = 1, random = 0.5.
4. PR-AUC (Precision-Recall AUC):
Лучше, чем ROC-AUC, когда один класс очень мал. ROC может вводить в заблуждение при большом дисбалансе.
5. Cohen's Kappa / MCC:
Согласие с учётом случайного угадывания.
Дополнительно:
Техники: oversampling (SMOTE), undersampling, class_weight в sklearn
Выбор порога классификации исходя из бизнес-стоимости ошибок
Базовые вопросы
ПоведенческиеЛегкийвопрос
Расскажи о себе
Расскажи о себе. (Классический открывающий вопрос HR-скрининга)
Структура ответа (1–2 минуты):
Текущая роль: «Сейчас я [должность] в [компания], где занимаюсь [основные задачи]»
Путь сюда: «До этого я [предыдущий опыт], что позволило мне [навык/результат]»
Специализация: «Особенно мне интересны [area] — работал с [инструменты/технологии]»
Зачем здесь: «Я смотрю на [эта компания], потому что [конкретная причина связанная с компанией]»
Советы:
Не пересказывай резюме дословно — дай контекст и цепочку
Говори о результатах, а не только об обязанностях
Заканчивай «переходом» на эту позицию — зачем ты здесь
Длина: 90–120 секунд
Чего не делать: не уходить в детали проектов (для этого будут другие вопросы), не рассказывать личную жизнь.
Базовые вопросы
ПоведенческиеЛегкийвопрос
Почему аналитика данных
Почему ты выбрал профессию аналитика данных? Что тебя в ней привлекает?
Что ищет интервьюер: осознанность выбора, мотивацию, долгосрочный интерес.
Структура хорошего ответа:
Откуда пришёл интерес: конкретный момент или опыт (учёба, проект, задача)
Что нравится в работе: анализ данных → решения → влияние на продукт/бизнес
Что продолжает мотивировать: постоянно новые задачи, стык бизнеса и технологий
Пример: «Во время учёбы я заметил, что команды принимают решения на основе интуиции. Хотелось работать там, где решения опираются на данные. Мне нравится переводить вопрос бизнеса в задачу, а данные — в рекомендацию. Особенно интересно, когда анализ меняет направление продукта».
Что ищет интервьюер: амбиции, осознанность карьерного пути, соответствие возможностям компании.
Структура ответа:
Честная карьерная цель (рост в экспертизе, переход в lead/head of analytics, или в продукт)
Связь с этой ролью и компанией — «именно здесь это возможно, потому что…»
Гибкость: «это моё направление, но я готов его уточнять по мере роста»
Примеры целей аналитика:
Стать Senior/Lead аналитиком с экспертизой в growth analytics
Перейти в Product Analytics / DS
Построить аналитическую команду или функцию
Советы:
Не говори «хочу работать в вашей компании» — это не ответ на вопрос
Не говори «пока не знаю» — это тревожный сигнал
Не говори «хочу вашу должность» (если интервьюер — твой будущий менеджер)
Будь конкретен, но не жёстко запрограммирован
Базовые вопросы
ПоведенческиеСреднийвопрос
Как работаешь с неопределёнными задачами
Расскажи, как ты работаешь, когда задача сформулирована нечётко или данных недостаточно?
Что ищет интервьюер: самостоятельность, структурность мышления, умение работать в условиях неопределённости.
Структура ответа (метод STAR):
Ситуация: задача пришла без чётких метрик и требований
Задача: понять, что реально нужно, и предложить подход
Действие: провёл Discovery — поговорил с продактом, нарисовал гипотезы, предложил MVP-анализ
Результат: согласовали метрику, анализ помог принять решение
Универсальный фреймворк:
Задать уточняющие вопросы: «Какое решение должен поддержать этот анализ?»
Зафиксировать допущения письменно
Предложить MVA (Minimum Viable Analysis) — быстрый first cut
Согласовать, нужно ли углубляться
Важно: не блокируйся на «нет данных». Скажи что знаешь, что неизвестно, и как принять решение при этой неопределённости.
Базовые вопросы
ПоведенческиеСреднийвопрос
Конфликт с коллегой
Расскажи о случае, когда у тебя возник конфликт с коллегой или членом команды. Как ты его разрешил?
Что ищет интервьюер: зрелость, умение слушать, способность решать конфликты конструктивно.
Структура STAR:
Ситуация: разошлись с разработчиком в оценке приоритетности трекинга
Задача: нужно было запустить аналитику к релизу
Действие: предложил отдельную встречу 1:1 (не в общем чате), объяснил бизнес-контекст, услышал его ограничения, предложили компромисс
Результат: запустили минимальный трекинг к дедлайну, полный — следующим спринтом
Принципы хорошего ответа:
Не говори «конфликтов у меня не бывает» — неправдиво
Покажи свою роль в решении, не только чужую ошибку
Не вини коллегу — фокус на процессе
Демонстрируй эмпатию: «я понял, что у него были другие приоритеты»
Базовые вопросы
ПоведенческиеСреднийвопрос
Задача, где данные не дали ответа
Приведи пример задачи, где данных было недостаточно, чтобы дать однозначный ответ. Что ты сделал?
Что ищет интервьюер: честность, зрелость, умение работать с неопределённостью, коммуникация с бизнесом.
Структура STAR:
Ситуация: попросили оценить влияние промо-акции на LTV. Данные были только за 2 недели, слишком мало для LTV-сигнала.
Задача: дать оценку к встрече с маркетингом.
Действие:
Честно обозначил ограничение: «LTV за 2 недели не измерить»
Предложил прокси: удержание за 7 дней и средний чек первой покупки — ранние индикаторы LTV
Показал scenario-анализ: «при росте retention на 5% долгосрочный эффект составит X, при падении — Y»
Результат: команда приняла решение с ясным пониманием неопределённости
Ключевой вывод для ответа: «Я понял, что моя задача — помочь принять решение при имеющихся данных, а не найти идеальный ответ».
Базовые вопросы
ПоведенческиеСреднийвопрос
Самый сложный аналитический проект
Расскажи о самом сложном проекте в твоей карьере. Что делало его сложным и как ты с этим справился?
Что ищет интервьюер: глубину опыта, подход к решению проблем, рефлексию.
Структура STAR:
Ситуация: Проект по атрибуции маркетинговых каналов. Данные из 5 источников с разным форматом, без единого user_id.
Задача: дать ответ на вопрос «какой канал приносит больше ценных пользователей».
Действие:
Провёл аудит данных — нашёл расхождения между источниками
Договорился с командой на единое определение «привлечение» и «ценный пользователь»
Построил identity resolution по email+device fingerprint
Использовал Shapley-атрибуцию вместо last-click
Результат: команда перераспределила бюджет, ROAS улучшился на 15%
Советы:
Будь конкретен: назови инструменты, метрики
Покажи, что именно ты делал — не «мы решили»
Упомяни что нового узнал из этого проекта
Базовые вопросы
ПоведенческиеСреднийвопрос
Расстановка приоритетов
Как ты расставляешь приоритеты, когда у тебя несколько задач с одинаково высоким приоритетом?
Что ищет интервьюер: самостоятельность, системность, умение коммуницировать о нагрузке.
Фреймворк ответа:
Прояснить приоритеты: «Когда всё горит — это сигнал к диалогу, а не к параллельной работе над всем сразу»
Оценить по критериям:
Срок: когда нужен результат?
Стейкхолдер: кто просит и насколько критично для бизнеса?
Сложность: что можно быстро сделать (quick win)?
Прозрачно коммуницировать: «Я могу сделать A к пятнице и B к следующей неделе — или другой порядок, если B важнее?»
Зафиксировать договорённости письменно
Пример: «Однажды от продакта, маркетинга и CEO пришли три "срочных" запроса одновременно. Я написал всем троим, что могу взять первым — и попросил каждого оценить срок. Договорились на порядок, который устроил всех».
Чего не делать: тихо тонуть в задачах или рандомно делать «самое первое».
Базовые вопросы
ПоведенческиеСложныйвопрос
Убеждение команды через данные
Опиши момент, когда тебе пришлось убеждать команду или менеджера принять решение на основе данных, несмотря на сопротивление.
Что ищет интервьюер: влияние, коммуникация, умение работать с resistance.
Структура STAR:
Ситуация: продакт хотел запустить фичу без A/B теста — «и так понятно, что улучшит». По историческим данным у меня были основания сомневаться.
Задача: убедить провести тест перед роллаутом.
Действие:
Показал исторические случаи «очевидных улучшений», которые не подтвердились (внутренние данные)
Предложил быстрый тест (2 недели, 10% аудитории) с минимальными затратами
Объяснил риск: «если запустим без теста и окажется хуже — откатывать труднее, чем не запускать»
Согласовал конкретный KPI для принятия решения
Результат: тест запустили. Метрика оказалась нейтральной. Команда стала скептичнее к «очевидным» идеям.
Ключевой тезис: «Я не отстаивал своё мнение — я снижал риск для команды».
Базовые вопросы
ПоведенческиеСложныйвопрос
Провальный проект: чему научил
Расскажи о проекте, который пошёл не по плану или провалился. Что ты из него вынес?
Что ищет интервьюер: честность, рефлексию, рост из ошибок.
Типичные «провалы» в аналитике:
Анализ привёл к неверному выводу из-за ошибки в данных
Дашборд не использовался, потому что не решал реальную задачу
A/B тест запустили неправильно, результаты невалидны
Структура STAR:
Ситуация: построил прогнозную модель оттока. На тесте — хорошие метрики. В проде — бесполезна.
Задача: понять почему и что делать дальше.
Действие: провёл post-mortem. Оказалось — обучающая выборка была несвежей (data leakage на уровне фичей). Не проверил distribution drift перед деплоем.
Результат: переобучил с правильной временной валидацией. Добавил мониторинг дрейфа. Написал checklist для будущих моделей.
Советы:
Не прячь провал за «обстоятельствами» — возьми личную ответственность за свою часть
Сфокусируйся на lesson learned, не на боли
Покажи, что ты изменил поведение после
WB
PythonСреднийзадача
Код-ревью: функция подсчёта элементов списка
Нужно написать функцию, которая проходит по списку и собирает для каждого элемента количество раз, которое он встречается. Если элемент входит в список `dont_collect`, то он не учитывается.
Задачу назначили джуну. Он сделал её и отправил тебе на ревью. Он утверждает, что проверил функцию — она отлично работает. Он также написал для неё тесты (они действительно проходят).
Проведи ревью его кода.
```python
def CollectStatisticsWODC(list, dont_collect=[2, 7, 14, 31]):
RESULT = dict()
for el in list:
skip = False
for ele in dont_collect:
if el == ele:
skip = True
if skip == False and el not in RESULT:
RESULT[el] = 1
elif skip == False and el in RESULT:
RESULT[el] = RESULT[el] + 1
return RESULT
if __name__ == '__main__':
list = [1, 1, 1, 2, '3', '3', 4, 7]
assert CollectStatisticsWODC(list) == {1: 3, '3': 2, 4: 1}
print("GOOD")
list = []
assert CollectStatisticsWODC(list) == {}
print("GOOD")
list = [2, 2, 2, 7, 14, 31]
assert CollectStatisticsWODC(list) == {}
print("GOOD")
print("ALL TESTS PASSED!")
```
Код рабочий, тесты проходят — но замечаний много. Хорошее ревью группирует их по важности.
1. Изменяемый аргумент по умолчанию — главная «мина»
dont_collect=[2, 7, 14, 31] — классическая ловушка Python: список по умолчанию создаётся один раз при определении функции и разделяется между всеми вызовами. Здесь его не мутируют, поэтому бага пока нет, но любая будущая правка вида dont_collect.append(...) сломает все последующие вызовы. Правильно: неизменяемый tuple/frozenset или None с созданием внутри функции.
2. Затенение встроенного типа list
Имя параметра list (и та же переменная в тестах) перекрывает встроенный тип: вызов list() в этой области видимости упадёт. Встроенные имена (list, dict, id, sum) нельзя использовать как имена переменных.
3. Сложность O(n·m) вместо O(n)
Для каждого элемента запускается внутренний цикл по dont_collect, причём без break — даже найдя совпадение, цикл крутится до конца. Достаточно преобразовать dont_collect в set один раз и проверять el in excluded за O(1).
4. Избыточная логика
if skip == False → идиоматично if not skip;
ветки if/elif с проверкой el in RESULT заменяются одной строкой RESULT[el] = RESULT.get(el, 0) + 1;
вся функция целиком — это collections.Counter с фильтрацией.
5. Именование (PEP 8)
CollectStatisticsWODC → snake_case (collect_statistics); аббревиатура WODC нечитаема. RESULT капсом выглядит как константа → result. Пара el/ele различима на глаз с трудом.
6. Тесты
Плюс, что они есть, но: голые assert в __main__ вместо pytest/unittest (первый упавший assert прячет остальные); нет граничных случаев — например, нехешируемый элемент ([[1], 2] даст TypeError) или ловушка True == 1: для [True, 1, 1.0] вернётся {True: 3}, потому что True, 1 и 1.0 равны и имеют одинаковый хеш — это стоит проговорить с интервьюером.
Улучшенный вариант:
from collections import Counter
DEFAULT_DONT_COLLECT = frozenset({2, 7, 14, 31})
defcollect_statistics(items, dont_collect=DEFAULT_DONT_COLLECT):
"""Считает вхождения элементов items, игнорируя элементы из dont_collect."""
excluded = set(dont_collect)
returndict(Counter(item for item in items if item notin excluded))
Как подать на ревью: сначала отметить, что задача решена и тесты написаны (это плюс джуну), затем объяснить пункты 1–3 как обязательные к исправлению, а 4–6 — как рекомендации по идиоматичности.
ННе определено
SQLЛегкийзадача
Пятёрки у учеников, у которых меньше 10 двоек
Дана таблица `journal` с колонками `fio` (ученик) и `mark` (оценка).
Посчитай количество пятёрок у каждого ученика, у которого меньше 10 двоек.
Задача на условную агрегацию и фильтрацию групп через HAVING.
SELECT fio,
COUNT(*) FILTER (WHERE mark =5) AS cnt_fives
FROM journal
GROUPBY fio
HAVINGCOUNT(*) FILTER (WHERE mark =2) <10;
Портируемый вариант без FILTER (MySQL и др.):
SELECT fio,
SUM(CASEWHEN mark =5THEN1ELSE0END) AS cnt_fives
FROM journal
GROUPBY fio
HAVINGSUM(CASEWHEN mark =2THEN1ELSE0END) <10;
Ключевые моменты, которые проверяет интервьюер:
Почему нельзя WHERE mark = 5? WHERE отфильтрует строки до группировки — двойки исчезнут, и условие «меньше 10 двоек» проверить будет не по чему. Обе агрегации должны считаться по полному набору строк ученика.
WHERE vs HAVING: WHERE фильтрует строки до агрегации, HAVING — группы после. Условие на агрегат (число двоек) может стоять только в HAVING.
Граничный случай: ученик без единой двойки проходит условие (0 < 10) и попадёт в результат — в том числе с cnt_fives = 0. Это корректно: «меньше 10 двоек» включает «нет двоек».
ННе определено
SQLСреднийзадача
LEFT JOIN с дубликатами и NULL: что вернёт запрос?
Есть две таблицы `t1` и `t2`. Что вернёт следующий запрос?
```sql
select *
from t1
left join t2 on t1.num1 = t2.num2
```
```
t1.num1: 1, 2, 2, 3, 3, 4, null, null
t2.num2: 1, 2, 2, 3, 5, null, null
```
Дубликаты перемножаются. Двойка встречается 2 раза в t1 и 2 раза в t2 — каждая левая строка находит обе правые: 2 × 2 = 4 строки с парой (2, 2). Это самый частый источник «размножения строк» после JOIN в реальных задачах.
NULL не джойнится с NULL. Условие null = null даёт не TRUE, а UNKNOWN — поэтому NULL-строки из t1 ни с чем не совпали. Но так как это LEFT JOIN, они остались в результате с num2 = null.
Непарные строки левой таблицы сохраняются: 4 не нашла пару → (4, null).
Непарные строки правой таблицы отбрасываются: 5 и оба NULL из t2 в результат не попали (для их сохранения нужен был бы FULL JOIN).
Тройка есть в t1 дважды, а в t2 один раз → 2 × 1 = 2 строки (3, 3).
Итого: 1 + 4 + 2 + 1 + 2 = 10 строк. Порядок строк без ORDER BY не гарантирован.
ННе определено
SQLСреднийзадача
Чётные и нечётные числа в два столбца
Есть таблица `t` с одним столбцом из чисел, упорядоченным по возрастанию. Нужно получить таблицу с двумя столбцами — нечётные и чётные числа из входной таблицы, каждый столбец по возрастанию. В общем случае количество чётных и нечётных не равно, т.е. могут потребоваться NULL.
Вход:
```
t.num
1
2
3
4
5
6
7
9
```
Ожидаемый результат:
```
odd | even
----+-----
1 | 2
3 | 4
5 | 6
7 | null
9 | null
```
Идея: разбить числа на две группы, в каждой пронумеровать строки через ROW_NUMBER() и соединить группы по этому номеру. Так i-е нечётное встаёт рядом с i-м чётным.
WITH odds AS (
SELECT num, ROW_NUMBER() OVER (ORDERBY num) AS rn
FROM t
WHERE num %2=1
),
evens AS (
SELECT num, ROW_NUMBER() OVER (ORDERBY num) AS rn
FROM t
WHERE num %2=0
)
SELECT o.num AS odd, e.num AS even
FROM odds o
FULLJOIN evens e ON o.rn = e.rn
ORDERBYCOALESCE(o.rn, e.rn);
Ключевые моменты:
Почему FULL JOIN, а не LEFT? В примере нечётных больше, и LEFT JOIN от odds сработал бы. Но в общем случае длиннее может оказаться любой из столбцов — FULL JOIN покрывает оба варианта, недостающие значения автоматически заполняются NULL. Именно про это оговорка в условии.
ORDER BY COALESCE(o.rn, e.rn) — у строк, где одна из сторон NULL, номер строки берём с той стороны, где он есть.
Если СУБД не поддерживает FULL JOIN (MySQL), эмулируем через LEFT JOIN ... UNION ... RIGHT JOIN либо объединяем номера через отдельный CTE с GREATEST от двух счётчиков.
ННе определено
SQLСреднийзадача
Сумма транзакций за предыдущий день и за всё время
Есть таблица с агрегатами — суммами транзакций клиентов за конкретный день.
```
client trans_date trans_sum
1 15.09.2020 232
1 16.09.2020 543
1 17.09.2020 123
1 18.09.2020 553
2 15.09.2020 234
2 16.09.2020 634
2 18.09.2020 234
2 19.09.2020 663
```
1. Напиши запрос, который для каждой строки покажет сумму транзакций клиента за предыдущий **календарный** день.
2. Напиши запрос, который подтянет к каждой строке сумму транзакций данного клиента за всё время.
Часть 1 — предыдущий календарный день.
Главная ловушка: напрашивающийся LAG(trans_sum) здесь неверен. LAG берёт предыдущую строку, а не предыдущий календарный день. У клиента 2 нет транзакций за 17.09 — для строки 18.09 LAG вернёт сумму за 16.09 (634), хотя правильный ответ NULL: за 17.09 транзакций не было.
Корректное решение — self join со сдвигом даты на день:
SELECT t.*,
p.trans_sum AS prev_day_sum
FROM transactions t
LEFTJOIN transactions p
ON p.client = t.client
AND p.trans_date = t.trans_date -INTERVAL'1 day';
Альтернатива одним окном (PostgreSQL): RANGE-рамка по датам вместо ROWS:
SELECT client, trans_date, trans_sum,
SUM(trans_sum) OVER (
PARTITIONBY client ORDERBY trans_date
RANGEBETWEENINTERVAL'1 day' PRECEDING ANDINTERVAL'1 day' PRECEDING
) AS prev_day_sum
FROM transactions;
Проверка на данных: у клиента 2 для 18.09 оба запроса дают NULL, для 19.09 — 234. LAG можно использовать, только если гарантировано, что пропусков дат нет.
Часть 2 — сумма за всё время.
Нужна оконная агрегация без ORDER BY — сумма по всей партиции клиента, при этом строки не схлопываются:
SELECT client, trans_date, trans_sum,
SUM(trans_sum) OVER (PARTITIONBY client) AS client_total
FROM transactions;
Для данных выше: у клиента 1 в каждой строке будет 1451, у клиента 2 — 1765. Вариант с GROUP BY + JOIN тоже валиден, но окно короче и читается лучше. Формулировка «подтянет к каждой строке» — подсказка, что нужна именно оконная функция, а не GROUP BY.
ННе определено
PythonЛегкийзадача
Отфильтровать простые числа из списка
Есть список `numbers = [1, 2, ..., 1000]`. Напиши функцию, которая оставит в нём только простые числа.
Базовое решение — проверка каждого числа на простоту делением до корня:
defget_primes(numbers):
defis_prime(n):
if n < 2:
returnFalseif n % 2 == 0:
return n == 2
d = 3while d * d <= n:
if n % d == 0:
returnFalse
d += 2returnTruereturn [n for n in numbers if is_prime(n)]
Ключевые моменты, которые проверяют:
1 — не простое число (и всё, что меньше 2). Частая ошибка.
Делители достаточно проверять до √n: если n = a·b и a ≤ b, то a ≤ √n. Отсюда d * d <= n вместо перебора до n — O(√n) вместо O(n) на число.
Чётные отсекаются сразу, дальше шаг 2 — вдвое меньше проверок.
Оптимальное решение для плотного диапазона — решето Эратосфена, O(n log log n) на весь диапазон:
defsieve(limit):
is_prime = [True] * (limit + 1)
is_prime[0] = is_prime[1] = Falsefor i inrange(2, int(limit ** 0.5) + 1):
if is_prime[i]:
is_prime[i * i :: i] = [False] * len(is_prime[i * i :: i])
return [i for i, p inenumerate(is_prime) if p]
primes = sieve(1000)
Решето выгодно, когда нужны все простые из сплошного диапазона (наш случай: список 1..1000); поштучная проверка — когда чисел немного или они разрозненные. Для контроля: простых чисел до 1000 ровно 168 (2, 3, 5, …, 997).
ННе определено
СтатистикаСреднийзадача
Вероятность выкинуть все 6 граней за 6 бросков
Кубик кидается 6 раз подряд. Какова вероятность, что за эти 6 бросков будут выкинуты все 6 значений?
Всего равновероятных исходов — 6⁶ (каждый бросок независимо даёт одно из 6 значений). Благоприятные исходы — все перестановки шести разных значений по шести броскам, их 6!.
P = 6! / 6⁶ = 720 / 46656 = 5/324 ≈ 0,0154 ≈ 1,5%
Тот же результат через последовательное умножение (часто так объяснить проще):
1-й бросок: подходит любое значение — вероятность 6/6 = 1
Интуиция, почему так мало: первые броски почти наверняка дают новые значения, но последние обязаны попасть в единственную недостающую грань — например, шестой бросок «угадывает» нужное значение лишь с вероятностью 1/6.
Задача — частный случай задачи о коллекционере купонов: в среднем, чтобы собрать все 6 граней, нужно 6·(1 + 1/2 + … + 1/6) = 14,7 броска, поэтому уложиться ровно в 6 удаётся редко.
ННе определено
СтатистикаСреднийзадача
Перебрасывать ли кубик? Оптимальная стратегия
Бросили кубик. Стоит ли перекидывать, если хотим получить бОльшее число? А если есть возможность перебросить два раза — какая стратегия оптимальна? (задача на матожидание)
Принцип один: перебрасываем, если текущее значение меньше матожидания того, что получим дальше. Считаем с конца (обратная индукция).
Один переброс.
Матожидание одного броска: E₀ = (1+2+3+4+5+6)/6 = 3,5.
Выпало x. Перебросив, получим в среднем 3,5 — значит, перебрасываем при x < 3,5, то есть при 1, 2, 3; оставляем 4, 5, 6.
Матожидание такой стратегии (с вероятностью 1/2 выпало 4–6 со средним 5, иначе перебрасываем):
E₁ = (1/2) · 5 + (1/2) · 3,5 = 4,25
Два переброса.
Рассуждаем с конца. После первого переброса мы в ситуации «остался один переброс», цена которой уже посчитана: E₁ = 4,25. Значит, на первом броске оставляем только значения больше 4,25 — то есть 5 и 6; на 1–4 перебрасываем. На втором броске порог снова 3,5: оставляем 4–6, иначе используем последний переброс.
E₂ = (2/6) · 5,5 + (4/6) · 4,25 = 14/3 ≈ 4,67
Итоговая стратегия: первый бросок — оставляем 5–6; второй — оставляем 4–6; третий — берём что выпало.
Пороги растут с числом оставшихся попыток (3,5 → 4,25 → 4,67…): чем больше попыток в запасе, тем привередливее надо быть. Важная деталь для собеседования: сравнивать текущий результат нужно не с матожиданием одного броска, а с ценностью всей оставшейся игры — это и есть суть обратной индукции (тот же принцип, что в задаче о секретаре и в оценке американских опционов).
Avito
КейсыЛегкийзадача
Две бригады: за сколько дней вторая закончит работу одна
Первая бригада может выполнить весь заказ за 6 дней, а вторая — за 9 дней. Первые 2 дня они работали вместе, после чего первая бригада уехала на другой объект. За сколько дней вторая бригада закончит оставшуюся работу одна?
Варианты ответа: 2 · 3 · 3,5 · 4
Правильный ответ: 4 дня.
Классический приём: принимаем весь объём работы за 1 и переходим к производительностям (доля работы в день).
Первая бригада: 1/6 работы в день
Вторая бригада: 1/9 работы в день
Вместе: 1/6 + 1/9 = 3/18 + 2/18 = 5/18 в день
За 2 дня совместной работы сделано: 2 · 5/18 = 5/9.
Осталось: 1 − 5/9 = 4/9.
Вторая бригада делает 1/9 в день, значит ей нужно:
(4/9) ÷ (1/9) = 4 дня
На что смотрит проверяющий: умение работать с производительностями вместо «дней». Частая ошибка — усреднять сроки (6 и 9 → 7,5) или складывать дни; складывать можно только скорости работы. Тот же приём используется в задачах про пропускную способность пайплайнов и время обработки очереди.
Avito
СтатистикаСреднийзадача
Первый работающий сервер окажется вторым по счёту
Есть 3 сервера, каждый работает с вероятностью 0.8. Их запускают в случайном порядке по одному. Найти вероятность того, что первый работающий сервер окажется вторым в порядке запуска.
Варианты ответа: 0,08 · 0,125 · 0,128 · 0,16
Правильный ответ: 0,16.
Событие «первый работающий сервер оказался вторым по счёту» означает ровно две вещи:
первый запущенный сервер не работает — вероятность 1 − 0,8 = 0,2;
второй запущенный сервер работает — вероятность 0,8.
Состояние третьего сервера уже не важно: он в событии не участвует.
P = 0,2 · 0,8 = 0,16
Почему случайный порядок запуска не влияет: все серверы одинаковы (у каждого одна и та же вероятность 0,8) и независимы, поэтому перестановка не меняет вероятность — на любой позиции сервер работает с вероятностью 0,8. Упоминание случайного порядка в условии — отвлекающая деталь.
Разбор ловушек в вариантах:
0,128 = 0,8³ — вероятность, что работают все три; ответ на другой вопрос.
0,08 — типичная ошибка 0,2 · 0,8 / 2 или путаница с делением на число позиций.
0,125 = 1/8 — «наивная» вероятность попасть на конкретную позицию, игнорирующая p = 0,8.
Avito
СтатистикаЛегкийзадача
Пользователь зарегистрируется, но не подпишется на рассылку
Вероятность того, что пользователь зарегистрируется, равна 0,5. Вероятность того, что пользователь подпишется на рассылку, равна 0,2. Считаем события независимыми. Какова вероятность, что пользователь зарегистрируется, но не подпишется на рассылку?
Варианты ответа: 0,3 · 0,4 · 0,5 · 0,6
Правильный ответ: 0,4.
Для независимых событий вероятность их совместного наступления — произведение вероятностей. Нужное событие: «зарегистрировался» И «не подписался».
P(регистрация) = 0,5
P(не подписался) = 1 − 0,2 = 0,8
P = 0,5 · 0,8 = 0,4
Ключевые моменты:
Переход к дополнению: «не подпишется» = 1 − P(подпишется). Это первый шаг во всех задачах со словом «не».
Независимость — обязательное условие для умножения. В реальности регистрация и подписка на рассылку почти наверняка зависимы (подписаться может только зарегистрированный), поэтому условие явно проговаривает независимость: без неё понадобилась бы условная вероятность P(A ∩ B) = P(A) · P(B|A).
Ошибка, дающая 0,3: вычитание 0,5 − 0,2 вместо умножения.
Avito
СтатистикаЛегкийзадача
Вероятность, что все 3 прибора не пройдут проверку
На заводе каждый изготовленный прибор проходит автоматическую проверку, которая срабатывает независимо для каждого прибора. Вероятность того, что прибор проходит проверку, равна 0.8. Проверяют 3 прибора. Какова вероятность того, что все 3 прибора НЕ пройдут проверку?
Варианты ответа: 0,008 · 0,125 · 0,488 · 0,6
Правильный ответ: 0,008.
Вероятность, что один прибор не пройдёт проверку: 1 − 0,8 = 0,2. Проверки независимы, значит вероятности перемножаются:
P = 0,2 · 0,2 · 0,2 = 0,2³ = 0,008
Разбор ловушек в вариантах:
0,488 — это P(хотя бы один не пройдёт) = 1 − 0,8³ = 1 − 0,512. Классическая подмена «все» на «хотя бы один»: читайте условие внимательно, эти два вопроса требуют разных вычислений.
0,6 = 3 · 0,2 — сложение вместо умножения. Складывать вероятности можно только для несовместных событий, а здесь события совместны и независимы.
0,125 = 0,5³ — расчёт с неверной вероятностью.
Задача — частный случай биномиального распределения: X ~ Bin(3; 0,2), где X — число непрошедших приборов, и нам нужна P(X = 3).
Avito
СтатистикаСреднийзадача
Две карточки А не окажутся соседними за круглым столом
На круглом столе случайно раскладывают 5 карточек: 2 с буквой А и 3 с буквой В. Какова вероятность того, что две карточки с буквой А не окажутся соседними? Первая и последняя карточки считаются соседними.
Варианты ответа: 0,5 · 0,6 · 0,7 · 0,8
Правильный ответ: 0,5.
Задача сводится к выбору двух позиций из пяти для карточек А — расположение карточек В определяется автоматически.
Всего способов: C(5,2) = 10 — столько пар позиций можно выбрать на круге из 5 мест.
Неблагоприятных (А рядом): на круге из 5 позиций ровно 5 пар соседних мест — (1,2), (2,3), (3,4), (4,5), (5,1). Здесь важна оговорка условия: пара (5,1) тоже соседняя, потому что стол круглый.
Благоприятных: 10 − 5 = 5.
P = 5/10 = 0,5
Ключевой момент: на круге из n позиций число соседних пар равно n (а не n − 1, как в ряду). Если бы карточки лежали в ряд, ответ был бы другим: соседних пар 4, и P = (10 − 4)/10 = 0,6 — именно поэтому 0,6 стоит среди вариантов как ловушка для тех, кто не заметил слово «круглый».
Проверка перебором всех 10 расстановок мультимножества AABBB по кругу подтверждает: ровно в 5 случаях буквы А не соседствуют.
Avito
СтатистикаЛегкийзадача
Сколькими способами выбрать барабанщика и клавишника из 7 человек
В музыкальной группе есть 7 участников. Нужно выбрать барабанщика и клавишника так, чтобы один человек не мог занимать две роли одновременно. Сколькими способами можно распределить роли?
Варианты ответа: 21 · 28 · 35 · 42
Правильный ответ: 42.
Роли различимы (барабанщик и клавишник — не одно и то же), поэтому порядок выбора важен и считаем размещения, а не сочетания:
барабанщика выбираем из 7 человек — 7 способов;
клавишника из оставшихся 6 — 6 способов.
7 · 6 = 42
Формально это число размещений: A(7,2) = 7! / (7−2)! = 42.
Главная ловушка — вариант 21: это C(7,2) = 21, число способов выбрать двух человек без учёта ролей. Ответ верен для вопроса «сколькими способами выбрать двоих участников», но здесь Иванов-барабанщик + Петров-клавишник и Петров-барабанщик + Иванов-клавишник — это разные распределения, поэтому 21 нужно умножить на 2! = 2.
Как отличать на собеседовании: если перестановка выбранных элементов даёт новый исход (роли, места, должности) — размещения A(n,k); если исход не меняется (просто «выбрать группу») — сочетания C(n,k).
Avito
СтатистикаЛегкийзадача
Сколько трёхзначных кодов без повторов не начинаются с нуля
Сколько различных трёхзначных кодов можно составить, используя цифры от 0 до 9, если цифры в коде не повторяются и код не может начинаться с 0?
Варианты ответа: 576 · 648 · 720 · 810
Правильный ответ: 648.
Считаем позиции по очереди, начиная с самой ограниченной — первой:
1-я цифра: любая, кроме 0 → 9 вариантов;
2-я цифра: любая из 10, кроме уже использованной первой (0 здесь снова разрешён) → 9 вариантов;
3-я цифра: любая из 10, кроме двух использованных → 8 вариантов.
9 · 9 · 8 = 648
Ключевой момент: начинать нужно с позиции, на которую наложено ограничение. Если считать слева направо «10 · 9 · 8 = 720, потом вычесть», то придётся отдельно считать коды, начинающиеся с нуля: их 1 · 9 · 8 = 72, и 720 − 72 = 648 — тот же ответ вторым способом.
Разбор ловушек:
720 = A(10,3) — все коды без повторов, включая начинающиеся с 0.
810 = 9 · 9 · 10 — забыли запрет повтора в третьей позиции.
576 = 9 · 8 · 8 — лишний раз исключили 0 из второй позиции, хотя там он уже разрешён.
Перебор всех перестановок из 10 цифр по 3 с фильтром «первая ≠ 0» даёт ровно 648.
Avito
СтатистикаЛегкийзадача
Математическое ожидание по эмпирическому распределению
По наблюдениям за 50 часов составили эмпирическое распределение случайной величины X — числа онлайн-заказов за час. Найдите математическое ожидание X.
| X | Частота |
|---|---------|
| 0 | 5 |
| 1 | 20 |
| 2 | 15 |
| 3 | 10 |
| **Итого** | **50** |
Варианты ответа: 1,4 · 1,6 · 1,8 · 2,0
Правильный ответ: 1,6.
Матожидание по эмпирическому распределению — это среднее взвешенное значений по частотам:
E(X) = Σ xᵢ · nᵢ / N
Считаем числитель:
0 · 5 = 0
1 · 20 = 20
2 · 15 = 30
3 · 10 = 30
Сумма: 0 + 20 + 30 + 30 = 80
E(X) = 80 / 50 = 1,6
Ключевые моменты:
Делить нужно на 50 (сумму частот), а не на 4 (число различных значений). Ошибка «среднее по столбцу X» даёт (0+1+2+3)/4 = 1,5 — распределение при этом игнорируется полностью.
Эквивалентная запись — через относительные частоты: E(X) = 0·0,1 + 1·0,4 + 2·0,3 + 3·0,2 = 1,6. Полезно помнить обе формы: вторая напрямую соответствует определению матожидания через вероятности.
Значение 1,6 лежит между 1 и 2 и ближе к 1 — это согласуется с тем, что самое частое значение равно 1. Быстрая проверка на разумность помогает отсечь варианты вроде 2,0.
Avito
СтатистикаСреднийзадача
E(X) и D(X) для числа сундуков с редким предметом
В игре персонаж открывает 12 сундуков. В каждом сундуке независимо от других с вероятностью 0.2 находится редкий предмет. Пусть X — число сундуков, в которых оказался редкий предмет. Найдите математическое ожидание и дисперсию X.
Варианты ответа:
- E(X) = 2,4; D(X) = 1,92
- E(X) = 2,4; D(X) = 2,4
- E(X) = 9,6; D(X) = 1,92
- E(X) = 12; D(X) = 0,2
Правильный ответ: E(X) = 2,4; D(X) = 1,92.
Распознаём схему Бернулли: 12 независимых испытаний, в каждом «успех» с одинаковой вероятностью p = 0,2. Значит X ~ Bin(n = 12; p = 0,2), и работают стандартные формулы:
E(X) = n · p = 12 · 0,2 = 2,4
D(X) = n · p · (1 − p) = 12 · 0,2 · 0,8 = 1,92
Признаки биномиального распределения (стоит проговорить вслух на собеседовании): фиксированное число испытаний, два исхода, постоянная вероятность успеха, независимость. Все четыре в условии есть.
Разбор ловушек:
D(X) = 2,4 — путаница с распределением Пуассона, где дисперсия равна матожиданию. Для биномиального D(X) всегда меньше E(X), поскольку домножается на (1 − p) < 1. Это хороший быстрый фильтр.
E(X) = 9,6 = 12 · 0,8 — посчитали ожидаемое число сундуков без предмета.
95% доверительный интервал для среднего времени сборки
В интернет-магазине измерили время сборки 100 заказов. Среднее время сборки по выборке составило 30 минут. Известно, что стандартное отклонение времени сборки в генеральной совокупности равно 15 минут. Определите приближённый 95% доверительный интервал для среднего времени сборки заказа. Используйте z ≈ 2.
Варианты ответа: [28; 31] · [15; 45] · [27; 33] · [24; 36]
Правильный ответ: [27; 33].
Доверительный интервал для среднего при известной σ:
x̄ ± z · σ/√n
Считаем стандартную ошибку среднего:
SE = σ/√n = 15/√100 = 15/10 = 1,5
Половина ширины интервала: z · SE = 2 · 1,5 = 3.
Интервал: 30 ± 3 = [27; 33]
Ключевые моменты:
Главная ошибка — использовать σ вместо SE. Вариант [15; 45] получается как 30 ± 2·15: человек забыл поделить на √n. Разброс отдельных заказов (σ = 15) и точность оценки их среднего (SE = 1,5) — принципиально разные величины: чем больше выборка, тем точнее среднее, хотя разброс самих значений не меняется.
√n в знаменателе даёт «закон квадратного корня»: чтобы сузить интервал вдвое, выборку нужно увеличить в 4 раза. Это же соображение лежит в основе расчёта размера выборки для A/B тестов.
Здесь используется z, а не t, потому что σ известна из генеральной совокупности (и n = 100 велико). При неизвестной σ и малой выборке брали бы t-распределение.
Корректная интерпретация: если многократно повторять эксперимент и каждый раз строить такой интервал, примерно 95% интервалов накроют истинное среднее. Неверно говорить «истинное среднее лежит в [27; 33] с вероятностью 95%» — истинное среднее не случайно, случаен интервал.
Avito
СтатистикаСреднийзадача
Проверка доли: отклонить ли утверждение сервиса про 5% ошибок
Онлайн-сервис заявляет, что доля пользователей, столкнувшихся с ошибкой входа, не превышает 5%. Нужно проверить это против предположения, что доля таких пользователей больше 5%. В выборке из 400 пользователей 24 столкнулись с ошибкой входа. При уровне значимости 0.05 критическое значение равно примерно 1.64. Нужно ли отклонить утверждение сервиса?
Варианты ответа:
- Да, потому что 24 пользователя — это много
- Нет, потому что z меньше критического значения
- Да, потому что 6% больше 5%
- Нет, потому что α равно 0.05
Правильный ответ: «Нет, потому что z меньше критического значения».
Вывод: 0,92 < 1,64, статистика не попала в критическую область — оснований отвергнуть H₀ нет. Наблюдаемое превышение (6% против 5%) укладывается в случайную изменчивость выборки такого размера.
Почему остальные варианты неверны:
«Да, потому что 6% больше 5%» — самая частая ошибка: сравнение точечных оценок без учёта случайности. Именно для этого и нужен статистический тест: при n = 400 разница в один процентный пункт статистически неразличима.
«Да, потому что 24 пользователя — это много» — абсолютное число само по себе ни о чём не говорит без базы сравнения.
«Нет, потому что α равно 0.05» — значение α задаёт порог, но само по себе не является основанием для вывода.
Важная формулировка: мы не доказали, что доля равна 5% или меньше — мы лишь не нашли достаточных доказательств обратного. «Не отвергли H₀» ≠ «H₀ верна».
Avito
СтатистикаЛегкийвопрос
Ошибка I рода на примере клинического исследования
Клиника проверяет новый препарат. Нулевая гипотеза: препарат не снижает давление лучше обычного лечения. Если результаты пациентов достаточно хорошие, нулевую гипотезу отвергают и признают препарат более эффективным. Какое утверждение описывает ошибку I рода?
Варианты ответа:
- Препарат признали эффективным, хотя он не лучше обычного лечения
- Препарат признали неэффективным, хотя он действительно лучше
- Давление пациентов измерили слишком поздно
- В исследовании участвовало слишком мало пациентов
Правильный ответ: «Препарат признали эффективным, хотя он не лучше обычного лечения».
Ошибка I рода — отвержение верной нулевой гипотезы, то есть ложная тревога. Здесь H₀ гласит «препарат не лучше обычного лечения». Отвергнуть её, когда она на самом деле верна, — значит объявить препарат работающим, хотя он не работает.
Ошибка II рода — обратная ситуация: не отвергли H₀, хотя она ложна. Это второй вариант: препарат действительно лучше, но исследование этого не обнаружило.
H₀ верна (препарат не работает)
H₀ ложна (препарат работает)
Отвергли H₀
Ошибка I рода (α)
Верное решение (мощность)
Не отвергли H₀
Верное решение
Ошибка II рода (β)
Как не путать:
Ошибка I рода — «поверили в эффект, которого нет». Её вероятность мы задаём сами: это уровень значимости α (обычно 0,05).
Ошибка II рода — «пропустили реальный эффект». Её вероятность β зависит от размера эффекта и размера выборки; 1 − β — мощность теста.
Почему это важно: цена ошибок несимметрична и зависит от контекста. В медицине ошибка I рода означает вывод неработающего препарата на рынок — поэтому α берут жёстким. В A/B тестировании ошибка I рода — раскатка изменения, которое ничего не даёт (потраченные ресурсы), ошибка II рода — отказ от реально полезной фичи.
Два последних варианта описывают методологические проблемы дизайна исследования, а не статистические ошибки решения.
Avito
A/B тестыСреднийвопрос
Менеджер: «p-value меньше порога — можно катить на всех»
Маркетплейс тестирует новый алгоритм сортировки товаров в поиске. Менеджер смотрит на результат эксперимента и говорит: «p-value получилось меньше выбранного порога, значит можно сразу выкатывать алгоритм на всех пользователей». Выберите все верные утверждения.
Варианты ответа (несколько верных):
- Менеджер прав только в части статистической значимости результата
- Перед решением о запуске нужно посмотреть размер эффекта и возможные побочные метрики
- Если p-value маленькое, бизнес-эффект автоматически считается большим
- Малое p-value означает, что вероятность ошибки в решении равна нулю
Верные утверждения — первое и второе.
1. «Менеджер прав только в части статистической значимости» — верно. p-value < α действительно означает, что наблюдаемые различия трудно объяснить случайностью. Но статистическая значимость отвечает только на вопрос «есть ли эффект», и ничего не говорит о том, насколько он велик и стоит ли он внедрения.
2. «Нужно посмотреть размер эффекта и побочные метрики» — верно. Это и есть правильный чек-лист перед раскаткой:
Размер эффекта и его доверительный интервал. На большой выборке статистически значимым становится и рост конверсии на 0,01% — значимо, но бизнесу бесполезно.
Побочные метрики (guardrail-метрики). Новая сортировка может поднять CTR, но уронить выручку, среднюю глубину просмотра или скорость выдачи. Рост одной метрики за счёт другой — обычная история.
Плюс проверки качества самого теста: корректность сплитования, SRM (sample ratio mismatch), достаточная длительность (недельная сезонность), отсутствие подглядывания и множественных сравнений без поправки.
3. «Маленькое p-value → большой бизнес-эффект» — неверно. p-value зависит не только от размера эффекта, но и от размера выборки и дисперсии. Достаточно увеличить выборку — и сколь угодно малый эффект даст сколь угодно малое p-value.
4. «Малое p-value → вероятность ошибки равна нулю» — неверно. Вероятность ошибки I рода не исчезает, она контролируется на уровне α (обычно 5%). p-value — это вероятность увидеть такие или более экстремальные данные при условии, что H₀ верна, а не вероятность того, что решение ошибочно.
Avito
A/B тестыЛегкийвопрос
Интерпретация p-value = 0.04 при α = 0.05
В онлайн-магазине тестируют новый дизайн страницы товара. Цель — понять, увеличивает ли он конверсию в покупку. По результатам A/B-теста получили p-value = 0.04 при уровне значимости α = 0.05. Выберите все верные утверждения.
Варианты ответа (несколько верных):
- Результат статистически значим, потому что p-value меньше α
- Нулевую гипотезу об отсутствии различий следует отвергнуть
- Результат не является статистически значимым, так как p-value меньше α
- p-value = 0.04 означает, что вероятность отсутствия эффекта от нового дизайна равна 4%
Верные утверждения — первое и второе.
Правило принятия решения простое: p-value < α → отвергаем H₀. Здесь 0,04 < 0,05, значит результат статистически значим и нулевую гипотезу об отсутствии различий отвергаем. Первые два утверждения — это одна и та же мысль, выраженная в разных терминах, поэтому верны оба.
Третье утверждение содержит прямое логическое противоречие: «не значим, так как p-value меньше α». Отношение перевёрнуто — незначимым результат был бы при p-value > α.
Четвёртое утверждение — классическое заблуждение о p-value. Оно приписывает p-value смысл «вероятность того, что эффекта нет», то есть P(H₀ | данные). На самом деле p-value — это P(данные такие же или более экстремальные | H₀ верна): вероятность условная относительно верности H₀, а не вероятность самой гипотезы. Поменять местами условие и событие нельзя — это подмена P(A|B) на P(B|A).
Что p-value не означает (короткий список для собеседования):
не вероятность того, что H₀ верна;
не вероятность того, что решение ошибочно;
не размер эффекта: p = 0,04 не значит, что эффект вдвое сильнее, чем при p = 0,08;
не вероятность, что результат воспроизведётся в повторном тесте.
Практическая ремарка: p-value = 0,04 при α = 0,05 — пограничный результат. Формально мы отвергаем H₀, но перед раскаткой стоит посмотреть доверительный интервал эффекта: если он почти касается нуля, эффект слабый и решение о запуске принимается уже по бизнес-соображениям.
Avito
A/B тестыСреднийвопрос
Баннер запустили на всех: какая ошибка могла произойти
Нулевая гипотеза: новый баннер не повышает кликабельность. По результатам A/B-теста команда решила, что новый баннер статистически значимо повышает кликабельность, и запустила его для всех пользователей. Что произошло?
Варианты ответа:
- Произошла ошибка I рода
- Произошла ошибка II рода
- Произошла ошибка в формулировке нулевой гипотезы
- Не хватает данных
Ответ, который ожидает тест: «Произошла ошибка I рода».
Логика такая: команда отвергла нулевую гипотезу. Единственная ошибка, возможная при отвержении H₀, — это ошибка I рода (ложноположительный результат): решили, что эффект есть, хотя на самом деле баннер кликабельность не повышает. Ошибка II рода в этом сценарии невозможна в принципе, потому что она означает противоположное решение — «не отвергли H₀, хотя эффект был».
Как это запомнить:
Решение команды
Возможная ошибка
Отвергли H₀ («эффект есть»)
Только ошибка I рода
Не отвергли H₀ («эффекта нет»)
Только ошибка II рода
Честная оговорка, которую стоит проговорить на собеседовании. Строго говоря, по условию мы не знаем, работает баннер на самом деле или нет. Если баннер действительно повышает CTR, то никакой ошибки не произошло — решение верное. Поэтому корректная формулировка звучит так: «Команда отвергла H₀, и риск, которому она при этом подвергается, — это ошибка I рода с вероятностью α». Если проверяющий готов к диалогу, такое уточнение — сильный ответ; если нужно просто выбрать вариант, выбирается «ошибка I рода».
Почему остальные варианты неверны: формулировка H₀ здесь корректна (нулевая гипотеза всегда описывает отсутствие эффекта — это статус-кво, который мы пытаемся опровергнуть), а данных для ответа на поставленный вопрос достаточно.
Avito
СтатистикаСреднийзадача
Определите FN и Recall по результатам модели оттока
Маркетплейс использует модель, которая предсказывает, уйдёт ли продавец с платформы в следующем месяце. Положительный класс — «продавец уйдёт». На тестовой выборке из 200 продавцов модель дала такие результаты:
- модель предсказала «уйдёт» для 50 продавцов, из них действительно ушли 30;
- модель предсказала «не уйдёт» для 150 продавцов, из них действительно ушли 20.
Определите FN и Recall.
Варианты ответа:
- FN = 20; Recall = 0,4
- FN = 20; Recall = 0,6
- FN = 30; Recall = 0,6
- FN = 130; Recall = 0,8
Правильный ответ: FN = 20; Recall = 0,6.
Первым делом восстанавливаем матрицу ошибок. Положительный класс — «уйдёт».
Предсказали «уйдёт» (50): из них ушли 30 → TP = 30, остальные 20 не ушли → FP = 20
Предсказали «не уйдёт» (150): из них ушли 20 → FN = 20 (пропущенные уходы), остальные 130 не ушли → TN = 130
Факт: ушёл
Факт: остался
Предсказали «уйдёт»
TP = 30
FP = 20
Предсказали «не уйдёт»
FN = 20
TN = 130
Проверка: 30 + 20 + 20 + 130 = 200 ✓
Recall (полнота) — какую долю реально ушедших модель поймала:
FN — это ушедшие, которых модель не распознала, то есть 20 продавцов из группы «предсказали не уйдёт». Ошибка в варианте с FN = 30 — путаница TP и FN; FN = 130 — это TN.
Знаменатель Recall — все фактически положительные (30 + 20 = 50 реально ушедших), а не все предсказанные положительными. Вариант Recall = 0,4 получается при делении на неверное основание.
Здесь Precision = TP/(TP+FP) = 30/50 = 0,6 численно совпал с Recall — совпадение из-за FP = FN, в общем случае они различаются. Полезно посчитать обе метрики и проговорить разницу: Recall отвечает «скольких ушедших мы нашли», Precision — «насколько можно доверять сигналу модели».
Accuracy здесь = (30 + 130)/200 = 0,8, что выглядит прилично, хотя модель пропускает 40% оттока — типичный пример, почему accuracy обманчива на несбалансированных классах.
Avito
СтатистикаЛегкийвопрос
Какую метрику контролировать при ручной проверке заявок
Команда обучила модель, которая автоматически помечает заявки на кредит как подозрительные и отправляет их на ручную проверку. Проверка каждой заявки занимает время сотрудников, поэтому важно, чтобы среди заявок, которые модель пометила как подозрительные, действительно было как можно больше мошеннических. Какую метрику в первую очередь стоит контролировать?
Варианты ответа: Precision · Recall · Accuracy · MSE
Правильный ответ: Precision.
Ключ к ответу — в формулировке «среди заявок, которые модель пометила как подозрительные, действительно было как можно больше мошеннических». Это дословное определение точности:
Precision = TP / (TP + FP)
Знаменатель — все заявки, помеченные моделью. Каждый false positive здесь — это впустую потраченное время сотрудника на проверку честной заявки, а именно на стоимость ручной проверки указывает условие.
Как быстро выбирать между Precision и Recall:
Вопрос звучит «из того, что модель пометила, сколько верно?» → Precision;
Вопрос звучит «из того, что реально есть, сколько модель нашла?» → Recall.
Почему не остальные:
Recall отвечал бы на вопрос «какую долю всех мошенников мы поймали». Это важная метрика в антифроде, но в этой постановке она вторична: условие явно оптимизирует нагрузку на проверяющих, а не полноту отлова.
Accuracy бесполезна при сильном дисбалансе классов: если мошенников 1%, модель «всё честное» даст 99% accuracy, не поймав ни одного мошенника.
MSE — метрика регрессии, для задачи бинарной классификации не подходит.
Что добавить, чтобы ответ был сильнее: на практике Precision и Recall балансируют порогом классификации, а компромисс оценивают по PR-кривой или F1. Правильный порог выбирают из экономики: сравнивают стоимость ручной проверки (цена FP) с потерями от пропущенного мошенничества (цена FN). Если ресурс проверяющих ограничен, часто фиксируют посильный объём заявок в день и максимизируют Precision при этом ограничении (метрика Precision@k).
Avito
SQLЛегкийвопрос
Вывести всех клиентов и их заказы, включая клиентов без заказов
Нужно вывести всех клиентов и их заказы. Клиенты без заказов тоже должны остаться. Выберите один верный вариант ответа.
Фрагмент данных:
```
customers orders
customer_id | name order_id | customer_id | amount
1 | Анна 10 | 1 | 1200
2 | Борис 11 | 1 | 800
3 | Вера 12 | 2 | 3000
```
Варианты ответа:
- `customers LEFT JOIN orders ON customer_id`
- `customers INNER JOIN orders ON customer_id`
- `orders LEFT JOIN customers ON customer_id`
- `customers JOIN orders ON order_id`
Правильный ответ: customers LEFT JOIN orders ON customer_id.
LEFT JOIN сохраняет все строки левой таблицы независимо от наличия совпадений справа. Раз требуется оставить всех клиентов, включая тех, у кого заказов нет, — слева должна стоять таблица customers.
Результат на приведённых данных:
name
order_id
amount
Анна
10
1200
Анна
11
800
Борис
12
3000
Вера
NULL
NULL
Вера остаётся в выборке с NULL в полях заказа — это и есть требуемое поведение. Анна встречается дважды: у неё два заказа, и LEFT JOIN размножает строку клиента по числу совпадений.
Почему остальные варианты неверны:
INNER JOIN оставит только клиентов с заказами — Вера пропадёт. Это самая частая ошибка: аналитик считает «клиентов» по джойну с заказами и молча теряет тех, кто ничего не купил, занижая знаменатель в конверсии.
orders LEFT JOIN customers сохраняет все заказы, а не всех клиентов — направление перепутано. Здесь важно, что LEFT JOIN некоммутативен: A LEFT JOIN B ≠ B LEFT JOIN A.
ON order_id — соединение по неверному ключу: order_id есть только в orders, связь между таблицами идёт через customer_id.
Полный запрос выглядел бы так:
SELECT c.customer_id, c.name, o.order_id, o.amount
FROM customers c
LEFTJOIN orders o ON o.customer_id = c.customer_id;
Avito
SQLЛегкийвопрос
Количество уникальных пользователей по каждой платформе
Нужно посчитать количество уникальных пользователей по каждой платформе. Выберите один верный вариант ответа.
Фрагмент данных:
```
order_id | city | customer_id | amount | status
1 | Москва | 101 | 1000 | paid
2 | Москва | 102 | 2000 | paid
3 | Казань | 103 | 500 | cancelled
4 | Казань | 104 | 3000 | paid
```
Варианты ответа:
- `GROUP BY platform, COUNT(DISTINCT customer_id)`
- `GROUP BY customer_id, COUNT(platform)`
- `COUNT(customer_id)` без `GROUP BY`
- `GROUP BY platform, SUM(customer_id)`
Правильный ответ: GROUP BY platform, COUNT(DISTINCT customer_id).
Разбираем формулировку по частям — она однозначно задаёт конструкцию запроса:
«по каждой платформе» → группировка GROUP BY platform: одна строка результата на платформу;
«количество уникальных пользователей» → COUNT(DISTINCT customer_id): без DISTINCT пользователь, сделавший несколько заказов, будет посчитан несколько раз.
SELECT platform, COUNT(DISTINCT customer_id) AS users
FROM orders
GROUPBY platform;
Почему остальные варианты неверны:
GROUP BY customer_id, COUNT(platform) — группировка перевёрнута: даст по строке на пользователя, а не на платформу.
COUNT(customer_id) без GROUP BY — вернёт одно общее число по всей таблице, без разбивки, и вдобавок посчитает дубли.
GROUP BY platform, SUM(customer_id) — суммирование идентификаторов лишено смысла: id — категориальный признак, его нельзя складывать.
Главный нюанс — DISTINCT.COUNT(customer_id) считает строки (заказы), COUNT(DISTINCT customer_id) — уникальных людей. Путаница между этими двумя метриками — источник завышенных отчётов по «числу пользователей».
Замечание к условию: в приведённом фрагменте данных колонки platform нет — есть city. Это неточность оригинального теста (фрагмент, судя по всему, от соседнего задания); на выбор ответа она не влияет, но на реальном собеседовании стоит уточнить у интервьюера имена колонок, прежде чем писать запрос.
Avito
PythonЛегкийвопрос
Города, отсортированные по общей выручке (pandas)
Какой вариант кода вернёт список городов, отсортированных по общей выручке по убыванию?
```python
import pandas as pd
df = pd.DataFrame({
'city': ['Moscow', 'Kazan', 'Moscow', 'Sochi', 'Kazan', 'Sochi'],
'category': ['food', 'food', 'tech', 'food', 'tech', 'tech'],
'revenue': [120, 80, 300, 90, 200, 150]
})
```
Варианты ответа:
- `df.sort_values('revenue', ascending=False)['city'].tolist()`
- `df.groupby('city')['revenue'].sum().sort_values(ascending=False).index.tolist()`
- `df.groupby('category')['revenue'].sum().sort_values(ascending=False).index.tolist()`
- `df.groupby('city')['revenue'].mean().sort_values(ascending=False).index.tolist()`
Разбираем цепочку по звеньям: groupby('city') — агрегируем по городам; ['revenue'].sum() — «общая выручка» города (Series с индексом city); sort_values(ascending=False) — сортировка по убыванию значений; .index.tolist() — достаём названия городов, поскольку после groupby город стал индексом.
df.sort_values('revenue', ...)['city'].tolist() — сортирует отдельные строки, а не города: вернёт список из 6 элементов с повторами (['Moscow', 'Kazan', 'Sochi', 'Moscow', 'Sochi', 'Kazan']). Агрегации нет вообще — это главная ошибка варианта.
groupby('category') — группировка не по тому полю: вернёт ['tech', 'food'], то есть категории вместо городов.
.mean() вместо .sum() — считает среднюю выручку, а не общую. Тонкость: на этих конкретных данных порядок получается тот же (['Moscow', 'Kazan', 'Sochi']), потому что у всех городов по 2 записи, и деление на одинаковое число не меняет порядок. Но по смыслу «общая выручка» — это сумма; при разном числе записей на город ответы разойдутся. Такие варианты в тестах и проверяют: правильный ответ должен быть верен не только на приведённом примере.
Полезно помнить: после groupby(...).agg() ключ группировки уходит в индекс. Если нужен DataFrame с городом как обычной колонкой — добавляется .reset_index(), а для списка значений индекса используется .index.tolist().
Avito
PythonСреднийзадача
Уникальные страницы каждой сессии в алфавитном порядке
Есть строка с просмотренными страницами по трём сессиям. Сессии разделены символом `#`, страницы внутри сессии разделены символом `/`.
Какой вариант кода вернёт строку с уникальными страницами каждой сессии в алфавитном порядке? Страницы внутри сессии должны быть соединены через `,`, а сессии — через `|`.
```python
s = 'home/catalog/home#cart/home/cart#search/product/search/home'
```
Варианты ответа:
- `'|'.join(','.join(session.split('/')) for session in s.split('#'))`
- `'|'.join(','.join(sorted(set(s.split('/')))) for session in s.split('#'))`
- `'|'.join(','.join(sorted(set(session.split('/')))) for session in s.split('#'))`
- `'|'.join(sorted(set(session.split('/'))) for session in s.split('#'))`
Правильный ответ:
'|'.join(','.join(sorted(set(session.split('/')))) for session in s.split('#'))
sorted(...) — алфавитный порядок (важно: set неупорядочен, поэтому сортировка обязательна);
','.join(...) — склеиваем страницы внутри сессии;
'|'.join(...) — склеиваем сессии.
Пошагово для примера: home/catalog/home → {'home','catalog'} → ['catalog','home'] → 'catalog,home'.
Разбор ошибок в остальных вариантах:
Вариант 1 — нет ни set, ни sorted: вернёт 'home,catalog,home|cart,home,cart|search,product,search,home' с дублями и в исходном порядке.
Вариант 2 — самая коварная ошибка: внутри стоит s.split('/') вместо session.split('/'). Переменная цикла не используется, поэтому для каждой сессии считается одно и то же по всей исходной строке, и # попадают внутрь «страниц»: 'cart#search,catalog,home,home#cart,product,search' — и так трижды. Классический баг copy-paste, который стоит уметь замечать при код-ревью.
Вариант 4 — '|'.join() получает генератор списков, а не строк, и падает с TypeError: sequence item 0: expected str instance, list found. Пропущен шаг склейки страниц внутри сессии.
Что проверяет задача: понимание порядка вложенных вызовов и того, что set разрушает порядок элементов, — поэтому sorted нужен всегда, если требуется детерминированный вывод.
Avito
PythonСреднийзадача
Подсчёт метрик с отрицательным итоговым изменением по группам
**Кодинг-задача.** На вход программе подаётся одна строка с записями об изменении метрик по группам. Каждая запись имеет формат `group_id, metric_id, delta`, записи разделены точкой с запятой `;`.
| Поле | Описание |
|---|---|
| `group_id` | числовой идентификатор группы |
| `metric_id` | числовой идентификатор метрики |
| `delta` | изменение значения метрики (может быть положительным, отрицательным или нулевым) |
**Что нужно сделать.** Для каждой группы посчитать итоговое изменение по каждой метрике: для одинаковой пары `group_id + metric_id` сложить все значения `delta`. После этого для каждой группы определить количество метрик, у которых итоговое изменение меньше нуля.
**Важные условия:**
- если одна и та же метрика встречается в группе несколько раз, её изменения нужно сложить;
- учитываются только метрики, у которых итоговая сумма `delta` меньше 0;
- группы должны быть выведены в порядке их первого появления во входной строке;
- результаты по группам соединяются через `;`.
**Формат ввода** — одна строка: `group_id, metric_id, delta; group_id, metric_id, delta; ...`
**Формат вывода** — одна строка: `group_id, count; group_id, count; ...`, где `count` — количество метрик группы с отрицательным итоговым изменением.
**Пример.** Ввод: `1, 101, -5; 1, 102, 3; 2, 101, -1; 1, 101, 2; 2, 103, 4` → Вывод: `1, 1; 2, 1`
Заготовка кода:
```python
import sys
def read_line():
return sys.stdin.readline().rstrip('\n')
def write(value):
sys.stdout.write(str(value))
def writeln(value=''):
sys.stdout.write(str(value) + '\n')
def solve():
pass
solve()
```
Задача на аккуратную агрегацию по составному ключу с сохранением порядка первого появления.
import sys
from collections import defaultdict
defread_line():
return sys.stdin.readline().rstrip('\n')
defwriteln(value=''):
sys.stdout.write(str(value) + '\n')
defsolve():
line = read_line()
totals = defaultdict(int) # (group_id, metric_id) -> сумма delta
group_order = [] # группы в порядке первого появления
metrics_of_group = defaultdict(list)
for record in line.split(';'):
record = record.strip()
ifnot record:
continue
group_id, metric_id, delta = (int(x) for x in record.split(','))
if group_id notin metrics_of_group:
group_order.append(group_id)
if metric_id notin metrics_of_group[group_id]:
metrics_of_group[group_id].append(metric_id)
totals[(group_id, metric_id)] += delta
parts = []
for group_id in group_order:
negative = sum(
1for metric_id in metrics_of_group[group_id]
if totals[(group_id, metric_id)] < 0
)
parts.append(f'{group_id}, {negative}')
writeln('; '.join(parts))
solve()
Ключ агрегации составной — пара (группа, метрика). Частая ошибка — суммировать только по метрике и потерять разделение по группам: метрика 101 встречается в обеих группах, и её нельзя складывать сквозь них.
Суммировать нужно ДО сравнения с нулём. Если проверять знак каждой записи по отдельности, метрика 101 группы 1 засчиталась бы по записи −5, хотя итог −3 (тот же знак, но в примере 5, 1, -3; 5, 1, 3 итог 0 — и метрика не должна учитываться вовсе).
Строго меньше нуля: нулевое итоговое изменение не считается отрицательным.
Порядок групп — по первому появлению, а не по возрастанию id. В Python 3.7+ обычный dict сохраняет порядок вставки, поэтому отдельный список group_order можно заменить на обход dict; но явный список надёжнее читается и не зависит от версии.
Пробелы после запятых во входной строке обязательно снимать через strip(), иначе int() упадёт.
Сложность — O(n) по числу записей.
Avito
PythonСреднийзадача
Среднее значение по группе после сортировки
**Кодинг-задача.** Даны две строки одинаковой длины. Первая содержит коды групп через запятую, вторая — целые значения через запятую.
Нужно:
1. сформировать DataFrame с колонками `group` и `value`;
2. отсортировать строки по колонке `group` по возрастанию;
3. для каждой строки добавить значение среднего `value` внутри её группы;
4. вернуть полученные средние значения в порядке итогового DataFrame;
5. средние значения округлить до целых чисел;
6. значения в ответе соединить через `,`.
Если в группе несколько строк, каждая строка этой группы получает одно и то же среднее значение.
**Формат ввода** — две строки: `group_1, group_2, group_3, ...` и `value_1, value_2, value_3, ...`
**Формат вывода** — одна строка с целыми числами, соединёнными через `,`.
**Пример.** Ввод: `2, 1, 2, 1, 2` и `10, 20, 30, 40, 50` → Вывод: `30, 30, 30, 30, 30`
Заготовка кода:
```python
import sys
def read_line():
return sys.stdin.readline().rstrip('\n')
def writeln(value=''):
sys.stdout.write(str(value) + '\n')
def solve():
pass
solve()
```
Задача проверяет знание groupby().transform() — агрегата, который возвращается на каждую строку, а не схлопывает группы.
import sys
import pandas as pd
defread_line():
return sys.stdin.readline().rstrip('\n')
defwriteln(value=''):
sys.stdout.write(str(value) + '\n')
defsolve():
groups = [g.strip() for g in read_line().split(',')]
values = [int(v.strip()) for v in read_line().split(',')]
# коды групп числовые — приводим к int, иначе '10' < '2' при строковой сортировкеifall(g.lstrip('-').isdigit() for g in groups):
groups = [int(g) for g in groups]
df = pd.DataFrame({'group': groups, 'value': values})
df = df.sort_values('group', kind='stable')
df['avg'] = df.groupby('group')['value'].transform('mean').round().astype(int)
writeln(', '.join(str(x) for x in df['avg']))
solve()
После сортировки по группе: (1, 20), (1, 40), (2, 10), (2, 30), (2, 50).
Средние: группа 1 → (20 + 40)/2 = 30; группа 2 → (10 + 30 + 50)/3 = 30.
Вывод: 30, 30, 30, 30, 30 ✓ (в этом примере средние совпали — на других данных они, конечно, различаются: например, 3, 1, 2, 1 / 100, 10, 50, 20 даёт 15, 15, 50, 100).
Ключевые моменты:
transform('mean') вместо agg/mean().groupby().mean() вернёт по одной строке на группу, и результат придётся приджойнивать обратно. transform возвращает Series той же длины и с тем же индексом, что исходный DataFrame, — ровно то, что требует пункт 3 условия.
Сортировка должна идти до вычислений (по условию порядок вывода — это порядок итогового DataFrame). kind='stable' сохраняет исходный относительный порядок строк внутри одной группы — на случай, если проверяющие тесты чувствительны к нему.
Тип ключа группы. Если коды групп оставить строками, сортировка будет лексикографической: '10' окажется раньше '2'. Приведение к int для числовых кодов снимает эту ловушку.
Округление..round() в pandas/NumPy использует банковское округление: 12,5 → 12, а не 13 (округление к ближайшему чётному). Если проверяющая система ждёт «половину вверх», нужен явный int(x + 0.5) или decimal.Decimal с ROUND_HALF_UP. Об этом стоит помнить и на реальных расчётах в отчётности.
.astype(int) в конце нужен, чтобы в выводе не появились 30.0 вместо 30.
Решение без pandas (если библиотека недоступна):
from statistics import mean
pairs = sorted(zip(groups, values), key=lambda p: p[0])
avg_by_group = {}
for g, v in pairs:
avg_by_group.setdefault(g, []).append(v)
avg_by_group = {g: round(mean(vs)) for g, vs in avg_by_group.items()}
writeln(', '.join(str(avg_by_group[g]) for g, _ in pairs))
WB
PythonЛегкийвопрос
Основные компоненты Apache Airflow
Назови основные компоненты Apache Airflow и объясни, за что отвечает каждый.
Airflow состоит из пяти ключевых компонентов:
1. Scheduler (планировщик) — сердце системы. Постоянно парсит файлы DAG-ов, вычисляет, какие задачи готовы к запуску (расписание наступило, зависимости выполнены), и ставит их в очередь. Он же отвечает за retry упавших задач и за обработку пропущенных интервалов.
2. Webserver (UI) — веб-интерфейс на Flask: просмотр DAG-ов и их графов, история запусков, логи задач, ручной запуск и очистка (clear) задач, управление Connections и Variables.
3. Executor — определяет, как и где запускаются задачи. Это не отдельный процесс, а компонент внутри Scheduler-а, который передаёт задачи на исполнение (SequentialExecutor, LocalExecutor, CeleryExecutor, KubernetesExecutor).
4. Metadata Database — обычно PostgreSQL или MySQL. Хранит всё состояние: DAG-и, их запуски (DagRun), состояния задач (TaskInstance), XCom, Connections, Variables, пользователей. Это единственный источник правды: если БД потеряна, Airflow не помнит, что уже выполнялось.
5. Workers — процессы, которые фактически выполняют код задач. В LocalExecutor это подпроцессы на той же машине, в CeleryExecutor — отдельные постоянные воркеры, в KubernetesExecutor — эфемерные поды.
Как компоненты взаимодействуют: Scheduler читает DAG-и → определяет готовые задачи → через Executor ставит их в очередь → Worker забирает задачу и выполняет → статус пишется в Metadata DB → Webserver показывает результат из той же БД.
Частая путаница на собеседовании: Executor и Operator — разные вещи. Executor отвечает за инфраструктуру запуска (где выполняется задача), Operator — за содержание задачи (что она делает: PythonOperator, BashOperator, SparkSubmitOperator). Сказать «SparkSubmitOperator» в ответ на вопрос про типы executor-ов — типичная ошибка.
В Airflow 2.x к этому добавляется Triggerer — отдельный процесс для deferrable-операторов, который позволяет задачам-сенсорам «засыпать», не занимая слот воркера.
WB
PythonЛегкийвопрос
Где физически выполняется Python-код задачи в Airflow
Где выполняется Python-код задачи в Airflow?
На воркере — то есть в том процессе, который Executor выделил под конкретную задачу. Но важно понимать, что код DAG-файла исполняется в двух разных местах и в разное время:
1. Код на верхнем уровне DAG-файла выполняется Scheduler-ом при парсинге — по умолчанию каждые 30 секунд для каждого файла. Сюда попадает всё, что написано вне функций: определение DAG-а, создание операторов, любые вызовы вне callable.
2. Код внутри задачи (тело python_callable у PythonOperator, команда BashOperator) выполняется на воркере в момент запуска задачи.
Конкретное «где» зависит от executor-а:
Executor
Где выполняется код задачи
SequentialExecutor
В процессе самого Scheduler-а, по одной задаче
LocalExecutor
В подпроцессе на машине Scheduler-а
CeleryExecutor
На отдельном постоянном воркере (другая машина)
KubernetesExecutor
В отдельном поде, который создаётся под задачу и удаляется после
Почему это важно на практике. Из этого разделения следует главное правило написания DAG-ов: на верхнем уровне файла не должно быть тяжёлых операций — запросов к БД, чтения больших файлов, обращений к API. Такой код будет выполняться при каждом парсинге, каждые полминуты, для всех DAG-ов сразу — это кладёт Scheduler.
# ПЛОХО: запрос выполняется при каждом парсинге DAG-файла
rows = clickhouse.execute("SELECT ...")
with DAG("my_dag", ...) as dag:
t1 = PythonOperator(task_id="t1", python_callable=lambda: process(rows))
# ХОРОШО: запрос выполняется только при запуске задачи, на воркереdefextract():
rows = clickhouse.execute("SELECT ...")
return process(rows)
with DAG("my_dag", ...) as dag:
t1 = PythonOperator(task_id="t1", python_callable=extract)
Второе следствие: воркер должен иметь доступ ко всем зависимостям (библиотекам, сетевым доступам, кредам), а не только машина со Scheduler-ом.
WB
PythonЛегкийвопрос
Как задать зависимости между тасками внутри DAG
Как организовать зависимости между тасками в DAG?
Есть три эквивалентных способа.
1. Битшифт-операторы >> и << — самый распространённый и читаемый:
3. TaskFlow API (Airflow 2.x) — зависимости выводятся автоматически из передачи данных между функциями:
from airflow.decorators import dag, task
@taskdefextract():
return {"rows": 100}
@taskdeftransform(data):
return data["rows"] * 2@dag(schedule="@daily", start_date=datetime(2026, 1, 1), catchup=False)defmy_pipeline():
transform(extract()) # зависимость создаётся самим фактом передачи результата
my_pipeline()
Что важно понимать:
Зависимости задают порядок выполнения, а не передачу данных. Чтобы передать значение между задачами в классическом API, нужен XCom (и он рассчитан на небольшие объёмы — метаданные, пути к файлам, а не сами датафреймы).
Граф должен оставаться ацикличным — это буква A в DAG. Airflow выбросит ошибку при попытке создать цикл.
По умолчанию задача запускается по правилу trigger_rule='all_success' — все предки должны завершиться успешно. Правило меняется: all_done (запустить в любом случае — удобно для задач очистки), one_failed, none_failed_min_one_success.
Для повторяющихся групп задач используют TaskGroup — это визуальная и логическая группировка внутри DAG-а, зависимости к группе применяются целиком: start >> my_group >> end.
WB
PythonСреднийвопрос
Условный запуск таски: B или C в зависимости от результата A
Есть таска A. В зависимости от её результата нужно запустить либо таску B, либо таску C. Как это реализовать в Airflow?
Это ветвление (branching), штатный инструмент — BranchPythonOperator. Он выполняет Python-функцию, которая возвращает task_id (или список task_id) той ветки, которую нужно выполнить. Все остальные ветки Airflow помечает статусом skipped.
Главная ловушка — задача после веток. Если поставить общую задачу join после B и C, она по умолчанию не запустится: правило all_success не выполняется, потому что одна из веток пропущена. Решается через trigger_rule:
Чем это отличается от сенсора. Сенсор (Sensor) — не про ветвление, а про ожидание внешнего события: появления файла, партиции в таблице, завершения другого DAG-а. Он блокирует выполнение до наступления условия или таймаута, но не выбирает между ветками. Предложить сенсор в ответ на вопрос про условный запуск — распространённая, но неточная замена.
Альтернативы:
ShortCircuitOperator — если условие ложно, пропускает всю нижележащую ветку. Подходит, когда нужно просто «не выполнять дальше», а не выбирать из двух путей.
Динамическое создание задач через expand() (Dynamic Task Mapping, Airflow 2.3+) — когда число веток заранее неизвестно и зависит от данных.
WB
PythonСреднийвопрос
Механизмы зависимостей между разными DAG-ами
Какие есть механизмы организации зависимостей между разными DAG-ами?
Три основных подхода, различаются направлением инициативы.
1. ExternalTaskSensor — «ждущий» подход (pull). DAG-потребитель ждёт, пока в DAG-е-поставщике завершится нужная задача:
from airflow.sensors.external_task import ExternalTaskSensor
wait_for_upstream = ExternalTaskSensor(
task_id="wait_for_etl",
external_dag_id="etl_raw_layer",
external_task_id="load_finished", # None — ждать весь DAG целиком
execution_delta=timedelta(hours=1), # если расписания сдвинуты
mode="reschedule", # освобождает слот воркера между проверками
timeout=3600,
)
Ключевая тонкость: сенсор ищет запуск с совпадающим execution_date. Если расписания DAG-ов различаются, обязательно нужен execution_delta или execution_date_fn — иначе сенсор будет ждать вечно. И всегда указывайте mode='reschedule' (или deferrable-версию), иначе сенсор занимает слот воркера всё время ожидания и провоцирует голодание остальных задач.
2. TriggerDagRunOperator — «толкающий» подход (push). DAG-поставщик сам запускает потребителя по завершении:
from airflow.operators.trigger_dagrun import TriggerDagRunOperator
trigger = TriggerDagRunOperator(
task_id="trigger_downstream",
trigger_dag_id="analytics_marts",
wait_for_completion=False,
conf={"run_date": "{{ ds }}"}, # можно передать параметры
)
3. Datasets / Data-aware scheduling (Airflow 2.4+) — современный декларативный способ. DAG объявляет, какой датасет он производит, а потребитель подписывается на обновление — расписание вычисляется автоматически:
from airflow.datasets import Dataset
orders = Dataset("clickhouse://analytics/orders")
# производитель@dag(schedule="@daily", ...)defproducer():
@task(outlets=[orders])defload_orders(): ...
# потребитель: запустится сам, как только orders обновится@dag(schedule=[orders], ...)defconsumer(): ...
Как выбирать:
Способ
Когда применять
ExternalTaskSensor
Потребитель знает своих поставщиков; расписания согласованы
TriggerDagRunOperator
Поставщик решает, что и когда запускать; событийная логика
Datasets
Много связей «по данным», хочется убрать явную привязку к расписанию
Отдельно стоит упомянуть SubDAG — устаревший механизм, в новых версиях заменён на TaskGroup, использовать его не стоит (он создавал проблемы с блокировкой слотов).
WB
PythonЛегкийвопрос
Параметр catchup в Airflow
Что такое параметр catchup в Airflow и как он работает?
catchup управляет тем, будет ли Airflow досчитывать пропущенные интервалы расписания при запуске DAG-а.
При catchup=True (историческое значение по умолчанию) Scheduler смотрит на start_date и расписание, вычисляет все интервалы между start_date и текущим моментом, и создаёт DagRun для каждого пропущенного. Классический сценарий: DAG был выключен неделю — после включения Airflow запустит семь ежедневных прогонов подряд, за каждую пропущенную дату.
При catchup=False запустится только один DagRun — за последний завершённый интервал, а история будет проигнорирована.
with DAG(
dag_id="daily_price_index",
start_date=datetime(2026, 1, 1),
schedule="@daily",
catchup=False,
max_active_runs=1,
) as dag:
...
Когда нужен catchup=True: пайплайн считает данные за конкретный интервал (партиция за день), и пропущенные дни действительно нужно пересчитать — историческая полнота важна. Обязательное условие: DAG идемпотентен и параметризован датой ({{ ds }}, data_interval_start), а не пишет «за сегодня».
Когда нужен catchup=False: DAG всегда обрабатывает актуальный срез (полная перезагрузка справочника, health-check), и повторять его за прошлые даты бессмысленно.
Практические предостережения:
Включённый catchup при старом start_date порождает лавину запусков — сотни DagRun-ов разом, которые кладут кластер. Поэтому вместе с catchup почти всегда ставят max_active_runs=1, чтобы прогоны шли последовательно.
Глобально поведение по умолчанию задаётся параметром catchup_by_default в airflow.cfg; во многих командах его выключают, чтобы забытый параметр в DAG-е не устроил аварию.
Для разового досчёта истории лучше не менять start_date, а использовать airflow dags backfill с явным диапазоном дат — это управляемая операция.
Не путайте catchup с backfill: catchup — автоматический досчёт при работе Scheduler-а, backfill — ручной запуск за указанный период.
WB
PythonСреднийвопрос
Типы executor-ов в Airflow
Какие бывают типы executor-ов в Airflow?
Executor определяет, как и где запускаются задачи. Основные четыре:
1. SequentialExecutor — выполняет задачи строго по одной, в процессе Scheduler-а. Единственный, работающий с SQLite. Годится только для отладки и знакомства с Airflow, в проде не используется.
2. LocalExecutor — запускает задачи параллельно в подпроцессах на той же машине, где работает Scheduler. Параллелизм ограничен параметром parallelism и ресурсами одной машины. Подходит для небольших инсталляций, прост в эксплуатации, нет внешних зависимостей.
3. CeleryExecutor — распределённое выполнение. Задачи попадают в очередь (Redis или RabbitMQ), откуда их разбирают постоянно работающие воркеры на разных машинах. Горизонтально масштабируется добавлением воркеров, поддерживает именованные очереди (queue='heavy') для маршрутизации тяжёлых задач на мощные ноды.
4. KubernetesExecutor — под каждую задачу создаётся отдельный Pod, который удаляется после завершения. Даёт изоляцию окружений (у каждой задачи свой образ и свои лимиты CPU/памяти) и динамическое выделение ресурсов без простаивающих воркеров.
Есть также CeleryKubernetesExecutor — гибрид, позволяющий часть задач гнать через Celery, а часть — в поды, и DaskExecutor для интеграции с Dask-кластером.
Executor
Параллелизм
Инфраструктура
Когда выбирать
Sequential
Нет
SQLite
Только локальная отладка
Local
В рамках машины
Postgres/MySQL
Малые и средние инсталляции
Celery
Распределённый
+ Redis/RabbitMQ
Много задач, стабильная нагрузка
Kubernetes
Распределённый
K8s-кластер
Разные окружения задач, изоляция, неравномерная нагрузка
Важное разграничение, которое проверяют этим вопросом: Executor ≠ Operator. Executor — про инфраструктуру запуска, Operator — про содержание задачи. SparkSubmitOperator, PythonOperator, BashOperator — это операторы, они работают при любом executor-е.
WB
PythonСложныйвопрос
CeleryExecutor против KubernetesExecutor
В чём разница между CeleryExecutor и KubernetesExecutor? Когда что выбирать?
Разница в модели выделения ресурсов: постоянные воркеры против эфемерных подов.
CeleryExecutor. Заранее поднят пул постоянно работающих воркеров. Scheduler кладёт задачу в брокер очередей (Redis/RabbitMQ), свободный воркер забирает её и выполняет в своём процессе. Воркеры живут всё время, независимо от наличия задач.
KubernetesExecutor. Под каждую задачу Scheduler создаёт отдельный Pod с нужным образом и лимитами ресурсов. Задача выполняется внутри пода, после завершения под удаляется. Между задачами ничего не «висит».
CeleryExecutor
KubernetesExecutor
Единица исполнения
Постоянный воркер-процесс
Эфемерный Pod на задачу
Доп. инфраструктура
Брокер очередей + мониторинг воркеров
Kubernetes-кластер
Старт задачи
Быстрый (воркер уже готов)
Медленнее (создание пода, pull образа)
Изоляция зависимостей
Общая для всех задач воркера
Полная: свой образ на задачу
Ресурсы под задачу
Общие ресурсы воркера
Индивидуальные requests/limits
Простой
Воркеры потребляют ресурсы всегда
Ресурсы только под активные задачи
Масштабирование
Ручное/по метрикам добавление воркеров
Автоматическое, средствами K8s
Когда выбирать Celery:
много коротких однотипных задач — накладные расходы на старт пода (секунды) стали бы заметной долей времени выполнения;
стабильная предсказуемая нагрузка, воркеры не простаивают;
нет Kubernetes или нет экспертизы по нему;
нужны именованные очереди с закреплением за конкретными машинами.
Когда выбирать Kubernetes:
задачи требуют разных окружений: одной нужен TensorFlow, другой — только psycopg2; в Celery пришлось бы ставить всё в один образ воркера;
нагрузка резко неравномерная (пик 8–10 утра, ночью пусто) — платить за простаивающие воркеры не хочется;
нужна жёсткая изоляция: «тяжёлая» задача не должна утянуть за собой соседей по воркеру;
разные требования к ресурсам: одной задаче 1 ГБ, другой 64 ГБ.
Главный недостаток Celery, о котором стоит сказать: все задачи одного воркера делят его окружение и ресурсы. Утечка памяти или зависший процесс в одной задаче влияет на соседние. В Kubernetes каждая задача изолирована, но платой становится задержка старта пода и необходимость поддерживать K8s.
WB
PythonСреднийвопрос
Голодание тасок (task starvation) в Airflow
Что такое голодание тасок (task starvation) в Airflow?
Starvation — ситуация, когда задача бесконечно (или недопустимо долго) ждёт свободного слота выполнения, потому что все ресурсы заняты другими задачами. Формально она в статусе queued, но фактически не запускается и срывает SLA.
Как возникает. Число одновременно выполняемых задач ограничено на нескольких уровнях:
parallelism — глобальный лимит задач во всём Airflow;
dag_concurrency (max_active_tasks_per_dag) — лимит на DAG;
max_active_runs_per_dag — лимит одновременных запусков одного DAG-а;
размер пула (pool), если задача в него назначена;
реальные ресурсы воркеров.
Если «жадный» DAG порождает сотни задач или занимает воркеры долгими сенсорами, очередь забивается, и короткие критичные задачи других команд не получают слотов.
Типичные причины:
Сенсоры в режиме poke — самая частая. Такой сенсор держит слот воркера всё время ожидания, даже когда просто спит между проверками. Десяток сенсоров, ждущих файлы по несколько часов, парализуют кластер. Лечится переводом в mode='reschedule' или использованием deferrable-операторов (они освобождают слот и передают ожидание в Triggerer).
Backfill или включённый catchup со старым start_date — лавина исторических запусков.
Один DAG с массовым Dynamic Task Mapping, отъедающий весь parallelism.
Долгие задачи, запущенные в пиковое окно вместе со всеми остальными.
Инструменты борьбы:
Pools — квоты слотов по доменам/командам: pool='team_pricing' с фиксированным числом слотов. Гарантирует, что один потребитель не съест всё.
priority_weight — приоритет задачи внутри очереди: критичные задачи разбираются раньше.
Именованные очереди Celery или отдельные node pools в K8s — физическое разделение ресурсов между типами нагрузки.
Пересмотр расписаний — разнести запуски по времени, чтобы не создавать пик.
mode='reschedule' для всех сенсоров как обязательное правило код-ревью.
Как обнаружить: метрики scheduler.tasks.starving в StatsD, рост числа задач в статусе queued при полной загрузке слотов, отставание фактического времени старта от расписания.
WB
PythonСложныйвопрос
Starvation при двух доменах в пиковое окно 8–10 утра
У вас два домена (две команды), которые пишут DAG-и в один инстанс Airflow. Оба грузят данные в пиковый период с 8 до 10 утра, и задачи начинают голодать. Как решать проблему?
Задача на инженерное мышление: одного «правильного» ответа нет, важно перебрать уровни решения.
1. Изоляция ресурсов через пулы — первое, что делают. Создаём отдельные пулы с квотами и назначаем задачи доменов в свои пулы:
Теперь всплеск задач одной команды физически не может занять больше своей квоты, и второй домен всегда получает свои слоты. Внутри пула порядок разбора регулируется через priority_weight.
2. Сдвиг расписания — то, что подсказал интервьюер. Часто «пик в 8–10» существует не потому, что данные нужны именно в 8:00, а потому, что все поставили 0 8 * * * по привычке. Стоит выяснить реальные SLA: если отчёт смотрят в 11, загрузку можно начать в 6 или растянуть на окно 5–9. Расстановка стартов по разным часам решает проблему без всякой инфраструктуры — самое дешёвое решение.
3. Наращивание и эластичность ресурсов. KubernetesExecutor с автоскейлингом кластера позволяет пережить пик, не оплачивая простаивающие воркеры остальные 22 часа. При CeleryExecutor — временное расширение пула воркеров по расписанию либо отдельные очереди с закреплёнными машинами под каждый домен.
4. Оптимизация самих DAG-ов. Часто пик рукотворный:
сенсоры в режиме poke держат слоты часами — перевести в reschedule/deferrable;
задачи, которые можно объединить, разбиты на сотни мелких, каждая со своим оверхедом;
max_active_runs не ограничен, и backfill соревнуется с регулярными запусками.
5. Организационное разделение. Если домены крупные и продолжают мешать друг другу — разнести их по разным инстансам Airflow (или разным namespace в Kubernetes). Полная изоляция ценой удвоения эксплуатации; к этому приходят, когда команды сильно различаются по критичности задач.
Порядок действий на практике: сначала измерить (какие задачи и сколько ждут, где реально узкое место), затем пулы и приоритеты как быстрое лекарство, параллельно — переговоры об SLA и сдвиг расписаний, и только потом расширение железа. Наращивать ресурсы, не разобравшись в причине, — самый дорогой путь.
WB
PythonЛегкийвопрос
Что такое идемпотентный DAG
Что такое идемпотентный DAG и зачем это свойство нужно?
Идемпотентность — свойство, при котором повторный запуск задачи с теми же параметрами даёт тот же результат, что и первый. Перезапустив DAG за 15 сентября трижды, вы получите ровно те же данные, что после одного запуска: без дублей, без задвоенных сумм, без побочных эффектов.
Зачем это нужно. Airflow построен вокруг повторных запусков: автоматические retry при падении, ручной clear задачи, backfill за прошлые даты, catchup после простоя. Все эти механизмы безопасны только если DAG идемпотентен. Неидемпотентный пайплайн превращает штатный retry в инцидент с дублированными данными, который потом ищут в отчётности.
Как достигается:
1. Параметризация датой запуска, а не «сейчас». Задача должна обрабатывать интервал, за который её запустили, а не текущее время:
# ПЛОХО: результат зависит от момента запуска
WHERE event_date = today()
# ХОРОШО: результат определяется параметром запуска
WHERE event_date = '{{ ds }}'
Это же требование делает осмысленным backfill: прогон за прошлую дату должен пересчитать именно ту дату.
2. Перезапись вместо дописывания. Вместо «добавить строки» — «заменить партицию целиком»:
ALTER TABLE prices DROPPARTITION'{{ ds }}';
INSERT INTO prices SELECT ... WHERE dt ='{{ ds }}';
3. Идемпотентная запись на уровне хранилища:INSERT ... ON CONFLICT DO UPDATE в PostgreSQL, MERGE в Iceberg/Delta, ReplacingMergeTree в ClickHouse, mode='overwrite' с partitionOverwriteMode в Spark.
4. Отсутствие скрытого состояния. Задача не должна зависеть от результатов прошлых запусков (счётчиков, «последнего обработанного id» в файле) — иначе повторный прогон даст другой результат.
Что ломает идемпотентность: слепой INSERT без ключа дедупликации, использование now()/today() внутри логики, инкрементальная запись «от последнего значения», отправка писем и уведомлений внутри задачи (побочный эффект нельзя откатить — такие действия выносят в отдельную финальную задачу).
Хорошая проверка на собеседовании: «если я прямо сейчас нажму clear на этой задаче за прошлый вторник — что произойдёт с данными?» Если ответ не «ничего не изменится», DAG не идемпотентен.
WB
SQLЛегкийвопрос
Функция sumIf и комбинаторы агрегатных функций в ClickHouse
Есть ли в ClickHouse функция `sumIf`? Что она делает и чем отличается от обычного `sum` с условием?
Да, sumIf(column, condition) — штатная функция ClickHouse. Она суммирует значения колонки только по строкам, где условие истинно:
SELECT
seller_id,
sumIf(revenue, status ='paid') AS paid_revenue,
sumIf(revenue, status ='cancelled') AS cancelled_revenue,
countIf(status ='paid') AS paid_orders
FROM orders
GROUPBY seller_id;
Это частный случай комбинатора -If, который применим практически к любой агрегатной функции: countIf, avgIf, maxIf, uniqIf, quantileIf. Комбинаторы — характерная черта ClickHouse, аналогов в обычном SQL нет.
Чем лучше, чем sum(if(...)). Формально sum(if(status = 'paid', revenue, 0)) даёт тот же результат, но sumIf эффективнее: он не материализует промежуточный столбец с нулями, а применяет условие как маску прямо в агрегации — меньше аллокаций и лучше векторизация. На больших таблицах разница заметна.
Есть и семантическое различие: sum(if(cond, x, 0)) подменяет отсутствующие значения нулями, а sumIf их просто не учитывает. Для sum результат совпадает, а для avgIf — уже нет: среднее по трём строкам и среднее по трём строкам с двумя дописанными нулями различаются.
Другие полезные комбинаторы:
Комбинатор
Что делает
Пример
-If
Агрегация по условию
sumIf(x, cond)
-Array
Агрегация по элементам массива
sumArray(prices)
-State
Возвращает промежуточное состояние для матвью
uniqState(user_id)
-Merge
Схлопывает состояния в финальное значение
uniqMerge(state)
-OrNull
Возвращает NULL вместо значения по умолчанию на пустом наборе
sumOrNull(x)
-Resample
Агрегация по интервалам ключа
sumResample(0, 100, 10)(x, age)
Комбинаторы комбинируются между собой: sumArrayIf, uniqStateIf — рабочие конструкции. Пара -State/-Merge — основа инкрементальных агрегатов в AggregatingMergeTree и материализованных представлениях.
Аналог в стандартном SQL — FILTER (WHERE ...) в PostgreSQL; в ClickHouse эту роль играет именно -If.
WB
SQLСреднийвопрос
Движок MergeTree в ClickHouse: что это и зачем
Что такое MergeTree в ClickHouse и зачем он нужен?
MergeTree — основное семейство движков таблиц ClickHouse, рассчитанное на аналитическую (OLAP) нагрузку: массовые вставки и быстрые сканирующие запросы по большим объёмам.
Как устроен. Данные хранятся по колонкам и разложены по частям (parts). Каждый INSERT создаёт новую часть — отдельную папку с файлами колонок, отсортированными по ключу сортировки. Фоновый процесс периодически сливает (merge) мелкие части в более крупные — отсюда и название движка. Слияние идёт по принципу LSM-дерева: писать всегда быстро (просто новая часть), а упорядочивание происходит в фоне.
Разреженный первичный индекс. ORDER BY определяет физическую сортировку данных внутри части. Индекс хранит одну засечку на каждые index_granularity строк (по умолчанию 8192), поэтому занимает мало места и целиком помещается в память. Запрос с фильтром по префиксу ключа сортировки читает только нужные гранулы, а не всю таблицу.
Партиционирование (PARTITION BY) — отсечение целых партиций до чтения данных и возможность атомарно удалять/заменять партицию.
TTL — автоматическое удаление или перенос устаревших данных на медленные диски.
Проекции и skip-индексы (minmax, set, bloom_filter) — дополнительные структуры для пропуска блоков.
Компрессия по колонкам, эффективная за счёт сортировки однородных значений рядом.
Семейство движков — вариации поведения при слиянии, что и делает MergeTree «базовым классом»:
Движок
Что делает при merge
MergeTree
Просто объединяет части
ReplacingMergeTree
Оставляет одну строку на ключ сортировки (дедупликация)
SummingMergeTree
Суммирует числовые колонки по ключу
AggregatingMergeTree
Схлопывает агрегатные состояния (-State/-Merge)
CollapsingMergeTree
Схлопывает пары строк по колонке sign (+1/−1)
VersionedCollapsingMergeTree
То же, но с учётом версии для внеочередных данных
Replicated*MergeTree
Любой из вышеперечисленных + репликация через ZooKeeper/Keeper
Почему не «просто таблица»: без MergeTree недоступны ни партиционирование, ни первичный индекс, ни TTL, ни репликация. Движки вроде Log или Memory годятся только для мелких служебных таблиц; вся продовая аналитика в ClickHouse живёт на MergeTree.
Практическое следствие для вставки: поскольку каждый INSERT создаёт часть, вставлять нужно большими батчами (десятки тысяч строк), а не построчно. Тысячи мелких вставок порождают тысячи частей, фоновый мерж не успевает, и запрос упирается в ошибку Too many parts.
WB
SQLСреднийвопрос
Что такое part (часть) в ClickHouse
Что такое part (часть) в ClickHouse? Как части появляются и что с ними происходит дальше?
Part — физическая единица хранения данных в таблице MergeTree. На диске это отдельная директория, внутри которой лежат файлы колонок (сжатые данные .bin, засечки .mrk), файл первичного индекса primary.idx, файл контрольных сумм и метаданные о числе строк и диапазоне ключа.
Жизненный цикл части:
Создание. Каждый INSERT создаёт новую часть — данные сортируются по ключу ORDER BY и записываются на диск целиком. Часть иммутабельна: после записи её содержимое не меняется.
Фоновое слияние. ClickHouse периодически объединяет несколько мелких частей в одну крупную, сохраняя сортировку. Именно на этом этапе срабатывает специфика движка: ReplacingMergeTree выбрасывает дубликаты, SummingMergeTree складывает значения.
Удаление. Исходные части после успешного слияния помечаются неактивными и удаляются по истечении old_parts_lifetime (по умолчанию 8 минут).
Внутри части — гранулы (granules). Гранула — минимальная единица чтения, по умолчанию 8192 строки (index_granularity). Первичный индекс хранит по одной засечке на гранулу, поэтому запрос читает не всю часть, а только гранулы, попадающие под условие фильтра. Отсюда правило: индекс в ClickHouse разреженный, он не адресует отдельные строки, и точечные выборки по одной строке для него неестественны.
Что смотреть в проде:
-- Части таблицы: сколько, какого размера, в каких партицияхSELECTpartition, name, rows, bytes_on_disk, level, active
FROM system.parts
WHEREtable='prices'AND active
ORDERBYpartition;
-- Принудительное слияние (осторожно: тяжёлая операция)
OPTIMIZE TABLE prices PARTITION'202608'FINAL;
Почему знание про части практически важно:
Ошибка Too many parts — прямое следствие частых мелких вставок. Каждая вставка = часть, фоновый мерж не успевает, и при превышении parts_to_throw_insert (по умолчанию 300 на партицию) ClickHouse начинает отклонять INSERT. Лечение: вставлять батчами, использовать асинхронные вставки (async_insert=1) или буферную таблицу.
Слишком мелкое партиционирование (например, PARTITION BY toDate(dt) при годах истории) множит число частей и убивает производительность. Обычная рекомендация — партиции по месяцам.
Дедупликация в ReplacingMergeTree происходит только при слиянии, а слияние не гарантировано во времени — поэтому свежие данные могут содержать дубли, пока часть не смержилась. Отсюда необходимость FINAL или агрегации в запросе.
Различие part и partition: партиция — логическая группа (например, все данные за месяц), часть — физический файловый блок. Одна партиция обычно состоит из нескольких частей.
WB
SQLСреднийвопрос
Чем partition отличается от part в ClickHouse
Чем partition отличается от part в ClickHouse?
Это разные уровни организации данных: партиция — логическая группировка, часть — физическая единица хранения.
Partition (партиция) задаётся выражением PARTITION BY в определении таблицы — например, toYYYYMM(dt) группирует данные по месяцам. Это логический контейнер: все строки с одинаковым значением ключа партиционирования принадлежат одной партиции.
Part (часть) — конкретная папка с файлами на диске, создаваемая каждым INSERT-ом и объединяемая фоновым слиянием.
Связь между ними: одна партиция состоит из одной или нескольких частей. Ключевое ограничение — слияние происходит только внутри партиции: части из разных месяцев никогда не сольются в одну. Именно поэтому чрезмерно дробное партиционирование плодит части и мешает мержу.
Таблица prices
├── партиция 202607 (логически — июль)
│ ├── part 202607_1_15_2 ← слились 15 вставок
│ └── part 202607_16_16_0 ← свежая вставка
└── партиция 202608 (август)
├── part 202608_1_8_1
└── part 202608_9_9_0
Partition
Part
Природа
Логическая группа
Физическая директория
Кто задаёт
Разработчик через PARTITION BY
ClickHouse автоматически при INSERT/merge
Количество
Обычно десятки
Сотни и тысячи, меняется постоянно
Слияние
Не сливаются между собой
Сливаются внутри партиции
Операции
DROP/DETACH/ATTACH/REPLACE PARTITION
Управляются движком, вручную не трогают
Зачем нужны партиции на практике:
Отсечение при чтении (partition pruning). Запрос с фильтром по ключу партиционирования вообще не открывает лишние партиции.
Атомарные операции над данными. Это главный аргумент для ETL: удалить или заменить данные за период можно мгновенно, не выполняя тяжёлый DELETE.
-- идемпотентная перезагрузка суток: старое отбрасывается целикомALTER TABLE prices DROPPARTITION'20260808';
INSERT INTO prices SELECT ... WHERE dt ='2026-08-08';
-- либо атомарная подмена из staging-таблицыALTER TABLE prices REPLACE PARTITION'20260808'FROM prices_staging;
TTL и tiered storage работают на уровне партиций: старые партиции автоматически удаляются или переезжают на медленные диски.
Типичная ошибка новичков — путать ключ партиционирования с ключом сортировки. PARTITION BY определяет, как данные разложены по каталогам, ORDER BY — как они отсортированы внутри части и что попадает в первичный индекс. Ускорение фильтрации по конкретному товару даёт ORDER BY, а не партиционирование по товару (последнее просто создаст миллионы партиций и сломает таблицу). Хорошая практика — держать число партиций в пределах сотен, а не тысяч.
WB
SQLСреднийвопрос
Как обеспечить идемпотентность при вставке данных
Как обеспечить идемпотентность при вставке данных в хранилище?
Общий принцип: вставка должна опираться на ключ, по которому система понимает, что такая запись уже есть, либо перезаписывать данные целым блоком.
1. UPSERT (MERGE) — вставка с обновлением по ключу. Классический механизм в OLTP-базах и современных табличных форматах:
-- PostgreSQLINSERT INTO prices (product_id, dt, price)
VALUES (100, '2026-08-08', 199.90)
ON CONFLICT (product_id, dt) DO UPDATESET price = EXCLUDED.price;
-- Iceberg / Delta / Spark SQLMERGEINTO prices t
USING staging s ON t.product_id = s.product_id AND t.dt = s.dt
WHEN MATCHED THENUPDATESET t.price = s.price
WHENNOT MATCHED THENINSERT*;
Повторный запуск не создаёт дублей: существующие строки обновляются теми же значениями.
2. Перезапись партиции целиком (delete-write / overwrite). Предпочтительный подход в аналитических хранилищах, где точечные UPDATE дороги:
ALTER TABLE prices DROPPARTITION'20260808';
INSERT INTO prices SELECT ... WHERE dt ='2026-08-08';
В Spark то же самое делается динамической перезаписью партиций:
Свойство идемпотентности здесь получается «бесплатно»: сколько раз ни повтори, в партиции окажется ровно то, что вернул источник.
3. Дедупликация по ключу на чтении или при слиянии — когда физически удалить дубли дорого: ROW_NUMBER() OVER (PARTITION BY key ORDER BY loaded_at DESC) = 1, либо движок, схлопывающий дубли сам (ReplacingMergeTree в ClickHouse).
4. Транзакционность и атомарная публикация. Пишем в staging-таблицу, и только после успешной записи атомарно подменяем целевую (REPLACE PARTITION, переименование таблицы, коммит снапшота в Iceberg). Прерванная на середине задача не оставляет полузагруженных данных.
Что важно, кроме самой вставки:
Ключ идемпотентности должен быть стабильным — естественный бизнес-ключ или детерминированный хеш от полей. Если ключ содержит время загрузки, идемпотентность ломается.
Параметризация датой запуска, а не now() — иначе повторный прогон запишет другой интервал.
Exactly-once при потоковой загрузке достигается связкой оффсетов Kafka и транзакционной записи, либо дедупликацией по ключу события на приёмнике.
Хорошая формулировка для собеседования: идемпотентность — это не свойство одной команды INSERT, а свойство всего пайплайна: детерминированный вход (интервал), детерминированный ключ и операция замены вместо накопления.
WB
SQLСложныйвопрос
Идемпотентная запись именно в ClickHouse
Как обеспечить идемпотентность вставки именно в ClickHouse, если обычный UPDATE там нежелателен?
В ClickHouse классический UPSERT недоступен: это OLAP-хранилище, точечные изменения строк ему противоестественны. Идемпотентность достигается другими средствами — по возрастанию предпочтительности.
1. Замена партиции — основной приём в ETL. Самый надёжный и дешёвый способ: удалить партицию за обрабатываемый интервал и записать заново.
ALTER TABLE prices DROPPARTITION'20260808';
INSERT INTO prices SELECT ... WHERE dt ='2026-08-08';
Ещё безопаснее — собрать данные в staging-таблице и атомарно подменить:
INSERT INTO prices_staging SELECT ... WHERE dt ='2026-08-08';
ALTER TABLE prices REPLACE PARTITION'20260808'FROM prices_staging;
REPLACE PARTITION атомарен: читатели видят либо старые данные, либо новые, промежуточного состояния нет. Именно поэтому таблицы под ежедневный ETL партиционируют согласованно с шагом загрузки.
2. ReplacingMergeTree — дедупликация при слиянии. Движок оставляет одну строку на ключ сортировки, выбирая по версии:
CREATE TABLE prices
(
product_id UInt64,
dt Date,
price Decimal(18,2),
version DateTime -- версия записи
)
ENGINE = ReplacingMergeTree(version)
ORDERBY (product_id, dt);
Ключевое ограничение, о котором надо сказать вслух: дедупликация происходит только при фоновом слиянии, момент которого не гарантирован. Пока части не смержились, SELECT вернёт дубли. Отсюда — либо SELECT ... FINAL (дорого, дочитывает и схлопывает на лету), либо явная дедупликация в запросе через argMax/ROW_NUMBER, либо агрегирующее представление поверх.
3. Встроенная дедупликация вставок. Для Replicated-таблиц ClickHouse хранит хеши последних вставленных блоков (insert_deduplication_window) и молча игнорирует повторную вставку идентичного блока. Это спасает при retry задачи Airflow, но опираться на это как на основную стратегию нельзя: работает только для точно совпадающих блоков и ограниченное время. Можно задать ключ дедупликации явно: INSERT INTO ... SETTINGS insert_deduplication_token = 'dag_run_2026_08_08'.
4. CollapsingMergeTree / VersionedCollapsingMergeTree — для потоков изменений: строка «отменяется» записью с sign = -1 и заменяется новой с sign = +1. Механизм мощный, но требует дисциплины на стороне поставщика данных.
Чего делать не стоит:
ALTER TABLE ... UPDATE/DELETE — это мутации: асинхронные операции, которые перезаписывают целые части на диске. На больших таблицах они долгие, тяжёлые и мешают мержам. Годятся для разовых исправлений, но не для регулярного ETL. Более новая альтернатива — DELETE FROM с lightweight deletes, но и она не превращает ClickHouse в OLTP.
INSERT OR REPLACE — такого синтаксиса в ClickHouse нет.
Итоговая рекомендация: партиционировать таблицу по интервалу загрузки и перезаписывать партицию целиком. ReplacingMergeTree — дополнение для потоковых сценариев, где границу интервала провести нельзя.
WB
SQLЛегкийвопрос
Является ли UPDATE нормальной практикой в ClickHouse
UPDATE в ClickHouse — нормальная практика? Почему?
Нет, регулярный UPDATE в ClickHouse — антипаттерн. Это следствие архитектуры: ClickHouse колоночный, данные хранятся в иммутабельных частях, отсортированных и сжатых по колонкам. Точечное изменение строки в такую модель не вписывается.
Что происходит при ALTER TABLE ... UPDATE. Это не команда изменения строки, а мутация — асинхронная фоновая операция, которая:
находит все части, содержащие подходящие строки;
полностью перезаписывает каждую такую часть на диск с новыми значениями;
заменяет старые части новыми.
То есть обновление одной строки в части на 150 ГБ означает перезапись всех 150 ГБ. Мутация выполняется в фоне, ALTER возвращает управление сразу, а реальное завершение приходится отслеживать отдельно:
SELECT database, table, mutation_id, command, is_done, latest_fail_reason
FROM system.mutations
WHERE is_done =0;
Дополнительные проблемы:
мутации конкурируют за ресурсы с фоновыми слияниями и вставками;
нет транзакционности и отката в привычном смысле;
эффективность падает тем сильнее, чем крупнее части (а крупные части — это как раз хорошо для чтения);
накопившиеся незавершённые мутации заметно деградируют кластер.
Когда UPDATE всё-таки уместен: разовые исправления — заполнить новую колонку историческим значением, обезличить персональные данные по запросу (GDPR), поправить последствия инцидента. То есть как административная операция, а не как часть регулярного пайплайна.
В свежих версиях появились lightweight deletes (DELETE FROM table WHERE ...), которые помечают строки удалёнными без немедленной перезаписи частей, и экспериментальные lightweight updates. Они снижают боль, но не меняют главного вывода: если рабочая нагрузка требует частых точечных изменений, значит выбрана неподходящая база — такие сценарии закрывают OLTP-хранилища.
WB
SQLСреднийвопрос
Kafka: что такое топик и партиция
Что такое топик и партиция в Kafka? Как устроено чтение и запись?
Топик — именованный логический канал сообщений, аналог «таблицы» или очереди: продюсеры пишут в топик, консьюмеры читают из него. Топик — понятие логическое, физически данные лежат в партициях.
Партиция — упорядоченный, неизменяемый лог сообщений, физически хранящийся на диске брокера. Топик разбит на N партиций, и каждое сообщение попадает ровно в одну из них. Внутри партиции у каждого сообщения есть offset — порядковый номер, по которому консьюмер отмечает прогресс чтения.
Зачем нужно разбиение на партиции:
Масштабирование. Партиции распределяются по брокерам кластера, поэтому пропускная способность топика растёт с числом партиций, а не упирается в один диск.
Параллелизм чтения. Одну партицию в рамках консьюмер-группы читает ровно один консьюмер. Значит, число партиций задаёт потолок параллелизма: 8 партиций — максимум 8 полезных консьюмеров в группе, девятый будет простаивать.
Гарантии порядка. Порядок сообщений гарантирован только внутри партиции, не по топику целиком. Поэтому сообщения, для которых порядок важен (события одного заказа, изменения одной цены), должны попадать в одну партицию.
Как продюсер выбирает партицию: по хешу ключа сообщения. Если ключ не задан — round-robin (или sticky-батчинг). Отсюда практическое правило: ключом делают идентификатор сущности (product_id, order_id), чтобы все её события шли в одну партицию и сохраняли порядок.
Консьюмер-группы. Консьюмеры объединяются в группу с общим group.id; Kafka распределяет партиции между её участниками и хранит зафиксированные offset-ы для группы. Это даёт два режима сразу:
очередь — несколько консьюмеров одной группы делят нагрузку;
publish/subscribe — несколько разных групп читают один топик независимо, каждая со своим прогрессом.
При добавлении или падении консьюмера происходит ребалансировка — переназначение партиций, на время которого чтение приостанавливается.
Что ещё стоит упомянуть:
Retention. Kafka не удаляет сообщение после чтения: оно живёт заданное время (retention.ms) или до достижения лимита размера. Это позволяет перечитать историю, сместив offset. Отдельный режим — log compaction, когда для каждого ключа хранится последнее значение.
Репликация. У партиции есть лидер и реплики-фолловеры на других брокерах; acks=all вместе с min.insync.replicas даёт гарантию, что подтверждённая запись не потеряется.
Число партиций можно только увеличивать, и увеличение ломает распределение ключей по партициям (сообщения с тем же ключом начнут попадать в другую партицию) — поэтому его планируют заранее с запасом.
WB
PythonЛегкийвопрос
Mutable и immutable типы в Python
Какие типы в Python изменяемые (mutable), а какие неизменяемые (immutable)? Почему это важно?
Mutable (изменяемые):list, dict, set, bytearray, экземпляры большинства пользовательских классов.
Неизменяемость означает, что объект нельзя модифицировать после создания: любая «модификация» создаёт новый объект. Проверяется это через id():
s = "abc"print(id(s))
s += "d"# не изменение, а создание новой строкиprint(id(s)) # другой id
lst = [1, 2]
print(id(lst))
lst.append(3) # изменение на местеprint(id(lst)) # тот же id
Почему это важно на практике — четыре следствия:
1. Ключи словарей и элементы множеств обязаны быть хешируемыми. Хеш объекта должен быть постоянным на протяжении его жизни — иначе объект «потеряется» в хеш-таблице. Поэтому изменяемые типы неприменимы как ключи:
d = {[1, 2]: "value"} # TypeError: unhashable type: 'list'
d = {(1, 2): "value"} # tuple — можно
d = {frozenset({1, 2}): 1} # frozenset — можно
2. Изменяемый аргумент по умолчанию — классическая ловушка. Значение по умолчанию вычисляется один раз при определении функции и разделяется между всеми вызовами:
defadd_item(item, target=[]): # ПЛОХО
target.append(item)
return target
add_item(1) # [1]
add_item(2) # [1, 2] — не то, что ожидалосьdefadd_item(item, target=None): # ХОРОШОif target isNone:
target = []
target.append(item)
return target
3. Присваивание не копирует объект. Две переменные могут ссылаться на один изменяемый объект, и изменение через одну видно через другую:
a = [1, 2]
b = a
b.append(3)
print(a) # [1, 2, 3]
b = a.copy() # поверхностная копияimport copy
b = copy.deepcopy(a) # глубокая копия для вложенных структур
4. Кортеж иммутабелен «на один уровень». Сам кортеж менять нельзя, но если внутри лежит список — его содержимое изменяемо, и такой кортеж перестаёт быть хешируемым:
Дополнительно стоит упомянуть кеширование малых объектов: CPython держит пул готовых int в диапазоне от −5 до 256, поэтому одинаковые небольшие числа — это буквально один и тот же объект, а числа побольше, полученные вычислением, — разные:
int('256') isint('256') # True — из пулаint('257') isint('257') # False — два разных объекта
(Литералы вроде a = 257; b = 257 в одном блоке кода дадут True из-за оптимизации констант при компиляции — поэтому проверять эффект надо на вычисленных значениях.) Отсюда практическое правило: значения сравнивать через ==, а is использовать только для None, True/False и специальных маркеров.
WB
PythonСреднийвопрос
Как передаются объекты в Python: по ссылке или по значению
Как передаются объекты в функции в Python — по ссылке или по значению? Как при этом работает сборка мусора?
Ни то, ни другое в чистом виде. Модель Python называется pass-by-object-reference (или call by sharing): в функцию передаётся ссылка на объект, но сама ссылка передаётся по значению. Практически это означает: функция может изменить объект, но не может переприсвоить переменную вызывающего кода.
defmutate(lst):
lst.append(4) # изменяем сам объект — снаружи видноdefrebind(lst):
lst = [9, 9] # переприсваиваем локальное имя — снаружи не видно
x = [1, 2, 3]
mutate(x); print(x) # [1, 2, 3, 4]
rebind(x); print(x) # [1, 2, 3, 4] — не изменилось
Для immutable-объектов эффект выглядит как «передача по значению», но причина другая: изменить их нельзя в принципе, поэтому любая операция создаёт новый объект и связывает с локальным именем.
defincrement(n):
n = n + 1# создаётся новый int, исходный не тронутreturn n
a = 5
increment(a)
print(a) # 5
Сборка мусора. В CPython работают два механизма:
1. Подсчёт ссылок (reference counting) — основной. У каждого объекта есть счётчик ссылок; когда он падает до нуля, память освобождается немедленно:
import sys
a = [1, 2, 3]
print(sys.getrefcount(a)) # на 1 больше: временная ссылка в аргументе
b = a # счётчик +1del b # счётчик −1
2. Циклический сборщик (generational GC) — дополняет первый. Подсчёт ссылок не справляется с циклическими структурами: два объекта ссылаются друг на друга, внешних ссылок нет, а счётчики не нулевые. Такие циклы находит gc, работающий по поколениям (молодые объекты проверяются чаще):
import gc
classNode:
def__init__(self):
self.ref = None
a, b = Node(), Node()
a.ref, b.ref = b, a # циклdel a, b # refcount не обнулился
gc.collect() # цикл обнаружен и собран
Что стоит добавить, чтобы ответ был полным:
Детерминированность освобождения — плюс подсчёта ссылок: объект умирает сразу, а не «когда-нибудь», как в JVM.
weakref позволяет ссылаться на объект, не увеличивая счётчик, — так разрывают циклы в кешах и наблюдателях.
Явный del удаляет имя, а не объект; объект живёт, пока на него есть другие ссылки.
В других реализациях (PyPy, Jython) подсчёта ссылок нет, используется трассирующий GC — поэтому полагаться на немедленное освобождение в переносимом коде нельзя, для ресурсов есть контекстные менеджеры (with).
WB
PythonСреднийвопрос
Декоратор @property: геттеры и сеттеры в Python
Что делает декоратор `@property`? Как реализовать геттер и сеттер?
@property превращает метод в вычисляемый атрибут: снаружи обращение выглядит как к обычному полю, а под капотом выполняется код. Это позволяет добавить логику (валидацию, вычисление, логирование) к атрибуту, не меняя интерфейс класса и не переписывая код, который этим классом пользуется.
classProduct:
def__init__(self, price: float):
self._price = price # «внутреннее» хранилище @propertydefprice(self) -> float: # геттерreturnself._price
@price.setterdefprice(self, value: float) -> None: # сеттер с валидациейifnotisinstance(value, (int, float)):
raise TypeError("price должен быть числом")
if value < 0:
raise ValueError("price не может быть отрицательной")
self._price = value
@price.deleterdefprice(self) -> None:
delself._price
@propertydefprice_with_vat(self) -> float: # только чтение, вычисляемое полеreturnround(self._price * 1.2, 2)
p = Product(100)
p.price = 150# вызывается сеттер, проходит валидацияprint(p.price) # вызывается геттер → 150print(p.price_with_vat) # 180.0
p.price = -10# ValueError
p.price_with_vat = 5# AttributeError: property has no setter
Ключевые моменты:
Имена методов должны совпадать — все три (price, price.setter, price.deleter) называются одинаково; декоратор @price.setter применяется к уже созданному объекту property.
Свойство без сеттера — только для чтения: попытка присвоить даёт AttributeError. Это удобный способ выразить вычисляемое или защищённое поле.
Хранить значение нужно в другом атрибуте (обычно _price). Если внутри геттера обратиться к self.price, получится бесконечная рекурсия.
Главная ценность — обратная совместимость. Класс начинался с публичного атрибута price, десятки мест кода делают product.price = x. Понадобилась валидация — и @property позволяет добавить её, не сломав ни одного вызова. В языках без property (Java) приходится сразу писать getPrice()/setPrice() на всякий случай; питоновский стиль — начинать с простого атрибута и вводить property только когда логика действительно понадобилась.
Что рядом стоит знать:property — частный случай дескриптора (объекта с методами __get__/__set__). Для повторяющейся логики на многих полях пишут собственный дескриптор или используют dataclasses с __post_init__. Для кешируемых вычисляемых свойств есть functools.cached_property — считает один раз и запоминает результат в __dict__ экземпляра.
WB
PythonСреднийвопрос
Инкапсуляция в Python: _x против __x
Чем отличается `_x` от `__x` в Python? Как устроена инкапсуляция?
В Python нет настоящих модификаторов доступа — инкапсуляция держится на соглашениях и одном техническом механизме.
_x — одно подчёркивание: соглашение. Означает «внутренняя деталь реализации, снаружи не трогать». Технически ничего не запрещено: атрибут доступен как обычно. Единственный реальный эффект — такие имена не импортируются при from module import *.
__x — два подчёркивания: name mangling. Интерпретатор на этапе компиляции переименовывает атрибут в _ClassName__x. Обращение по исходному имени извне даёт AttributeError, но данные всё равно доступны — по искажённому имени:
classAccount:
def__init__(self):
self._balance = 100# protected по соглашениюself.__secret = "key"# name mangling
acc = Account()
print(acc._balance) # 100 — работает, просто «не принято»print(acc.__secret) # AttributeError: 'Account' object has no attribute '__secret'print(acc._Account__secret) # 'key' — доступ есть, приватности нетprint(acc.__dict__) # {'_balance': 100, '_Account__secret': 'key'}
Зачем нужен name mangling. Его настоящая задача — не приватность, а защита от случайного переопределения в наследниках. Если базовый и дочерний классы независимо заведут атрибут __cache, они не столкнутся: они превратятся в _Base__cache и _Child__cache.
classBase:
def__init__(self):
self.__data = "base"# → _Base__datadefshow_base(self):
returnself.__data
classChild(Base):
def__init__(self):
super().__init__()
self.__data = "child"# → _Child__data, конфликта нетdefshow_child(self):
returnself.__data
c = Child()
print(c.show_base(), c.show_child()) # base child
_x
__x
Механизм
Только соглашение
Name mangling компилятором
Доступ извне
Прямой
Через _ClassName__x
Основная цель
Пометить внутреннее API
Избежать коллизий имён в иерархии
Наследование
Наследуется как есть
Каждый класс получает своё имя
Философия Python — «we are all consenting adults». Язык не мешает залезть во внутренности, но помечает границы. Ответственность за нарушение соглашения лежит на том, кто его нарушил: сломается при следующем обновлении библиотеки — сам виноват.
Отдельно про __dunder__ (двойное подчёркивание с обеих сторон): это не приватность, а зарезервированные имена протоколов языка — __init__, __len__, __eq__. Name mangling к ним не применяется, и собственные имена в таком стиле придумывать не следует.
Когда что использовать на практике: в подавляющем большинстве случаев — _x; этого достаточно, чтобы обозначить приватность, и не мешает отладке и тестам. __x оправдан в базовых классах библиотек, которые предполагают наследование и где коллизия имён реальна.
WB
PythonСреднийвопрос
Класс object — базовый класс в Python
Что такое `object` в Python?
object — корневой базовый класс всей иерархии типов. В Python 3 любой класс неявно наследуется от него, даже если это не написано:
Запись class Foo(object): — наследие Python 2, где существовали «старые» классы (old-style) и наследование от object явно создавало новый тип. В Python 3 обе записи эквивалентны, и явное указание избыточно.
Что даёт object. Он определяет поведение по умолчанию для базовых протоколов языка — dunder-методов, которые наследует каждый класс:
Метод
Поведение по умолчанию
__init__
Ничего не делает
__new__
Создаёт и возвращает экземпляр
__str__
Возвращает результат __repr__
__repr__
<__main__.Foo object at 0x7f...>
__eq__
Сравнение по идентичности (is)
__hash__
Хеш от id() объекта
__getattribute__, __setattr__, __delattr__
Доступ к атрибутам
__dir__, __class__, __sizeof__
Интроспекция
Именно поэтому любой объект можно положить в множество и сравнить с другим — реализация уже унаследована.
Практические следствия:
Переопределяя __eq__, нужно переопределить и __hash__. Python автоматически ставит __hash__ = None при определении __eq__, и объекты становятся нехешируемыми — их больше нельзя класть в set и использовать как ключи словаря. Либо задают согласованный __hash__, либо используют @dataclass(frozen=True), который делает это сам.
object() — минимальный объект-«часовой», полезный как уникальный маркер: SENTINEL = object() для отличия «параметр не передан» от None.
У object нет __dict__, поэтому «голому» экземпляру нельзя присвоить атрибут: object().x = 1 → AttributeError. У пользовательских классов __dict__ появляется, если не задан __slots__.
type и object связаны рекурсивно:type — метакласс (класс классов), при этом type наследуется от object, а object является экземпляром type. Это основа модели типов Python, вопрос-продолжение на собеседовании часто именно про это.
Короткая формулировка: «в Python всё является объектом, и object — вершина этой иерархии; он задаёт минимальный контракт поведения, который наследуют все типы».
WB
PythonСреднийвопрос
Процесс и поток: в чём разница и как влияет GIL
В чём разница между процессом и потоком? Как это связано с GIL в Python?
Процесс — самостоятельная единица исполнения со своим адресным пространством, дескрипторами и ресурсами. Процессы изолированы: сбой одного не роняет остальные, а обмен данными требует явного механизма (IPC, очереди, сокеты, разделяемая память).
Поток — единица исполнения внутри процесса. Потоки одного процесса разделяют память и ресурсы, поэтому обмен данными между ними тривиален, но требует синхронизации (блокировки, семафоры) и чреват гонками.
Процесс
Поток
Память
Своя, изолированная
Общая с другими потоками процесса
Создание
Дорогое
Дешёвое
Обмен данными
Через IPC, сериализацию
Напрямую через общие объекты
Сбой
Не влияет на другие процессы
Может уронить весь процесс
Переключение контекста
Дороже
Дешевле
GIL (Global Interpreter Lock) — специфика CPython: глобальная блокировка, из-за которой байт-код Python в один момент времени исполняет только один поток процесса. GIL нужен, потому что подсчёт ссылок в CPython не потокобезопасен; одна глобальная блокировка проще и быстрее для однопоточного кода, чем блокировки на каждый объект.
Практический вывод: многопоточность в Python не ускоряет CPU-bound задачи. Четыре потока, считающие числа, отработают не быстрее одного (а часто медленнее — из-за накладных расходов на переключение).
Но GIL освобождается, когда поток уходит в ожидание: сетевой запрос, чтение файла, time.sleep(), а также внутри многих C-расширений (NumPy, pandas на тяжёлых операциях освобождают GIL). Поэтому для IO-bound нагрузки потоки работают отлично.
Как выбирать инструмент:
Тип нагрузки
Инструмент
Почему
CPU-bound (вычисления, парсинг, сжатие)
multiprocessing, ProcessPoolExecutor
Каждый процесс со своим GIL — настоящий параллелизм по ядрам
IO-bound, немного задач
threading, ThreadPoolExecutor
GIL освобождается на ожидании
IO-bound, тысячи соединений
asyncio
Кооперативная многозадачность без накладных расходов на потоки
Тяжёлая математика
NumPy/pandas/Polars
Считает в C с освобождённым GIL, векторизованно
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
# CPU-bound: процессыwith ProcessPoolExecutor() as pool:
results = list(pool.map(heavy_compute, chunks))
# IO-bound: потокиwith ThreadPoolExecutor(max_workers=32) as pool:
results = list(pool.map(fetch_url, urls))
Что добавит ответу веса:
Плата за процессы — сериализация (pickle) аргументов и результатов; передавать между процессами большие датафреймы дорого, иногда выгоднее общая память или файл на диске.
GIL — деталь CPython, а не языка: в Jython и IronPython его нет. В Python 3.13 появился экспериментальный режим free-threaded (PEP 703) со сборкой без GIL.
В распределённой обработке (Spark, Airflow с KubernetesExecutor) вопрос GIL снимается сам: параллелизм достигается на уровне процессов и узлов, а не потоков внутри одного интерпретатора.
WB
PythonСреднийвопрос
MRO: как Python разрешает порядок наследования
Что такое MRO в Python? Как разрешается множественное наследование?
MRO (Method Resolution Order) — порядок, в котором Python ищет метод или атрибут по иерархии классов. Он вычисляется один раз при создании класса алгоритмом C3-линеаризации и доступен через __mro__ или mro():
classA:
defhello(self): return"A"classB(A):
defhello(self): return"B"classC(A):
defhello(self): return"C"classD(B, C):
passprint(D().hello()) # 'B'print([c.__name__ for c in D.__mro__])
# ['D', 'B', 'C', 'A', 'object']
Правила C3-линеаризации:
класс всегда идёт раньше своих родителей;
порядок родителей сохраняется таким, как он указан в объявлении (слева направо);
каждый класс встречается в списке ровно один раз;
порядок должен быть согласован для всех классов иерархии — иначе Python откажется создавать класс:
classX: passclassY: passclassA(X, Y): passclassB(Y, X): pass# порядок баз обратныйclassC(A, B): ...
# TypeError: Cannot create a consistent method resolution order (MRO) for bases X, Y
Здесь A требует, чтобы X шёл раньше Y, а B — наоборот; согласованного порядка не существует, и класс не создаётся.
Классическая «ромбовидная» проблема. В D(B, C), где B и C наследуют A, наивный обход в глубину дошёл бы до A раньше, чем до C, и метод C никогда бы не вызвался. C3 ставит Aпосле обоих потомков — поэтому порядок D → B → C → A → object.
Как это связано с super(). Распространённое заблуждение — что super() вызывает «родительский класс». На самом деле super() идёт к следующему классу в MRO текущего объекта, а он зависит от фактического типа экземпляра, а не от места в коде:
classA:
def__init__(self): print("A");
classB(A):
def__init__(self): print("B"); super().__init__()
classC(A):
def__init__(self): print("C"); super().__init__()
classD(B, C):
def__init__(self): print("D"); super().__init__()
D() # D B C A — super() в B ведёт к C, а не к A
Именно это делает возможным кооперативное множественное наследование и паттерн миксинов: каждый класс вызывает super().__init__(), и вся цепочка отрабатывает ровно один раз.
Практические правила:
в кооперативных иерархиях всегда вызывать super(), а не A.__init__(self) напрямую — прямой вызов ломает цепочку и может привести к двойной инициализации;
миксины ставить левее основного класса: class MyView(LoggingMixin, BaseView), чтобы их методы шли раньше в MRO;
проверять ClassName.__mro__ при отладке — это самый быстрый способ понять, откуда пришёл метод;
не выстраивать глубокие иерархии множественного наследования: композиция обычно понятнее и предсказуемее.
Полезно упомянуть, что до Python 2.3 использовался обход в глубину слева направо, и именно его недостатки (пропуск методов при ромбе) привели к появлению C3.
Яндекс
SQLСреднийзадача
Разметка сессий пользователя по разрыву в 1 минуту
Есть исходные данные в виде CTE. В таблице записаны отметки времени, когда пользователь совершил некоторое действие.
Напиши запрос, который разметит каждую строку порядковым номером сессии пользователя `sess_id`, считая, что в одну сессию объединяются последовательные действия, между которыми прошло не больше одной минуты.
```sql
WITH actions (user_id, action_time) AS (
VALUES
(1, '2026-08-08 10:00:00'::timestamp),
(1, '2026-08-08 10:00:30'),
(1, '2026-08-08 10:01:30'),
(1, '2026-08-08 10:03:00'),
(1, '2026-08-08 10:03:20'),
(2, '2026-08-08 11:00:00'),
(2, '2026-08-08 11:05:00')
)
SELECT * FROM actions;
```
Классическая задача сессионизации (gaps and islands). Решается в три шага.
Шаг 1. Через LAG берём время предыдущего действия того же пользователя.
Шаг 2. Ставим флаг 1, если разрыв больше минуты (началась новая сессия), иначе 0.
Шаг 3. Считаем накопительную сумму флагов — она и есть номер сессии.
WITH actions (user_id, action_time) AS (
VALUES
(1, '2026-08-08 10:00:00'::timestamp),
(1, '2026-08-08 10:00:30'),
(1, '2026-08-08 10:01:30'),
(1, '2026-08-08 10:03:00'),
(1, '2026-08-08 10:03:20'),
(2, '2026-08-08 11:00:00'),
(2, '2026-08-08 11:05:00')
),
marked AS (
SELECT
user_id,
action_time,
CASEWHEN action_time -LAG(action_time) OVER (
PARTITIONBY user_id ORDERBY action_time
) <=INTERVAL'1 minute'THEN0ELSE1ENDAS is_new_session
FROM actions
)
SELECT
user_id,
action_time,
SUM(is_new_session) OVER (
PARTITIONBY user_id ORDERBY action_time
ROWSBETWEEN UNBOUNDED PRECEDING ANDCURRENTROW
) AS sess_id
FROM marked
ORDERBY user_id, action_time;
Результат:
user_id
action_time
разрыв
sess_id
1
10:00:00
—
1
1
10:00:30
30 сек
1
1
10:01:30
60 сек
1
1
10:03:00
90 сек
2
1
10:03:20
20 сек
2
2
11:00:00
—
1
2
11:05:00
5 мин
2
Ключевые моменты, которые проверяет интервьюер:
1. Почему первая строка автоматически открывает сессию. У первого действия пользователя LAG возвращает NULL, сравнение NULL <= INTERVAL '1 minute' даёт UNKNOWN, а не TRUE — поэтому CASE уходит в ELSE и ставит 1. Отдельно обрабатывать первую строку не нужно, но объяснить это стоит: логика опирается на трёхзначную логику SQL.
2. PARTITION BY user_id обязателен в обоих окнах. Без него разрыв будет считаться между действиями разных людей. На «удобных» данных, где пользователи идут блоками, ошибка не проявится, но стоит событиям чередоваться — и разметка ломается:
3. Граница «не больше минуты» — это <=, а не <. Разрыв ровно в 60 секунд по условию оставляет действие в той же сессии. На собеседовании этот момент стоит проговорить вслух: формулировки «не больше», «менее», «более» задают строгость сравнения, и уточнить её — признак аккуратности.
4. Рамка окна.SUM(...) OVER (... ORDER BY ...) по умолчанию использует RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, что при одинаковых значениях времени даст одинаковую сумму для всех строк-дублей. Явное ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW устраняет эту неоднозначность — привычка писать рамку явно спасает от трудноуловимых багов.
Варианты и обобщения:
Сквозная нумерация сессий по всей таблице (а не с единицы для каждого пользователя) — убрать PARTITION BY во втором окне, оставив его в первом:
SUM(is_new_session) OVER (ORDERBY user_id, action_time ROWS UNBOUNDED PRECEDING)
Уникальный идентификатор сессии для последующих джойнов удобно собирать как user_id || '_' || sess_id или брать время начала сессии через FIRST_VALUE/MIN в окне партиции по (user_id, sess_id).
В ClickHouse есть готовая функция для этого шаблона:
SELECT user_id, action_time,
runningDifference(action_time) AS gap
FROM actions ORDERBY user_id, action_time
а также sessionizeWindow-подобные конструкции в новых версиях; в BigQuery и Snowflake решение остаётся тем же самым — LAG + накопительная сумма.
Этот шаблон (флаг + SUM() OVER) универсален: так же размечают периоды непрерывной подписки, серии дней активности (streak), интервалы работы оборудования без простоя.
Т-Банк
PythonЛегкийзадача
Перенести нули в конец массива, сохранив порядок
Перенести все нули в конец массива, сохранив относительный порядок остальных элементов.
```
[1, 0, 8, 9] -> [1, 8, 9, 0]
[0, 1, 0, 3, 12] -> [1, 3, 12, 0, 0]
```
Оптимальное решение — два указателя, in-place, O(n) по времени и O(1) по памяти.
Указатель pos отмечает позицию, куда встанет следующий ненулевой элемент. Проходим массив: встретив ненулевое значение, меняем его местами с элементом на позиции pos и сдвигаем pos. Все нули естественным образом вытесняются в хвост.
Почему порядок сохраняется. Ненулевые элементы записываются в том же порядке, в каком встречаются при проходе слева направо. Обмен безопасен: на позиции pos к этому моменту либо ноль, либо сам элемент nums[i] (когда pos == i и обмен вырожденный).
Небольшая оптимизация — пропускать бессмысленный обмен, когда указатели совпали:
defmove_zeros(nums: list[int]) -> list[int]:
pos = 0for i inrange(len(nums)):
if nums[i] != 0:
if i != pos:
nums[pos], nums[i] = nums[i], nums[pos]
pos += 1return nums
Альтернатива без изменения исходного списка — если требование in-place не ставится, читаемее всего так:
defmove_zeros(nums: list[int]) -> list[int]:
non_zero = [x for x in nums if x != 0]
return non_zero + [0] * (len(nums) - len(non_zero))
O(n) по времени, но O(n) дополнительной памяти.
Однострочник через стабильную сортировку — хорош как демонстрация знания языка, но проигрывает по сложности O(n log n):
sorted(nums, key=lambda x: x == 0) # False(0) сортируется раньше True(1)
Работает потому, что сортировка в Python стабильна и сохраняет исходный порядок среди равных ключей.
О чём стоит сказать на собеседовании:
Уточнить требования до написания кода: нужно ли менять массив на месте или можно вернуть новый; важен ли порядок нулей между собой (они неразличимы); гарантированы ли только числа.
In-place или нет — принципиальная разница. Первое решение мутирует переданный список (вызывающий код увидит изменения), второе возвращает новый объект и оставляет исходный нетронутым. На ревью это разное поведение, и функция должна его явно документировать.
Число записей в память. Версия со swap делает две записи на каждый «полезный» обмен; альтернатива — сначала сдвинуть ненулевые влево простым присваиванием, затем заполнить хвост нулями. На массиве [1, 2, 3, 0, 0, 0, 4, 5] swap-версия делает 4 записи, а «сдвиг + заполнение» — 8. На практике разница несущественна, но упоминание таких деталей ценится.
Ловушка False == 0. В Python False равен нулю, а True равен единице, поэтому булевы значения обрабатываются как числа: [1, False, 2, 0, True] превратится в [1, 2, True, 0, False]. То же касается 0.0 и 0j. Если нужно переносить строго целочисленный ноль, проверка должна быть строже: not (isinstance(x, int) and not isinstance(x, bool) and x == 0).
Сложность: время O(n), память O(1), один проход по массиву. Решение через list.remove(0) в цикле даёт O(n²) — типичная ошибка, которую стоит назвать и отбросить.
Замечание к условию. В исходной формулировке пример записан как [1, 0, 8, 9] -> [1, 8, 9, 8], но это опечатка: длина массива не меняется, а ноль переезжает в конец, поэтому правильный результат — [1, 8, 9, 0].
СберДевайсы
SQLСреднийзадача
Размеры когорт по месяцам активации устройств
Посчитать количество активаций устройств по месяцам. Активация устройства — его первая сессия. Когорта — группа устройств, активированных в определённом месяце.
**Таблица** `sessions` — все сессии активности устройств (сессия — период непрерывного использования):
| Колонка | Описание |
|---|---|
| `device_id` | id устройства |
| `device_type` | тип устройства (`box` / `tv`) |
| `session_id` | id сессии активности |
| `session_ts` | дата-время начала сессии |
**Вывести:**
- тип устройства (например, `tv`)
- месяц активации (например, январь 2025)
- количество устройств, активированных в этом месяце (например, 10000)
Задача из тестового на позицию аналитика, решается в DuckDB.
Задача на агрегацию в два шага: сначала для каждого устройства находим момент активации, затем считаем устройства по месяцам.
WITH activation AS (
SELECT
device_id,
device_type,
MIN(session_ts) AS activation_ts
FROM sessions
GROUPBY device_id, device_type
)
SELECT
device_type,
DATE_TRUNC('month', activation_ts) AS cohort_month,
COUNT(DISTINCT device_id) AS devices_activated
FROM activation
GROUPBY device_type, cohort_month
ORDERBY cohort_month, device_type;
Результат (фрагмент):
device_type
cohort_month
devices_activated
box
2024-11-01
218
tv
2024-11-01
227
box
2024-12-01
478
tv
2024-12-01
452
…
…
…
Ключевые моменты:
1. Активация — это MIN(session_ts), а не любая сессия. Наивный запрос SELECT DATE_TRUNC('month', session_ts), COUNT(DISTINCT device_id) FROM sessions GROUP BY 1 посчитает устройство в каждом месяце, где оно было активно, — это метрика активных устройств (MAU), а не активаций. Устройство активируется ровно один раз, поэтому сначала нужно схлопнуть таблицу до одной строки на устройство.
2. DATE_TRUNC('month', ...) вместо форматирования в строку. Обрезка до месяца сохраняет тип даты, поэтому сортировка идёт хронологически. Вариант strftime(session_ts, '%Y-%m') тоже работает, но '2025-1' и '2025-10' при строковой сортировке встанут неверно, если не дополнять нулями.
3. Особенность этих данных, которую стоит заметить и проговорить. В сгенерированном датасете device_type проставлен случайно каждой сессии, а не закреплён за устройством: из 3000 устройств у 2638 встречаются сессии обоих типов. Это артефакт генератора, но он меняет ответ:
группировка по паре (device_id, device_type) — как в запросе выше — даёт 5638 строк-«устройств», потому что устройство с двумя типами попадает в две когорты;
группировка только по device_id с типом из первой сессии даёт корректные 3000 устройств:
WITH activation AS (
SELECT
device_id,
MIN(session_ts) AS activation_ts,
ARG_MIN(device_type, session_ts) AS device_type -- тип первой сессииFROM sessions
GROUPBY device_id
)
SELECT device_type, DATE_TRUNC('month', activation_ts) AS cohort_month, COUNT(*) AS devices_activated
FROM activation
GROUPBY1, 2ORDERBY cohort_month, device_type;
Правильное поведение на собеседовании — не молча выбрать вариант, а сказать: «в данных тип не закреплён за устройством, сумма по когортам превысит число устройств; уточняю, считаем активацию на устройство или на пару устройство-тип». Именно такие вопросы отличают аналитика от исполнителя SQL.
4. COUNT(DISTINCT device_id) против COUNT(*). После группировки в CTE каждая строка уже уникальна, поэтому обе формы дают одно и то же. DISTINCT здесь — страховка от того, что кто-то позже изменит CTE и в нём появятся дубли.
Полезное продолжение: такой запрос — заготовка для когортного анализа. Добавив к нему таблицу активности по месяцам, получают классическую retention-матрицу.
СберДевайсы
SQLСложныйзадача
Когортный retention устройств по месяцам
Посчитать когортный Retention. Когорты формируются по месяцам активации (активация — первая сессия устройства). Нужно рассчитать, какой процент устройств активен (есть сессии) на n-й месяц после активации.
**Таблица** `sessions`:
| Колонка | Описание |
|---|---|
| `device_id` | id устройства |
| `device_type` | тип устройства (`box` / `tv`) |
| `session_id` | id сессии активности |
| `session_ts` | дата-время начала сессии |
**Вывести:**
- тип устройства (например, `tv`)
- месяц когорты активации (например, январь 2025)
- количество устройств в когорте (например, 10000)
- номер месяца активности от активации устройства (например, 3)
- доля вернувшихся устройств в этот номер месяца (например, 0.30)
Задача из тестового на позицию аналитика, решается в DuckDB.
Решение строится из трёх блоков: когорта каждого устройства → размер когорты → факты активности с номером месяца.
WITH activation AS ( -- 1. когорта каждого устройстваSELECT
device_id,
device_type,
DATE_TRUNC('month', MIN(session_ts)) AS cohort_month
FROM sessions
GROUPBY device_id, device_type
),
cohort_size AS ( -- 2. размер каждой когорты (знаменатель)SELECT device_type, cohort_month, COUNT(*) AS cohort_devices
FROM activation
GROUPBY device_type, cohort_month
),
activity AS ( -- 3. в каком месяце жизни устройство было активноSELECTDISTINCT
a.device_type,
a.cohort_month,
s.device_id,
DATE_DIFF('month', a.cohort_month, DATE_TRUNC('month', s.session_ts)) AS month_number
FROM sessions s
JOIN activation a
ON a.device_id = s.device_id
AND a.device_type = s.device_type
)
SELECT
c.device_type,
c.cohort_month,
c.cohort_devices,
act.month_number,
ROUND(COUNT(DISTINCT act.device_id) *1.0/ c.cohort_devices, 4) AS retention
FROM cohort_size c
JOIN activity act
ON act.device_type = c.device_type
AND act.cohort_month = c.cohort_month
GROUPBY c.device_type, c.cohort_month, c.cohort_devices, act.month_number
ORDERBY c.device_type, c.cohort_month, act.month_number;
Результат (фрагмент):
device_type
cohort_month
cohort_devices
month_number
retention
box
2024-11-01
218
0
1.0000
box
2024-11-01
218
1
0.6835
box
2024-11-01
218
2
0.6789
box
2024-11-01
218
3
0.6651
box
2024-11-01
218
4
0.5229
Ключевые моменты:
1. Знаменатель всегда один — размер когорты. Доля считается от числа устройств, активированных в этом месяце, а не от числа активных в предыдущем месяце. Деление на предыдущий месяц — это другая метрика (месячный отток), и её часто путают с retention.
2. Месяц 0 обязан быть равен 1.0. Это встроенная проверка корректности: в месяц активации у устройства по определению есть сессия, значит вернулись все 100%. Если в выводе месяц 0 не равен единице, где-то ошибка в джойне или в знаменателе.
3. DATE_DIFF по месяцам, а не разница дат в днях.DATE_DIFF('month', ...) считает именно число календарных месяцев между обрезанными датами. Попытка получить номер месяца делением разницы дней на 30 даёт съезжающие границы и неверную нумерацию на длинных горизонтах.
4. DISTINCT в блоке активности обязателен. У устройства в одном месяце обычно несколько сессий; без DISTINCT одно устройство было бы посчитано несколько раз, и retention легко превысил бы 100%.
5. Пропуски месяцев — это норма. Если в когорте никто не заходил в 3-й месяц, строки с month_number = 3 просто не будет. Для полноценной матрицы (с нулями в пустых ячейках) генерируют полный набор номеров месяцев и джойнят к нему факты:
FROM cohort_size c
CROSSJOIN generate_series(0, 12) AS m(month_number)
LEFTJOIN activity act ON ...
Это же нужно, чтобы визуализация в виде треугольной таблицы не «съезжала».
6. Незрелые когорты нельзя сравнивать напрямую. Когорта июля 2025 физически не может иметь 5-й месяц жизни, если данные заканчиваются в августе. При сравнении когорт между собой смотрят на одинаковый номер месяца, а неполные периоды либо отбрасывают, либо явно помечают.
Что стоит добавить в ответе:
Classic retention (реализован выше) — «был ли активен именно в n-й месяц». Rolling retention — «был ли активен в n-й месяц или позже», он монотонно убывает и удобнее для оценки оттока насовсем.
В этих данных device_type не закреплён за устройством (случаен на уровне сессии), поэтому джойн активности идёт по паре (device_id, device_type) — это согласуется с расчётом размеров когорт в предыдущей задаче. Если считать тип по первой сессии, джойнить нужно только по device_id.
Для вывода в виде матрицы поверх этого запроса делают PIVOT (в DuckDB он есть в явном виде) или условную агрегацию MAX(CASE WHEN month_number = 1 THEN retention END).
СберДевайсы
PythonСреднийзадача
Воронка: продажа → активация → активность в 2026
Построить воронку `продажа → активация → активность в 2026` на pandas. Активация — первая сессия устройства.
**Таблица** `devices` — все уникальные устройства:
| Колонка | Описание |
|---|---|
| `device_id` | id устройства |
| `device_type` | тип устройства (`box` / `tv` / `ring`) |
| `model` | модель устройства |
| `sale_date` | дата продажи |
| `sale_channel` | канал продажи |
**Таблица** `sessions` — все сессии активности устройств:
| Колонка | Описание |
|---|---|
| `device_id` | id устройства |
| `session_id` | id сессии активности |
| `session_ts` | дата-время начала сессии |
**Вывести** в разрезе типа устройства: количество проданных устройств, количество активированных, количество активных в 2026 году и конверсии между шагами.
Ключевая идея: воронка строится от таблицы устройств через левые джойны, чтобы на каждом шаге сохранялись все проданные устройства, а не только те, что дошли до следующего этапа.
import pandas as pd
devices = pd.read_csv('data_python/devices.csv', parse_dates=['sale_date'])
sessions = pd.read_csv('data_python/sessions.csv', parse_dates=['session_ts'])
# шаг 2 воронки: активация = первая сессия устройства
activation = (sessions.groupby('device_id', as_index=False)['session_ts']
.min()
.rename(columns={'session_ts': 'activation_ts'}))
# шаг 3 воронки: устройства с хотя бы одной сессией в 2026 году
active_2026 = (sessions.loc[sessions.session_ts.dt.year == 2026, ['device_id']]
.drop_duplicates()
.assign(is_active_2026=True))
base = (devices[['device_id', 'device_type']]
.merge(activation, on='device_id', how='left') # left join — не теряем непроактивированные
.merge(active_2026, on='device_id', how='left'))
base['is_active_2026'] = base['is_active_2026'].fillna(False)
funnel = base.groupby('device_type').agg(
sold=('device_id', 'count'),
activated=('activation_ts', 'count'), # count игнорирует NaT — считает только активированные
active_2026=('is_active_2026', 'sum'),
).reset_index()
funnel['cr_sale_to_activation'] = (funnel.activated / funnel.sold).round(4)
funnel['cr_activation_to_active'] = (funnel.active_2026 / funnel.activated).round(4)
funnel['cr_total'] = (funnel.active_2026 / funnel.sold).round(4)
print(funnel)
Результат:
device_type
sold
activated
active_2026
продажа→активация
активация→актив. 2026
сквозная
box
4501
3744
1926
0.8318
0.5144
0.4279
ring
979
815
410
0.8325
0.5031
0.4188
tv
4520
3747
1888
0.8290
0.5039
0.4177
Ключевые моменты:
1. Только how='left'. Внутренний джойн выбросил бы непроактивированные устройства, и первый шаг воронки схлопнулся бы до второго — конверсия «продажа → активация» всегда получалась бы 100%. Воронка обязана строиться от самого широкого множества.
2. count() не считает пропуски. В агрегации activated=('activation_ts', 'count') это работает в нашу пользу: у неактивированных устройств там NaT, и они не попадают в счётчик. Это лаконичнее, чем notna().sum(), но требует понимания: count — число непустых значений, а не число строк (для числа строк есть size).
3. Две конверсии, а не одна. Пошаговая конверсия (activated / sold) показывает потери на конкретном переходе, сквозная (active_2026 / sold) — итоговую эффективность. Смешивать их нельзя: сквозная равна произведению пошаговых, и путаница в знаменателе — классическая ошибка в отчётах.
4. Проверка на сходимость. Числа воронки обязаны монотонно убывать: sold ≥ activated ≥ active_2026. Полезно проверять это утверждением в коде — так ошибки в джойнах ловятся сразу:
5. session_ts.dt.year == 2026 требует datetime. При чтении CSV колонка приедет строкой, поэтому parse_dates обязателен — иначе .dt выбросит AccessorError. Альтернатива — явное pd.to_datetime(...) после чтения.
Что можно улучшить в ответе:
Добавить абсолютные потери между шагами (sold - activated) — бизнесу важнее «потеряли 757 устройств», чем «конверсия 83%».
Заметить, что доля активированных ~83% одинакова для всех типов — это признак того, что фактор не связан с продуктом; искать причину надо в других разрезах (канал продаж, модель, месяц продажи).
Для устойчивого сравнения когорт стоит ограничить выборку устройствами, проданными достаточно давно: у проданных в конце периода просто не было времени активироваться.
СберДевайсы
PythonСреднийзадача
Доля активных колец по каналам продаж с диаграммой
Посчитать процент активных в 2026 году колец (`device_type = 'ring'`) от проданных, в разрезе каналов продаж, и показать результат на диаграмме.
**Таблицы:** `devices` (`device_id`, `device_type`, `model`, `sale_date`, `sale_channel`) и `sessions` (`device_id`, `session_id`, `session_ts`).
**Вывести:**
- канал продажи (например, `ozon`)
- процент активных в 2026 году устройств (например, 0.4)
- чарт
Расчёт сводится к фильтрации по типу устройства и доле активных внутри каждого канала.
Диаграмма. Для сравнения долей по категориям правильный выбор — горизонтальный бар-чарт, отсортированный по значению: так названия каналов читаются без наклона, а порядок сразу задаёт ранжирование.
fig, ax = plt.subplots(figsize=(8, 4))
data = by_channel.sort_values('active_share') # снизу вверх по возрастанию
bars = ax.barh(data.sale_channel, data.active_share, color='#2a6770')
ax.set_xlabel('Доля активных в 2026')
ax.set_title('Доля активных колец от проданных, по каналам продаж')
ax.set_xlim(0, max(data.active_share) * 1.15)
ax.spines[['top', 'right']].set_visible(False)
# подписи значений и объём выборки — иначе доли не с чем соотнестиfor bar, share, sold inzip(bars, data.active_share, data.sold):
ax.text(bar.get_width() + 0.005, bar.get_y() + bar.get_height() / 2,
f'{share:.1%} (n={sold})', va='center', fontsize=9)
plt.tight_layout()
plt.show()
Ключевые моменты:
1. Знаменатель — все проданные кольца канала, а не активированные. По условию считается доля от проданных, поэтому устройства, которые вообще никогда не включали, обязаны остаться в знаменателе. Использование merge(how='inner') с сессиями молча выкинуло бы их и завысило результат.
2. isin вместо джойна. Для проверки «был ли активен» достаточно множества идентификаторов: isin по set работает за O(1) на элемент и не размножает строки, в отличие от merge с таблицей сессий, где у устройства десятки записей.
3. Сумма по каналам должна сойтись с числом колец — 979 в этих данных. Простая проверка by_channel.sold.sum() == (devices.device_type == 'ring').sum() ловит потерянные строки.
4. Размер выборки обязателен на графике. Разброс долей здесь невелик (36–47%), а в каждом канале около 200 колец. При таком объёме разница между соседними каналами статистически неубедительна: 95%-й доверительный интервал для доли при n = 200 имеет полуширину примерно ±7 процентных пунктов. Поэтому вывод «wb лучше site» требует проверки, а не констатации — на графике стоит показывать n, а в идеале и доверительные интервалы:
import numpy as np
p = by_channel.active_share
n = by_channel.sold
by_channel['ci95'] = 1.96 * np.sqrt(p * (1 - p) / n) # ≈ 0.07 при n ≈ 200
5. Почему бар-чарт, а не круговая диаграмма. Доли здесь не части одного целого (каналы не суммируются в 100%), а независимые метрики — pie chart был бы содержательно неверен. Сортировка по значению и общая базовая линия делают сравнение точным.
Что усилит ответ: отметить, что канал продаж коррелирует не только с качеством аудитории, но и с датой продажи — если через site кольца продавались в основном недавно, у них было меньше времени «дожить» до активности в 2026. Корректное сравнение требует фиксации периода продажи или расчёта доли активных на одинаковом горизонте жизни устройства.
WB Travel
КейсыСложныйкейс
Продакт хочет изменить дефолтную сортировку выдачи
В сервисе путешествий дефолтная сортировка выдачи — по популярности. Продакт хочет её изменить.
Какие уточняющие вопросы нужно задать по поводу этих изменений? Также предложи, какие варианты сортировки (ранжирования) можно сделать.
Дефолтная сортировка — самая дорогая настройка в любом маркетплейсе: её видят почти все пользователи, потому что подавляющее большинство никогда не меняет фильтры. Поэтому первым делом выясняем, какую проблему решаем, а не как именно менять.
Уточняющие вопросы
1. Зачем меняем? Какая проблема или гипотеза за этим стоит.
Что не так с текущей сортировкой: жалобы пользователей, низкая конверсия, отчёт конкурента, интуиция?
Есть ли данные: как ведут себя пользователи на выдаче — докручивают ли до конца, уходят ли без клика, часто ли переключают сортировку вручную?
Какую бизнес-цель преследуем: рост конверсии, среднего чека, повторных покупок, удовлетворённости?
2. Как устроена текущая сортировка «по популярности».
Что именно в ней зашито: число бронирований, просмотров, кликов, за какой период?
Есть ли перекос в сторону старых объектов? Популярность — самоусиливающаяся метрика: что показали выше, то чаще покупают, что чаще покупают — то ещё выше. Новые хорошие отели могут не иметь шанса в принципе.
3. Целевая метрика и защитные метрики.
Что считаем успехом: CR в бронирование, GMV, выручка сервиса, доля отмен, NPS?
Какие метрики не должны просесть: доля отмен и возвратов, средний чек, повторные покупки, доля объектов, получающих хотя бы один показ (охват предложения)?
Особенность travel: успех — это не бронирование, а состоявшаяся поездка. Метрику надо смотреть с учётом отмен и заезда, а лаг между покупкой и потреблением — недели и месяцы.
4. Для кого и где.
Меняем для всех или для сегментов (новые/возвращающиеся, мобильные/десктоп, гео, тип поездки — командировка или отпуск)?
На всех экранах или только в поиске? В категориях, подборках, на главной сортировка может быть своя.
Что с пустыми и узкими выдачами — там сортировка почти не влияет.
5. Бизнес-ограничения.
Есть ли платное продвижение, партнёрские обязательства, разная комиссия по объектам? Изменение сортировки напрямую бьёт по доходу от рекламы и по договорённостям с партнёрами.
Есть ли юридические требования к прозрачности ранжирования (в ряде юрисдикций критерии сортировки нужно раскрывать пользователю)?
6. Как измеряем и откатываем.
Готовы ли катить через A/B тест? Какой длительности, хватит ли трафика с учётом длинного цикла принятия решения?
Что считаем критерием отката, кто принимает решение?
Как ловим долгосрочные эффекты: рост бронирований сегодня может обернуться ростом отмен и падением повторных покупок через месяцы.
7. Технические ограничения.
Сколько признаков доступно в реальном времени, какой бюджет по latency, есть ли инфраструктура для ML-ранжирования и логирования фичей?
Варианты сортировки
Простые (по одному признаку) — как опции для пользователя, не как дефолт:
Вариант
Плюсы
Минусы
Цена по возрастанию
Понятно, честно
Наверх лезет мусор без удобств; падает средний чек
Рейтинг
Отражает качество
Отель с одним отзывом на 5,0 обходит отель с 900 отзывами на 4,8 — нужна поправка на объём (например, нижняя граница доверительного интервала или байесовское сглаживание)
Расстояние до центра / точки интереса
Важно для командировок
Не универсально: для пляжного отдыха бессмысленно
Новизна
Даёт шанс новым объектам
Новизна не равна качеству
Композитный скор — реалистичный дефолт. Взвешенная сумма нормированных признаков: качество (рейтинг с поправкой на число отзывов), релевантность запросу, цена относительно медианы по выдаче, историческая конверсия объекта, доступность и условия отмены, штрафы за жалобы и отмены со стороны отеля. Прозрачен, легко объясним, веса настраиваются вручную — хорошая отправная точка.
ML-ранжирование (learning to rank) — модель предсказывает вероятность целевого события и сортирует по ожидаемой ценности. Ключевые решения:
Что оптимизируем. Ранжирование по P(клик) даёт кликбейт; по P(бронирование) — уже лучше; по ожидаемой ценностиP(бронирование) × маржа × (1 − P(отмены)) — ближе всего к бизнесу.
Смещение позиции (position bias). Обучение на исторических кликах закрепляет текущую выдачу: клики есть только у того, что показывали сверху. Лечится взвешиванием на обратную склонность (IPS) или подмешиванием случайности в выдачу для сбора несмещённых данных.
Персонализация — учёт истории пользователя: ценовой сегмент, предпочитаемый тип жилья, направление, состав поездки. Даёт наибольший прирост, но требует профиля пользователя и деградирует для новых пользователей — нужен разумный fallback.
Гибрид с исследованием (exploration). Даже с хорошей моделью часть позиций отводят под ротацию новых и малопоказанных объектов — иначе система застревает в локальном оптимуме и новое предложение не имеет шанса. Обычная реализация — эпсилон-жадная подмешка или Thompson sampling.
Диверсификация выдачи. Топ, состоящий из десяти почти одинаковых отелей одной сети в одном районе, хуже, чем разнообразный по цене, району и типу жилья. Здесь помогают MMR или правила «не более N объектов одного типа подряд».
Что предложить в итоге. Начать с композитного скора с явными весами и запустить его в A/B против текущей популярности, параллельно собирая данные с элементом рандомизации для будущей ML-модели. Дефолт менять только после проверки на защитных метриках, а пользователям дополнительно дать явные опции сортировки — это одновременно и сервис, и источник сигнала о том, чего им не хватает в дефолте.
WB Travel
A/B тестыСреднийвопрос
Полный процесс A/B тестирования от гипотезы до раскатки
Расскажи полный процесс A/B тестирования — как он происходит от начала до конца?
Процесс удобно излагать по этапам: до теста, во время и после. Главное — показать, что все решения (метрика, размер выборки, критерии) фиксируются до запуска.
1. Гипотеза
Формулируется проверяемо: что меняем, на что это повлияет, почему и насколько. «Если заменить дефолтную сортировку на композитный скор, конверсия в бронирование вырастет на 2%, потому что релевантные объекты попадут в первый экран».
Здесь же — прикидка ценности: сколько принесёт эффект, если гипотеза подтвердится, и стоит ли эксперимент затрат.
2. Метрики
Ключевая метрика (OEC) — одна. Не «конверсия, выручка и удержание», а одна, по которой принимается решение. Она должна быть чувствительной и связанной с целью.
Защитные (guardrail) метрики — не должны просесть: скорость страницы, доля отмен, отток, жалобы.
Отдельный вопрос — прокси-метрики: если целевое событие редкое или отложенное (в travel — состоявшаяся поездка), берут прокси, но обязательно проверяют, что оно исторически связано с целевым.
3. Дизайн эксперимента
Единица рандомизации: пользователь, устройство, сессия, а иногда гео или магазин. Единица должна совпадать с уровнем, на котором измеряется метрика, и не допускать «протечек» между группами.
Расчёт размера выборки и длительности. Заранее фиксируются α (обычно 0,05), мощность (обычно 80%) и MDE — минимальный эффект, который имеет смысл детектировать. Отсюда считается нужное число наблюдений и срок. Ориентир — не меньше недели (недельная сезонность) и целое число недель.
Разбиение: 50/50 как правило оптимально по мощности; при риске — сначала маленькая доля. Полезны стратификация по важным признакам и метод CUPED для снижения дисперсии.
Критерии остановки и отката фиксируются письменно до старта.
4. Валидация перед запуском
A/A-тест — проверка, что система сплитования не врёт: на одинаковых версиях значимых различий быть не должно.
Проверка корректности логирования событий и попадания пользователей в группы.
5. Запуск и мониторинг
Часто выкатывают постепенно: 1% → 10% → 50%, следя за ошибками и защитными метриками.
Ежедневно проверяется техническое здоровье теста: SRM (sample ratio mismatch) — расхождение фактического соотношения групп с задуманным. Значимый SRM означает поломку сплитования, и результаты теста при нём недействительны.
Peeking problem: подглядывать в p-value и останавливать тест, как только он «позеленел», нельзя — это многократно раздувает вероятность ложноположительного результата. Либо ждём запланированный срок, либо используем последовательное тестирование (sequential testing, alpha spending), заложенное в дизайн заранее.
6. Анализ результатов
Проверка предпосылок и выбор критерия: t-тест для средних, χ² или z-тест для долей, бутстрап и дельта-метод для отношений (например, GMV на пользователя).
Обязательно смотрим не только p-value, но и размер эффекта с доверительным интервалом — статистическая значимость без практической ничего не стоит.
Учёт множественных сравнений, если метрик или групп много (поправки Холма, Бенджамини — Хохберга).
Анализ по сегментам — как разведка, а не как основание для решения: чем больше срезов, тем выше шанс найти случайную «значимость».
Проверка эффектов новизны и привыкания: всплеск в первые дни может не сохраниться.
7. Решение и раскатка
Решение принимается по заранее объявленным критериям: ключевая метрика выросла значимо, защитные не просели, эффект окупает внедрение.
Возможные исходы: раскатать, откатить, повторить тест на большей выборке, доработать и перезапустить.
После раскатки — пост-мониторинг: проверить, что эффект сохраняется на всём трафике и в долгую (holdout-группа помогает оценивать накопленный эффект).
Результат документируется независимо от исхода: отрицательные результаты экономят команде время в будущем.
Что добавит ответу веса на собеседовании: сказать, что порядок этапов не ритуал, а защита от самообмана — фиксация метрики и размера выборки до старта не даёт подогнать критерий под полученные данные, A/A и SRM отсекают технические поломки, а доверительный интервал переводит разговор из «сработало или нет» в «насколько сработало».
WB Travel
SQLСложныйзадача
Атрибуция заказов по First Click и Last Click
Есть две таблицы.
`events`:
| Колонка | Описание |
|---|---|
| `user_id` | id пользователя |
| `event_time` | время события |
| `event_name` | название события |
| `entry_point` | точка входа (канал) |
`orders`:
| Колонка | Описание |
|---|---|
| `user_id` | id пользователя |
| `order_id` | id заказа |
| `status` | статус заказа |
| `time` | время заказа |
| `price` | сумма заказа |
Нужно посчитать количество заказов и GMV в разрезе точек входа по двум моделям атрибуции: **First Click** и **Last Click**.
**Усложнение.** У заказов появились статусы `created`, `paid`, `cancelled`. Учитывать нужно только оплаченные заказы, отменённые — убрать.
Написать SQL.
Идея: для каждого заказа собрать все касания пользователя до момента заказа, пронумеровать их по времени с двух концов и взять первое и последнее.
WITH paid_orders AS ( -- усложнение: только оплаченныеSELECT user_id, order_id, price, timeFROM orders
WHERE status ='paid'
),
touches AS (
SELECT
o.order_id,
o.price,
COALESCE(e.entry_point, 'direct') AS entry_point,
ROW_NUMBER() OVER (PARTITIONBY o.order_id
ORDERBY e.event_time ASCNULLS LAST, e.entry_point) AS rn_first,
ROW_NUMBER() OVER (PARTITIONBY o.order_id
ORDERBY e.event_time DESCNULLS LAST, e.entry_point) AS rn_last
FROM paid_orders o
LEFTJOIN events e
ON e.user_id = o.user_id
AND e.event_time <= o.time -- касания только ДО заказаAND e.event_time >= o.time -INTERVAL'30 days'-- окно атрибуции
)
SELECT'first_click'AS model, entry_point,
COUNT(*) AS orders_cnt, SUM(price) AS gmv
FROM touches
WHERE rn_first =1GROUPBY entry_point
UNIONALLSELECT'last_click', entry_point,
COUNT(*), SUM(price)
FROM touches
WHERE rn_last =1GROUPBY entry_point
ORDERBY model, gmv DESC;
Компактная альтернатива для одной модели — DISTINCT ON в PostgreSQL:
SELECTDISTINCTON (o.order_id)
o.order_id, o.price, e.entry_point
FROM paid_orders o
LEFTJOIN events e ON e.user_id = o.user_id AND e.event_time <= o.time
ORDERBY o.order_id, e.event_time DESCNULLS LAST; -- last click
На что смотрит интервьюер
1. LEFT JOIN, а не INNER JOIN — главная ловушка. У части заказов не будет ни одного подходящего события: пользователь пришёл напрямую, события потерялись при логировании, касание было за пределами окна. INNER JOIN молча выбросит такие заказы, и сумма GMV по каналам не сойдётся с общей. На тестовых данных из пяти оплаченных заказов внутренний джойн потерял два заказа на 3700 из 7200 GMV — больше половины. Непривязанные заказы отправляем в отдельную корзину direct / unknown и обязательно проверяем сходимость:
-- сумма GMV по каналам должна совпасть с общим GMV оплаченных заказовSELECTSUM(price) FROM orders WHERE status ='paid';
2. Условие e.event_time <= o.time обязательно. Без него в атрибуцию попадут касания, случившиеся после покупки, и канал получит заслугу за заказ, которого ещё не было. Условие должно стоять именно в ON, а не в WHERE: в WHERE оно превратит левое соединение обратно во внутреннее и вернёт нас к ловушке из пункта 1.
3. Окно атрибуции. Касание годовой давности вряд ли привело к сегодняшнему заказу. Стандартная практика — 7/14/30 дней; конкретное окно стоит уточнить у продакта, а в ответе — проговорить, что оно вообще нужно.
4. Тай-брейк при одинаковом времени. Если два события пришли в одну секунду (частая ситуация при логировании), порядок без второго ключа сортировки недетерминирован, и результат будет «плавать» между запусками. Поэтому в ORDER BY добавлен entry_point — подойдёт любой стабильный ключ, например event_id.
5. Что делать со статусами — уточняющий вопрос. Тут есть развилка, и хороший ответ её называет:
Если в orders одна строка на заказ с текущим статусом, достаточно WHERE status = 'paid', как в решении выше.
Если таблица хранит историю переходов (created → paid → cancelled отдельными строками), то WHERE status = 'paid' засчитает в GMV заказ, который позже отменили. Нужно сначала свернуть заказ до финального статуса:
WITH final_status AS (
SELECTDISTINCTON (order_id)
order_id, user_id, status, time, price
FROM orders
ORDERBY order_id, timeDESC-- последнее событие по заказу
)
SELECT*FROM final_status WHERE status ='paid';
Проверено на данных: наивный фильтр по статусу засчитывает отменённый заказ на 2000, свёртка до финального статуса — нет.
6. Что такое GMV. Стоит уточнить, считаем ли мы сумму заказов целиком или за вычетом отмен и возвратов, и берётся ли цена на момент заказа. В travel это существенно: между бронированием и заездом проходят месяцы, и часть заказов отменяется уже после оплаты.
Чем этот вопрос продолжают
First и last click не сходятся между собой — один и тот же заказ достаётся разным каналам. Это не ошибка, а разные взгляды: first click оценивает привлечение, last click — закрытие сделки. Реклама верхнего уровня всегда выглядит лучше в first click, брендовый поиск и ретаргетинг — в last click.
Линейная атрибуция делит заказ поровну между всеми касаниями:
SUM(o.price /COUNT(*) OVER (PARTITIONBY o.order_id))
Дальше идут time decay (чем ближе касание к покупке, тем больше вес), позиционная модель U-shape и data-driven атрибуция (например, на значениях Шепли). Упоминание этого ряда показывает, что кандидат понимает ограниченность однокасательных моделей.