статистикаошибка выжившегосмещение выборки

Ошибка выжившего в аналитике: как не попасться

2026-07-11 11 мин

Ошибка выжившего — это когда вы делаете вывод по тем, кто «дошёл до конца»: остался клиентом, долетел до базы, оформил заказ, — и молча переносите его на всех остальных. Подвох в том, что «погибшие» наблюдения с вами не спорят: их просто нет в таблице. Запрос отрабатывает, цифра выглядит убедительно, дашборд зелёный — а вывод неверный. Чтобы не попасться, я каждый раз задаю себе один вопрос: кого в этих данных физически не может быть, и не из-за них ли я вижу такую приятную картину.

Что такое ошибка выжившего и почему её так сложно заметить?

Формально это разновидность смещения отбора: попадание наблюдения в выборку связано с тем самым исходом, который вы измеряете. Клиент остаётся в таблице «активных», потому что он лоялен, — а вы потом делаете вывод о лояльности по этой же таблице. Круг замкнулся, и вы этого не видите.

Именно незаметность делает ошибку выжившего дорогой. Она не роняет запрос и не подсвечивается красным. NULL-ов нет, дубликатов нет, типы сходятся. Технически всё чисто. Проблема живёт не в SQL, а в том, какие строки в таблицу вообще не попали, — а про отсутствующие строки движок вам ничего не скажет.

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

Причём здесь самолёты Абрахама Вальда?

Классическую историю приписывают статистику Абрахаму Вальду времён Второй мировой. Военные изучали вернувшиеся с вылетов бомбардировщики, отмечали, где на фюзеляже больше всего пробоин, и хотели усилить бронёй именно эти места. Логика вроде железная: куда чаще попадает — то и защищаем.

Вальд заметил дыру в рассуждении. Изучали-то только те самолёты, что вернулись. Машины с пробоинами в двигателе или кабине на базу не долетали — их в выборке просто не было. Значит, скопления пробоин на выживших показывают ровно те зоны, попадание в которые самолёт переживает. Бронировать надо там, где у вернувшихся пробоин нет.

Мне эта история нравится тем, что в ней нет ни статистики, ни формул — только правильно заданный вопрос: «а кого мы не видим?». В аналитике всё то же самое. Каждый раз, когда вы смотрите на «успешные» объекты и хотите вывести из них рецепт успеха, вспоминайте про непробитый двигатель.

Как ошибка выжившего прячется в продуктовой аналитике?

Самый частый сюжет — «портрет успешного клиента». Берём тех, кто с нами больше года, смотрим их поведение и объявляем: вот кто платит долго, давайте искать похожих. Но клиенты, которые ушли на второй месяц, точно так же когда-то что-то делали в продукте — просто их отфильтровали словом «текущие». Признаки «выживших» вы находите, а вот отличает ли они выживших от погибших — неизвестно, потому что погибших вы не смотрели.

Второй сюжет — средние по «дожившим». Фраза «наши клиенты в среднем с нами 14 месяцев», посчитанная по действующим подписчикам, завышает лояльность в разы: все, кто отвалился быстро, из знаменателя выпали. То же с «средним чеком платящих», «средней сессией вернувшихся», «конверсией по дошедшим до корзины».

Третий — обучение моделей и правил на очищенной истории. Скоринг, обученный только на выданных кредитах, не видел тех, кому отказали, и потому не знает, как повели бы себя отказники. Рекомендации, обученные на кликах, не видят товаров, которые никому не показали. Практику отбора «нормальных» строк удобно тренировать на живых примерах из каталога кейсов — там смещение выборки всплывает почти в каждом продуктовом разборе.

Почему retention «по активным» показывает неправду?

Retention особенно любит эту ловушку, потому что «активность» и «удержание» — почти одно и то же слово. Стоит взять за знаменатель не исходную когорту, а тех, кто «ещё активен», — и метрика начинает мерить саму себя.

Смотрите, как легко ошибиться. Хочется посчитать, какая доля пользователей возвращается на 7-й день, и в качестве базы берут тех, кто был активен на этой неделе:

-- Так НЕ надо: знаменатель — уже «выжившие», активные на этой неделе
SELECT
    count(DISTINCT r.user_id)::float
        / count(DISTINCT a.user_id) AS retention_7d
FROM events a
LEFT JOIN events r
    ON r.user_id = a.user_id
   AND r.event_date = a.event_date + INTERVAL '7 days'
WHERE a.event_date >= current_date - INTERVAL '7 days';

Знаменатель здесь — не «все, кто пришёл», а «все, кто уже проявил активность». Ушедшие в базу не попали, и retention красиво завышается. Правильная фиксация когорты идёт от момента регистрации или первого действия и держит эту когорту неизменной, сколько бы человек из неё ни отвалилось:

-- Когорта фиксируется по первому дню и больше не «худеет»
WITH cohort AS (
    SELECT user_id, min(event_date) AS joined
    FROM events
    GROUP BY user_id
)
SELECT
    count(DISTINCT e.user_id)::float
        / count(DISTINCT c.user_id) AS retention_7d
FROM cohort c
LEFT JOIN events e
    ON e.user_id = c.user_id
   AND e.event_date = c.joined + INTERVAL '7 days'
WHERE c.joined = current_date - INTERVAL '7 days';

Разница — в том, кто стоит в знаменателе. Пока это «все пришедшие», а не «все дожившие», retention честный. Если хотите пощупать оба варианта на живых таблицах, удобно собрать их в SQL-тренажёре, а базовое определение активной аудитории я держу под рукой в описании метрики DAU.

