**Задание по мотивам реального тестового в Dodo Brands (Додо Пицца).**
**Контекст:** В марте 2022 McDonald's закрыл все рестораны в России. Нужно оценить, как это повлияло на выручку Додо Пиццы.
**Данные:** Ежедневная выручка 50 ресторанов Додо за 6 месяцев (янв-июн 2022):
[см. код в задании]
`had_mcdonalds_nearby` = был ли McDonald's в радиусе 1 км от ресторана.
**Задание:**
1. **Difference-in-Differences (DiD):** Treatment = рестораны с McDonald's рядом. Событие = 14 марта 2022. Оцените ATT (average treatment effect on treated)
2. **Визуализация:** Параллельные тренды до события + расхождение после
3. **Проверка:** Тест на параллельные тренды (pre-treatment) — ключевое допущение DiD
4. **Robustness:** Placebo test — сдвиньте дату события на 1 февраля. Эффект должен быть ~0
Структура для ориентира — реальные значения из эталонного решения.
import pandas as pd
import numpy as np
from scipy import stats
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
# --- Генерация данных ---
np.random.seed(42)
dates = pd.date_range('2022-01-01', '2022-06-30', freq='D')
n_restaurants = 50
event_date = pd.Timestamp('2022-03-14')
restaurants = []
for i in range(n_restaurants):
rid = f'R{i:03d}'
city = np.random.choice(['Moscow', 'SPb', 'Kazan', 'Ekb', 'NSK'],
p=[0.3, 0.2, 0.15, 0.15, 0.2])
has_mc = np.random.random() < 0.4 # 40% рядом с McD
base_revenue = np.random.uniform(80_000, 200_000)
restaurants.append({'id': rid, 'city': city, 'has_mc': has_mc, 'base': base_revenue})
rows = []
for rest in restaurants:
for dt in dates:
# Базовый тренд + сезонность
day_num = (dt - dates[0]).days
trend = 1 + day_num * 0.0003 # небольшой рост
dow_effect = 1.15 if dt.dayofweek >= 4 else 1.0 # выходные
noise = np.random.normal(1, 0.08)
# Эффект закрытия McD (только для treatment, только после события)
mc_effect = 1.0
if rest['has_mc'] and dt >= event_date:
weeks_after = (dt - event_date).days / 7
# Резкий скачок +12%, затухающий к +5% за 3 месяца
mc_effect = 1 + 0.12 * np.exp(-weeks_after / 8) + 0.05
revenue = rest['base'] * trend * dow_effect * noise * mc_effect
orders = int(revenue / np.random.uniform(500, 700))
rows.append({
'date': dt,
'restaurant_id': rest['id'],
'city': rest['city'],
'had_mcdonalds_nearby': rest['has_mc'],
'revenue': round(revenue),
'orders': orders,
})
df = pd.DataFrame(rows)
df['post'] = (df['date'] >= event_date).astype(int)
df['treatment'] = df['had_mcdonalds_nearby'].astype(int)
df['did'] = df['post'] * df['treatment']
print(f"Записей: {len(df):,}")
print(f"Рестораны: {n_restaurants} (treatment: {df['treatment'].mean()*100:.0f}%)")
# =============================================
# 1. Difference-in-Differences
# =============================================
# Простой DiD: средние до/после × treatment/control
groups = df.groupby(['treatment', 'post'])['revenue'].mean().unstack()
groups.columns = ['pre', 'post_period']
groups['diff'] = groups['post_period'] - groups['pre']
att_simple = groups.loc[1, 'diff'] - groups.loc[0, 'diff']
print(f"\n=== DiD: простая оценка ===")
print(groups.round(0).to_string())
print(f"\nATT (средний эффект на treatment): {att_simple:+,.0f} руб./день")
# Регрессионный DiD: Y = β₀ + β₁×T + β₂×Post + β₃×T×Post + ε
from numpy.linalg import lstsq
X = np.column_stack([
np.ones(len(df)),
df['treatment'].values,
df['post'].values,
df['did'].values,
])
y = df['revenue'].values
beta, residuals, _, _ = lstsq(X, y, rcond=None)
y_pred = X @ beta
residuals_vec = y - y_pred
mse = np.mean(residuals_vec**2)
# Стандартные ошибки (OLS)
var_beta = mse * np.linalg.inv(X.T @ X)
se = np.sqrt(np.diag(var_beta))
t_stats = beta / se
p_values = 2 * (1 - stats.t.cdf(np.abs(t_stats), df=len(df) - 4))
labels = ['intercept', 'treatment', 'post', 'treatment×post (ATT)']
print(f"\n=== DiD: регрессия ===")
print(f"{'Переменная':<30} {'β':>10} {'SE':>10} {'t':>8} {'p-value':>10}")
print("-" * 70)
for i in range(4):
sig = '***' if p_values[i] < 0.001 else '**' if p_values[i] < 0.01 else '*' if p_values[i] < 0.05 else ''
print(f"{labels[i]:<30} {beta[i]:>10,.1f} {se[i]:>10,.1f} {t_stats[i]:>8.2f} {p_values[i]:>10.4f} {sig}")
print(f"\nATT = {beta[3]:+,.0f} руб./день (p = {p_values[3]:.4f})")
print(f"Эффект: {beta[3]/beta[0]*100:+.1f}% от baseline")
# =============================================
# 2. Визуализация параллельных трендов
# =============================================
weekly = (df.groupby([pd.Grouper(key='date', freq='W'), 'treatment'])
['revenue'].mean().unstack())
weekly.columns = ['Control', 'Treatment']
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
# Тренды
axes[0].plot(weekly.index, weekly['Control'], label='Control (нет McD рядом)',
linewidth=2, color='#4A5568')
axes[0].plot(weekly.index, weekly['Treatment'], label='Treatment (McD рядом)',
linewidth=2, color='#FF5A1F')
axes[0].axvline(event_date, color='red', linestyle='--', linewidth=2,
label='McD закрылся (14.03)')
axes[0].set_title('Средняя выручка: Treatment vs Control')
axes[0].set_ylabel('Выручка, руб.')
axes[0].legend()
# Разница
weekly['gap'] = weekly['Treatment'] - weekly['Control']
axes[1].plot(weekly.index, weekly['gap'], linewidth=2, color='#FF5A1F')
axes[1].axvline(event_date, color='red', linestyle='--', linewidth=2)
axes[1].axhline(0, color='gray', linestyle=':')
axes[1].set_title('Разница Treatment - Control')
axes[1].set_ylabel('Δ выручка, руб.')
plt.tight_layout()
plt.savefig('did_parallel_trends.png', dpi=150)
# =============================================
# 3. Тест параллельных трендов (pre-period)
# =============================================
pre = df[df['post'] == 0].copy()
pre['day_num'] = (pre['date'] - pre['date'].min()).dt.days
X_pre = np.column_stack([
np.ones(len(pre)),
pre['day_num'].values,
pre['treatment'].values,
pre['day_num'].values * pre['treatment'].values, # interaction
])
y_pre = pre['revenue'].values
beta_pre, _, _, _ = lstsq(X_pre, y_pre, rcond=None)
y_pred_pre = X_pre @ beta_pre
res_pre = y_pre - y_pred_pre
mse_pre = np.mean(res_pre**2)
var_pre = mse_pre * np.linalg.inv(X_pre.T @ X_pre)
se_pre = np.sqrt(np.diag(var_pre))
t_pre = beta_pre / se_pre
p_pre = 2 * (1 - stats.t.cdf(np.abs(t_pre), df=len(pre) - 4))
print(f"\n=== Тест параллельных трендов (pre-period) ===")
print(f"Interaction (day × treatment): β={beta_pre[3]:.2f}, p={p_pre[3]:.4f}")
if p_pre[3] > 0.05:
print("→ Тренды параллельны (p > 0.05) — допущение DiD выполняется ✓")
else:
print("→ ВНИМАНИЕ: тренды не параллельны — DiD может быть смещён!")
# =============================================
# 4. Placebo test
# =============================================
placebo_date = pd.Timestamp('2022-02-01')
pre_only = df[df['date'] < event_date].copy()
pre_only['placebo_post'] = (pre_only['date'] >= placebo_date).astype(int)
pre_only['placebo_did'] = pre_only['placebo_post'] * pre_only['treatment']
X_placebo = np.column_stack([
np.ones(len(pre_only)),
pre_only['treatment'].values,
pre_only['placebo_post'].values,
pre_only['placebo_did'].values,
])
y_placebo = pre_only['revenue'].values
beta_pl, _, _, _ = lstsq(X_placebo, y_placebo, rcond=None)
y_pred_pl = X_placebo @ beta_pl
res_pl = y_placebo - y_pred_pl
mse_pl = np.mean(res_pl**2)
var_pl = mse_pl * np.linalg.inv(X_placebo.T @ X_placebo)
se_pl = np.sqrt(np.diag(var_pl))
t_pl = beta_pl / se_pl
p_pl = 2 * (1 - stats.t.cdf(np.abs(t_pl), df=len(pre_only) - 4))
print(f"\n=== Placebo test (fake event = 1 февраля) ===")
print(f"Placebo ATT: {beta_pl[3]:+,.0f} руб. (p = {p_pl[3]:.4f})")
if p_pl[3] > 0.05:
print("→ Placebo эффект незначим — подтверждает валидность основного DiD ✓")
else:
print("→ ВНИМАНИЕ: Placebo значим — возможна конфаунда!")
# Итоговый вывод
print(f"\n{'='*50}")
print(f"ИТОГО: Закрытие McDonald's привело к росту выручки")
print(f"Додо Пиццы на {beta[3]:+,.0f} руб./день (+{beta[3]/beta[0]*100:.1f}%)")
print(f"для ресторанов, расположенных вблизи бывших McD.")
print(f"Эффект статистически значим (p < 0.001).")
print(f"Допущение параллельных трендов подтверждено.")
python causal inference DiD synthetic control Dodo Brands
Это задание для уровня Senior. Senior-уровень — глубокое понимание темы, опыт решения нестандартных задач, обсуждение trade-off на собеседовании.
Подобные задания в категории «Python» регулярно дают на собеседованиях аналитика данных в Яндекс, Сбер, Ozon, Авито, Тинькофф, Wildberries, T-Bank, X5, ВТБ и других крупных IT-компаниях. Тематика: python, causal inference, DiD, synthetic control, Dodo Brands.
На реальном собеседовании на подобную задачу отводится 30-60 минут с обсуждением подходов, оптимизаций и trade-off. Для тренировки рекомендуем сначала решить самостоятельно, потом сверить с эталонным решением и подсказками.
На zasqlpython.ru есть 482 Python задачи с проверкой через Pyodide, конспекты Python и pandas, AI мок-собеседование с разбором ваших ответов.
← Все задания