Как посчитать propensity score matching в SQL
Содержание:
Зачем PSM
В observational data treated и control различаются не только по самому факту treatment, но и по характеристикам (age, plan, country). Это даёт selection bias: «новички в Enterprise купили feature и вырос revenue». Propensity score matching выравнивает группы — каждому treated находим control с похожей вероятностью treatment, и сравниваем пары.
Шаги PSM
- Оценить P(treatment | covariates) — обычно logit-регрессия в Python.
- Импортировать propensity score в SQL.
- Для каждого treated найти nearest neighbor в control по PS.
- Сравнить outcome.
В чистом SQL шаг 1 сложен, поэтому обычно considered hybrid: model в Python, matching в SQL.
PSM в SQL
Допустим, propensity score уже посчитан и сохранён:
WITH treated AS (
SELECT user_id, propensity_score, outcome
FROM observations
WHERE is_treated = TRUE
),
control AS (
SELECT user_id, propensity_score, outcome
FROM observations
WHERE is_treated = FALSE
),
nearest AS (
SELECT
t.user_id AS treated_id,
t.propensity_score AS treated_ps,
t.outcome AS treated_outcome,
c.user_id AS control_id,
c.propensity_score AS control_ps,
c.outcome AS control_outcome,
ABS(t.propensity_score - c.propensity_score) AS ps_distance,
ROW_NUMBER() OVER (PARTITION BY t.user_id ORDER BY ABS(t.propensity_score - c.propensity_score)) AS rn
FROM treated t
CROSS JOIN control c
)
SELECT *
FROM nearest
WHERE rn = 1;CROSS JOIN дорогостоящий — на 100k×100k не пойдёт. Для больших данных используют k-d tree (вне SQL).
Баланс ковариат
После matching проверяем, что age/plan/country у пар равны:
WITH matched_pairs AS (
-- из предыдущего CTE
SELECT treated_id, control_id FROM nearest WHERE rn = 1
)
SELECT
'age' AS covariate,
AVG(t.age) - AVG(c.age) AS mean_diff
FROM matched_pairs mp
JOIN observations t ON t.user_id = mp.treated_id
JOIN observations c ON c.user_id = mp.control_id
UNION ALL
SELECT
'plan_premium',
AVG((t.plan = 'premium')::INT::NUMERIC) - AVG((c.plan = 'premium')::INT::NUMERIC)
FROM matched_pairs mp
JOIN observations t ON t.user_id = mp.treated_id
JOIN observations c ON c.user_id = mp.control_id;Хороший matching: standardized mean differences < 0.1 по всем ковариатам.
ATE по matched парам
Average Treatment Effect:
WITH matched_pairs AS (
SELECT treated_id, control_id, treated_outcome, control_outcome
FROM nearest WHERE rn = 1
)
SELECT
AVG(treated_outcome - control_outcome) AS ate,
COUNT(*) AS n_pairs,
STDDEV_SAMP(treated_outcome - control_outcome) / SQRT(COUNT(*)) AS se_ate
FROM matched_pairs;ATE — оценка эффекта treatment, очищенная от observable confounders.
Частые ошибки
Ошибка 1. PSM не выравнивает unobserved confounders. Если в данных нет важной переменной (motivation, region), PSM не поможет.
Ошибка 2. Matching with replacement vs without. With replacement один control попадает многим. Без — каждый control в одной паре. Влияет на bias-variance trade-off.
Ошибка 3. Игнорировать common support. Если у treated PS близок к 1, а у control max 0.6 — для таких treated нет match. Отбраковка обязательна.
Ошибка 4. Использовать matched data для обычного t-test. Pairs зависимы. Используйте paired t-test или conditional inference.
Ошибка 5. Caliper не задан. Без caliper «nearest» может быть с PS 0.9 и 0.1. Стандарт — caliper = 0.2 × σ(PS).
Связанные темы
- Как посчитать DiD в SQL
- Как посчитать propensity score в SQL
- Propensity score matching
- Как посчитать t-test в SQL
FAQ
PSM vs A/B?
A/B — random. PSM — наблюдательные данные с попыткой эмулировать random.
Какой caliper?
0.2 × stddev(PS). Жёстче — лучше match, меньше pairs.
Когда PSM не работает?
При strong unobserved confounding. Sensitivity-анализ обязателен.
Matching k-to-1?
Один treated → k controls. Снижает variance, повышает bias.
Альтернатива?
Inverse probability weighting (IPW), DiD, synthetic control — все используют PS, но по-разному.