Что такое цензурирование и почему когорты «выживают»?

Есть более тонкий случай, где выживших создаёте не вы, а само время. Если я сегодня считаю, «сколько в среднем живёт подписка», у части людей подписка ещё не закончилась. Их срок жизни не 3 месяца — он «3 месяца и продолжается». Такие наблюдения называют цензурированными: настоящий исход мы пока не наблюдали.

Обе типовые реакции на цензурирование дают смещение. Выкинуть активных и считать только по завершённым подпискам — классическая ошибка выжившего наоборот: вы оставляете лишь тех, кто уже ушёл, и занижаете срок жизни. Взять текущий стаж активных как финальный — тоже неверно, потому что он ещё вырастет.

import pandas as pd

sub = pd.DataFrame({
    "user_id": [1, 2, 3, 4, 5],
    "start": pd.to_datetime(["2025-01-01"] * 5),
    "end":   pd.to_datetime(["2025-03-01", None, "2025-02-15", None, "2025-09-01"]),
})
sub["active"] = sub["end"].isna()

# Смещение: средний срок только по завершённым подпискам
closed = sub[~sub["active"]].copy()
closed["days"] = (closed["end"] - closed["start"]).dt.days
print(closed["days"].mean())  # игнорирует всех, кто ещё жив

Честный ответ здесь дают методы анализа выживаемости (например, кривые Каплана — Майера), которые явно учитывают цензурированные наблюдения вместо того, чтобы их прятать. Разбираться в этом проще на коде — оба фрагмента можно прогнать в Python-тренажёре, а синтаксис groupby и работы с датами я подсматриваю в справочнике по Python.

Как отменённые заказы и платежи выпадают из выборки?

Отдельная головная боль — джойны. INNER JOIN по своей природе выбрасывает всех, у кого нет пары в правой таблице, и делает это молча. Соединили пользователей с платежами через INNER JOIN — и в выборке остались только заплатившие. После этого любой «средний чек» или «доля довольных» посчитаны по выжившим.

-- INNER JOIN оставит только заплативших — это и есть «выжившие»
SELECT avg(p.amount) AS avg_check
FROM users u
JOIN payments p ON p.user_id = u.user_id;

-- Полная популяция сохраняется через LEFT JOIN
SELECT
    count(*)                                   AS all_users,
    count(p.user_id)                           AS paid_users,
    count(p.user_id)::float / count(*)         AS conversion
FROM users u
LEFT JOIN payments p ON p.user_id = u.user_id;

То же самое с отменёнными и возвращёнными заказами. Если аналитика витрины уже отфильтровала status = 'completed' где-то в ETL, вы получаете таблицу без отмен и считаете конверсию, средний чек и retention по идеальному миру, которого не существует. Отмена — это не мусор, это исход, и она обязана быть в знаменателе, когда вы меряете долю.

Мой рабочий рефлекс простой: INNER JOIN и любой WHERE status = ... — это места, где могли исчезнуть строки. Прежде чем считать долю или среднее, я проверяю, не режу ли я популяцию по признаку, связанному с исходом. Потренировать разницу между типами соединений можно на задачах по SQL, а логику JOIN с нуля я в своё время раскладывал по полочкам в курсе по SQL.

Как поймать смещение в своих данных?

Первое, что я делаю, — считаю строки до и после каждого фильтра и джойна. Если население таблицы после JOIN заметно похудело, это повод спросить, кто именно выпал и не связан ли его уход с тем, что я измеряю. Резкое падение count(*) — почти всегда чей-то невернувшийся самолёт.

Второе — проговариваю знаменатель словами. «Доля от чего?» Если ответ звучит как «доля активных среди активных» или «средний срок среди тех, кто уже ушёл», значит, база уже смещена, и цифру можно не досчитывать.

Третье — ищу, где данные могли быть очищены до меня. Отмены, возвраты, забаненные, недозвоны, отказники по кредиту, товары без показов — это всё «погибшие», которых удобно потерять на этапе выгрузки. Полезно один раз пройтись по типовым вопросам с собеседований: в подборке вопросов смещение выборки — стабильный сюжет, и формулировки там помогают натренировать сам рефлекс сомнения.

Как это чинить: полная выборка и честный знаменатель

Лечение сводится к одному принципу — начинать анализ от полной популяции, а не от выживших. На практике это несколько привычек. Строить агрегаты от исходной когорты и LEFT JOIN-ить к ней всё остальное, а не наоборот. Держать отмены, возвраты и отказы в данных до самого конца и убирать их только осознанно, зафиксировав это решение. Для метрик жизни (LTV, срок подписки, время до события) применять методы, которые умеют работать с цензурированием, вместо того чтобы выкидывать «недожитые» наблюдения.

И самое дешёвое средство — вопрос «кого здесь не может быть по построению?», заданный до того, как цифра попадёт в презентацию. Он не требует ни библиотек, ни доступа к сырым логам, а ловит большую часть ошибок выжившего ещё на уровне идеи. Самолёты Вальда бронировали не там, где были пробоины, а там, где их не было, — и в аналитике самый ценный сигнал точно так же прячется в строках, которых в вашей таблице нет.

Если хочется довести этот навык до автомата — на смещениях выборки, когортах и джойнах легко набить руку: сотни разобранных SQL-задач и продуктовых кейсов в Pro-доступе как раз про то, чтобы замечать пропавшие строки раньше, чем они испортят вывод.

Готовься к собесу аналитика
Вопросы по статистике и A/B с разбором — попробуй бесплатно.
Банк вопросов →