Откуда берутся данные: сбор, события, выживший источник
В прошлой главе мы договорились начинать с решения: какой выбор изменится от ответа. Допустим, вопрос сформулирован честно — «раскатывать ли новый онбординг на всех». Дальше почти все идут в хранилище, пишут запрос и получают число. И вот тут начинается настоящая работа, потому что число из хранилища отвечает не на ваш вопрос, а на другой: «что сказали бы данные, если бы процесс их сбора был идеальным». Он не идеальный. Он вообще не проектировался под ваш вопрос.
Главная опасность аналитики — уверенный вывод из данных, которые его не выдерживают. И точка, где вывод ломается, почти всегда лежит не в статистике, а на два шага раньше: в том, как строка попала в таблицу и какие строки в неё не попали.
Эта глава про одну дисциплину: перед любым анализом уметь сказать вслух, что ваш способ сбора позволяет и чего он не позволяет. Не «данные грязные» — это про качество данных. Здесь про другое: данные бывают идеально чистыми и при этом систематически описывают не ту популяцию, о которой вы думаете.
Данных не существует, есть процесс их производства
Инженерная привычка говорит: база — это факты. Аналитическая привычка должна говорить иначе: каждая таблица — это лог решений о том, что стоит записать, принятых кем-то другим, раньше, под другую задачу.
Три вопроса к любому столбцу, прежде чем строить на нём вывод:
- Кто и когда его записал. Приложение при действии пользователя? Ночной джоб? Менеджер руками в админке? Импорт из чужой системы полтора года назад?
- Что происходит, когда записать не удалось. Строки нет? Остаётся
NULL? Подставляется дефолт SDK, неотличимый от осознанного выбора? - Что происходит при изменении. Пишется новая строка или затирается старая? Если затирается — истории просто не существует, и никакой SQL её не вернёт.
Третий вопрос убивает больше анализов, чем первые два. Классика: «покажи распределение тарифов на момент регистрации» по таблице users, где plan обновляется через UPDATE. Такого распределения в природе нет — есть сегодняшний срез, задним числом применённый к прошлому. Запрос отработает, число появится, график построится. Вывод будет ложным.
Каждая ветка отвечает на свой класс вопросов и молчит про остальные. Смешивать их можно — но только назвав вслух, чем отличается их покрытие. Про то, как эти потоки физически доезжают до хранилища, — трек по инженерии данных; нам здесь важна не транспортировка, а её последствия для вывода.
Состояние против события: разница, из которой растёт половина ошибок
Снимок состояния отвечает на вопрос «как сейчас»: строка в users, orders, subscriptions. Читается тривиально, обновляется на месте. Проблема одна — времени в нём нет.
Лог событий отвечает на вопрос «что произошло»: неизменяемые записи «в момент T пользователь U сделал X с параметрами P». Из событий состояние восстанавливается сворачиванием (идея event sourcing), обратно — нет. Асимметрия принципиальная: событие содержит больше информации, чем состояние, и стоит дороже.
| Снимок состояния | Лог событий | |
|---|---|---|
| Отвечает на | «как сейчас» | «что и когда произошло» |
| Восстановление истории | невозможно | по построению |
| Объём | размер сущностей | размер активности, растёт со временем |
| Типичный носитель | OLTP-таблица | поток и колоночное хранилище |
| Что ломает анализ | UPDATE затирает прошлое |
потери, дубликаты, поздние приходы |
Практический вывод: если в вопросе есть слова «на момент», «до того как», «изменилось ли» — снимок состояния на него не отвечает никогда. Нужны либо события, либо медленно меняющиеся измерения SCD Type 2 из моделирования данных, либо CDC, включённый до того, как вопрос возник.
Половинчатое, но честное решение, когда событий нет: ежедневный снимок состояния в отдельную таблицу. Гранулярность — сутки, зато история появляется с завтрашнего дня. Это дёшево и почти всегда лучше, чем через полгода объяснять, почему на вопрос ответить нельзя.
Клиент или сервер: два разных мира слепоты
Клиентский сбор видит показы, скроллы, клики по элементам, отвалы в формах — всё, «что пользователь увидел». Не видит: пользователей с блокировщиками, событий из закрытой вкладки, действий в офлайне сверх ёмкости буфера, всего, что случилось после падения приложения.
Серверный сбор видит каждое действие, изменившее состояние: заказ, платёж, регистрацию, вызов API. Он почти не теряет данные, потому что живёт внутри вашего периметра. Не видит ничего, что не долетело: пользователь трижды тыкал в неотвечающую кнопку — на сервере ноль событий, в жизни три попытки и одно раздражение.
отправка пачкой раз в 10 секунд alt блокировщик или антитрекинг S--xC: запрос не ушёл Note over S,C: потеря невидима: нет строки
и нет отметки, что строка была else вкладка закрыта до флаша S--xC: буфер умер вместе со страницей else обычный путь S->>C: POST /batch C-->>S: 200 OK end C->>C: валидация схемы, дедуп по event_id C->>W: запись с occurred_at и received_at Note over W: аналитик видит ровно то,
что дожило до этой строки
Ключевая деталь — потери молчаливы. Отсутствующее событие не оставляет следа: нет строки, нет NULL, нет счётчика ошибок. Битое значение хотя бы заметно; потерянное событие выглядит как «этого не было». Поэтому клиентская аналитика систематически занижает всё, что коррелирует с блокировкой трекеров.
Что реально помогает:
navigator.sendBeacon()для отправки при уходе со страницы — запрос переживает выгрузку документа (MDN). Флашить надо поvisibilitychangeвhidden, а не поbeforeunload: на мобильныхbeforeunloadчасто не срабатывает вовсе (Page Lifecycle API).- Приём событий со своего домена вместо домена трекера: снимает часть блокировок по спискам, но не антитрекинг браузеров (WebKit ITP, Firefox ETP).
- Персистентная очередь на устройстве: событие переживает офлайн и перезапуск (офлайн-данные в мобильных).
- Двойное измерение критичных шагов. То, что важно для денег, меряется и на клиенте, и на сервере. Расхождение — не баг, а ваша измеренная слепая зона, и её надо знать числом.
Ограничения согласия на сбор данных (приватность и комплаенс) — ещё один неслучайный фильтр поверх всех перечисленных.
Воронка выживания события
Числа иллюстративные — у вашего продукта они другие. Важна структура: потери не распределены равномерно. Блокировщики стоят у более технически подкованных, офлайн-потери приходятся на мобильный трафик в дороге, отвал по CSP концентрируется в корпоративных сетях. Если хотя бы один из этих признаков связан с изучаемым исходом — а он обычно связан, — вы получаете не «выборку поменьше», а систематически сдвинутую картину.
Отсюда название главы. Выживший источник — данные, дожившие до вашей таблицы, отобранные процессом, который вы не контролировали и обычно даже не описали. Термин работает в двух смыслах: выжившие записи, чьи свойства молча приписывают всей популяции, — и выживший источник как система: в компании используется та таблица, которая исторически «прижилась», а не та, что подходит под вопрос.
Канонический пример — работа Абрахама Вальда в Statistical Research Group (Колумбийский университет, 1943). Военные принесли статистику пробоин на вернувшихся бомбардировщиках и предложили бронировать самые пострадавшие места. Вальд показал: усиливать надо там, где пробоин нет, потому что самолёты с попаданиями в эти зоны не возвращались и в выборку не попали. Выборка была не «маленькой» — она была отобрана исходом (см. Abraham Wald; разбор — Mangel M., Samaniego F., Journal of the American Statistical Association, 1984).
Современные бомбардировщики, прилетающие на дашборд:
- «У наших пользователей отличное удержание». Считали по таблице
events, то есть по тем, у кого события есть. У ушедших после первого экрана событий нет. - «Инциденты не влияют на отток». Проверяли на текущих клиентах. Те, кого инцидент вытолкнул, из таблицы клиентов уже удалены.
- «NPS 62». Опрос показывается на пятом экране онбординга — кто дошёл, тот уже прошёл отбор.
- «Средняя длительность сессии выросла». Поменяли таймаут склейки сессий в SDK: менялось определение, а не реальность.
- «Органика конвертит лучше платного трафика». Платный размечен UTM-метками, часть которых режут блокировщики, и он частично уехал в органику.
Почему смещение не лечится объёмом
Самое вредное убеждение в прикладной аналитике: «у нас миллионы строк, искажения усреднятся». Случайные — да. Систематические — нет.
Пусть $S = 1$ — событие «наблюдение попало в данные», $p = P(S = 1)$ — доля выживших, $Y$ — интересующая величина. По формуле полной вероятности $\mathbb{E}(Y) = p \cdot \mathbb{E}(Y \mid S = 1) + (1 - p) \cdot \mathbb{E}(Y \mid S = 0)$, а наблюдаем мы только $\mathbb{E}(Y \mid S = 1)$. Значит, смещение равно
$$\text{bias} = \mathbb{E}(Y \mid S = 1) - \mathbb{E}(Y) = (1 - p)\bigl(\mathbb{E}(Y \mid S = 1) - \mathbb{E}(Y \mid S = 0)\bigr)$$
В выражении нет $n$. Смещение определяется долей потерянных и тем, насколько потерянные отличаются от оставшихся. Обнулить его можно двумя способами: либо $p = 1$, либо потерянные ничем не отличаются от выживших.
Числовой пример. Меряем конверсию по клиентским событиям, 30% сессий не долетают из-за блокировщиков. У «видимых» конверсия 4,0%, у «невидимых» — 2,5% (люди с блокировщиками покупают реже).
- Наблюдаемая конверсия: 4,00%
- Истинная: $0{,}7 \cdot 4{,}0 + 0{,}3 \cdot 2{,}5 = 3{,}55%$
- Смещение: $(1 - 0{,}7)(4{,}0 - 2{,}5) = 0{,}45$ п.п., то есть завышение на 12,7% относительно истины.
Теперь сравним с шумом. При 700 000 наблюдённых сессий стандартная ошибка доли равна $\sqrt{0{,}0355 \cdot 0{,}9645 / 700000} \approx 2{,}2 \cdot 10^{-4}$, то есть 0,022 п.п. Смещение больше случайной ошибки примерно в 20 раз. Доверительный интервал, который вы построите (глава про неопределённость), будет узким, аккуратным и полностью мимо цели: интервал описывает разброс, а не сдвиг.
Индекс дефекта данных. Сяо-Ли Мэн (Xiao-Li Meng, «Statistical paradises and paradoxes in big data», Annals of Applied Statistics, 2018) выводит точное разложение ошибки выборочного среднего при неслучайном попадании в данные:
$$\bar{Y}_n - \bar{Y}N = \rho{R,Y} \times \sqrt{\frac{1 - f}{f}} \times \sigma_Y$$
где $R$ — индикатор попадания наблюдения в данные, $\rho_{R,Y}$ — корреляция между попаданием и самой величиной, $f = n/N$ — доля охвата популяции, $\sigma_Y$ — разброс в популяции. Первый множитель — дефект данных, второй — охват, третий — природная изменчивость. Абсолютный объём $n$ входит только через $f$: если вместе с выборкой растёт и популяция, выигрыша нет.
Проверим на нашем примере: $f = 0{,}7$, $\sigma_Y = \sqrt{0{,}0355 \cdot 0{,}9645} \approx 0{,}185$, ошибка 0,0045. Тогда $\rho_{R,Y} \approx 0{,}0045 / (0{,}655 \cdot 0{,}185) \approx 0{,}037$ — величина, которую в любом другом контексте назвали бы «связи нет». Приравняв квадрат смещения к дисперсии честной случайной выборки размера $n_{\text{eff}}$, получаем
$$n_{\text{eff}} = \frac{f}{(1 - f)\rho^2} = \frac{0{,}7}{0{,}3 \cdot 0{,}037^2} \approx 1700$$
Семьсот тысяч записей с корреляцией отбора 0,04 несут столько же информации, сколько 1700 честно случайных наблюдений. Это не метафора, это арифметика. Формальный аппарат — в главе про вероятность и статистику.
Вывод, который стоит повесить на стену: прежде чем радоваться объёму данных, оцените $\rho$ — спросите, связан ли механизм попадания в данные с тем, что вы измеряете. Если связан, объём вам не друг, а анестезия.
Как устроено событие, которое переживёт анализ
{
"event_id": "0f3f0a1e-4c8a-4c0f-9f0f-2b8b9b52a0d1",
"event_name": "order_completed",
"occurred_at": "2026-07-15T12:41:07.482Z",
"received_at": "2026-07-15T12:41:09.114Z",
"anonymous_id": "a1b2c3d4-e5f6-4711-8899-aabbccddeeff",
"user_id": "u_884213",
"schema_version": 4,
"source": "web",
"sdk_version": "analytics-js 3.11.2",
"context": { "app_version": "5.28.0", "locale": "ru-RU", "tz_offset_min": 180 },
"properties": { "order_id": "o_5512309", "amount_minor": 499000, "currency": "RUB" }
}
event_idгенерируется в момент возникновения события, а не отправки. Доставка гарантирует «хотя бы один раз», значит дубликаты будут обязательно (идемпотентность и гарантии доставки). Еслиevent_idприсваивается при отправке, каждый ретрай породит «новое» событие — и вы получите красивый рост метрики при ухудшении сети.- Три времени.
occurred_at— часы устройства, врут на часы и дни.received_at— часы коллектора.ingested_at— попадание в витрину. Бизнес-метрики агрегируют поoccurred_at, поток мониторят поreceived_at, инкрементальные джобы строят поingested_at. Почему «просто взять время» — плохая идея, разбирается в главе про время и часы. schema_versionиsdk_version. Без них невозможно объяснить излом на графике: половина «изменений в поведении пользователей» — это релизы клиента.sourceразделяетweb,ios,android,serverи обязательноbackfill: исторические дозаливки не должны быть неотличимы от живого трафика.- Единицы измерения в имени поля:
duration_ms,amount_minor,distance_m. Деньги — целым числом в минорных единицах плюс отдельное поле валюты: суммы в рублях и в USD в одном столбце без валюты складываются молча и неправильно.
Словарь событий — документ, а не соглашение на словах
Схема событий это требования, и работать с ними надо как с требованиями (техника — в главе про выявление требований). Правила, которые окупаются:
объект_действиев прошедшем времени, snake_case:order_completed,checkout_started,plan_upgraded. Прошедшее время — потому что событие фиксирует свершившийся факт, а не намерение.- Свойство, а не новое событие.
button_clickedсо свойствомbutton_idвместоpay_button_clicked,signup_button_clicked,cancel_button_clicked. Иначе через год у вас 900 имён событий и ни одной строимой воронки. - Никаких данных в имени:
purchase_1990_rub— гарантированная катастрофа. - Версионирование, а не переопределение. Сменить смысл существующего поля значит сломать все исторические сравнения.
- Реестр с владельцем. У каждого события есть человек, отвечающий за то, что оно означает. Рабочие форматы — Segment Tracking Plan и Snowplow; подход переносится на любой стек.
Идентичность: «уникальные пользователи» — это неправда
Пользователей вы не наблюдаете. Вы наблюдаете идентификаторы: cookie, device id, аккаунт. Отображение между ними и живыми людьми не биекция ни в одну сторону.
Что ломает эту модель: один человек владеет несколькими идентификаторами (телефон, ноутбук, рабочий браузер, инкогнито); один идентификатор используют несколько людей (семейный планшет); идентификатор недолговечен — Safari ограничивает срок жизни cookie, выставленных из JavaScript, семью днями (ITP 2.1).
Оценка масштаба. Пусть реальных людей 100 000: у 70% один идентификатор, у 20% два, у 10% четыре (два устройства плюс еженедельный сброс cookie). Наблюдаемое число уникальных за месяц:
$$100000 \times (0{,}7 \cdot 1 + 0{,}2 \cdot 2 + 0{,}1 \cdot 4) = 150000$$
MAU завышен в полтора раза. Удержание при этом, наоборот, занижено: вернувшийся человек с новой cookie выглядит новым, а не вернувшимся. Обе метрики сдвинуты, причём в разные стороны, и чистка данных этого не исправит — данные не грязные, они просто про идентификаторы, а вопрос был про людей. Как это искажает кривые — в главе про когорты и удержание.
Правило формулировки: не «120 тысяч пользователей», а «120 тысяч различных идентификаторов устройств за 30 дней». Это не занудство: первая формулировка провоцирует решения, которые вторая предотвращает.
Жизненный цикл события как конечный автомат
Инженерное следствие: Quarantine должен существовать как таблица. Дефолтная реакция многих пайплайнов на невалидное событие — выбросить; это худший вариант, потому что теряются и данные, и знание о потере. Карантин со счётчиками по event_name и schema_version превращает молчаливую потерю в видимую метрику. То же с дедупликацией: считайте, сколько дубликатов сняли — рост этого числа обычно означает деградацию сети раньше, чем это заметит мониторинг.
SQL: как допросить источник до начала анализа
Диалект PostgreSQL; в колоночных движках (ClickHouse и OLAP) синтаксис отличается деталями, логика та же.
1. Паспорт источника за сутки
-- Первый запрос к любому потоку: одна строка на источник и версию схемы.
SELECT
e.source,
e.schema_version,
count(*) AS events,
count(DISTINCT e.event_id) AS unique_events,
count(*) - count(DISTINCT e.event_id) AS duplicates,
count(*) FILTER (WHERE e.user_id IS NULL) AS anonymous_events,
count(*) FILTER (WHERE e.occurred_at > e.received_at) AS from_the_future,
percentile_cont(0.99) WITHIN GROUP (
ORDER BY extract(epoch FROM e.received_at - e.occurred_at)) AS lag_p99_sec
FROM analytics.events AS e
WHERE e.received_at >= DATE '2026-07-15'
AND e.received_at < DATE '2026-07-16'
GROUP BY e.source, e.schema_version
ORDER BY events DESC;
Как читать результат: duplicates заметно больше нуля — доставка «хотя бы один раз» работает как задумано, а дедуп ниже по потоку сломан или отсутствует. from_the_future больше нуля — часы устройств уходят вперёд, и агрегация по дням в occurred_at подтекает в будущее. lag_p99_sec в тысячах секунд — есть офлайн-буферы, значит вчерашняя витрина сегодня утром ещё неполна. Несколько schema_version одновременно — идёт раскатка релиза, и любое сравнение «до/после» в этот период смешивает эффект продукта с эффектом смены инструмента.
2. Дедупликация: оставить первую доставленную копию
-- Ровно одна строка на event_id. Первой считаем доставленную раньше;
-- тай-брейк по идентификатору пачки делает результат детерминированным
-- при повторном запуске джоба.
WITH ranked AS (
SELECT r.*,
row_number() OVER (PARTITION BY r.event_id
ORDER BY r.received_at, r.ingest_batch_id) AS rn
FROM analytics.events_raw AS r
WHERE r.received_at >= now() - INTERVAL '7 days'
)
SELECT * FROM ranked WHERE rn = 1;
Оконные функции подробно разбираются в главе про SQL для анализа. Здесь важно другое: запрос корректен только если event_id присвоен при возникновении события. Иначе дедупить нечего, и никакой SQL не спасёт.
3. Измерить выживший источник напрямую
Самый ценный запрос главы: посчитать одну метрику дважды — от полного списка сущностей и от «тех, у кого есть события». Разница и есть цена смещения.
-- Какая доля зарегистрировавшихся возвращается на 7-й день?
-- Знаменатель берём из операционной БД — ПОЛНЫЙ список регистраций,
-- а не список тех, у кого нашлись события. В этом вся суть.
WITH signups AS (
SELECT u.user_id, u.created_at::date AS signup_day
FROM crm.users AS u
WHERE u.created_at >= DATE '2026-06-01'
AND u.created_at < DATE '2026-07-01'
),
has_any_event AS (
SELECT DISTINCT s.user_id
FROM signups AS s
JOIN analytics.events AS e ON e.user_id = s.user_id
),
day7 AS (
SELECT DISTINCT s.user_id
FROM signups AS s
JOIN analytics.events AS e
ON e.user_id = s.user_id
AND e.occurred_at >= s.signup_day + INTERVAL '7 days'
AND e.occurred_at < s.signup_day + INTERVAL '8 days'
)
SELECT
count(*) AS signups_total,
count(*) FILTER (WHERE a.user_id IS NOT NULL) AS with_any_event,
count(*) FILTER (WHERE d.user_id IS NOT NULL) AS retained_d7,
-- честный ответ: знаменатель — все зарегистрировавшиеся
round(100.0 * count(*) FILTER (WHERE d.user_id IS NOT NULL)
/ nullif(count(*), 0), 2) AS retention_all_pct,
-- ответ «как обычно считают»: знаменатель — только видимые в событиях
round(100.0 * count(*) FILTER (WHERE d.user_id IS NOT NULL)
/ nullif(count(*) FILTER (WHERE a.user_id IS NOT NULL), 0), 2)
AS retention_visible_pct
FROM signups AS s
LEFT JOIN has_any_event AS a ON a.user_id = s.user_id
LEFT JOIN day7 AS d ON d.user_id = s.user_id;
Два последних столбца — одна и та же «метрика удержания», посчитанная по двум популяциям. Расходятся на проценты — источник надёжен. Расходятся в полтора раза — все исторические выводы про удержание были про выживших, а не про пользователей.
4. Детектор пропавшего источника
Молчаливая потеря целого потока — самая дорогая авария в аналитике: она выглядит как настоящее падение метрики.
-- Сработает и на «поток встал», и на «поток наполовину просел».
WITH hourly AS (
SELECT date_trunc('hour', e.received_at) AS h, e.source, count(*) AS events
FROM analytics.events AS e
WHERE e.received_at >= now() - INTERVAL '35 days'
GROUP BY 1, 2
),
baseline AS (
SELECT source,
extract(dow FROM h) AS dow,
extract(hour FROM h) AS hour_of_day,
percentile_cont(0.5) WITHIN GROUP (
ORDER BY events::double precision) AS median_events
FROM hourly
WHERE h < date_trunc('hour', now()) - INTERVAL '1 day'
GROUP BY 1, 2, 3
)
SELECT c.h, c.source, c.events, b.median_events,
round((100.0 * c.events / nullif(b.median_events, 0))::numeric, 1) AS pct_of_median
FROM hourly AS c
JOIN baseline AS b
ON b.source = c.source
AND b.dow = extract(dow FROM c.h)
AND b.hour_of_day = extract(hour FROM c.h)
WHERE c.h >= now() - INTERVAL '1 day'
AND c.events < 0.6 * b.median_events
ORDER BY c.h DESC, c.source;
Медиана, а не среднее, — чтобы одна аномалия в прошлом не поднимала порог (почему именно так — глава про описательную статистику). Группировка по дню недели и часу — потому что воскресенье в 4 утра и вторник в полдень несравнимы.
Паспорт источника: контракт, который останавливает половину ошибок
У каждого источника должен быть версионируемый файл рядом с кодом, а не вики-страница, которую никто не откроет.
id: web_events
owner: growth-analytics # кого будят, когда поток встал
collection: client # client | server | cdc | batch-import | manual
sdk: "analytics-js 3.11" # смена версии = разрыв в историческом ряду
covers: # на какие вопросы источник ОТВЕЧАЕТ
- клики и просмотры экранов в вебе
- UTM-метки первой сессии
does_not_cover: # на какие НЕ отвечает — читать вслух перед анализом
- пользователи с блокировщиками трекеров
- действия в мобильных приложениях
- всё, что происходило до 2025-03-11 (дата внедрения SDK)
known_biases:
- id: adblock
direction: занижает долю технически подкованной аудитории
estimate: "потери 25-35% desktop-сессий"
measured_by: "сверка с серверными логами, отчёт от 2026-05-20"
- id: itp_cookie_ttl
direction: завышает число уникальных, занижает удержание
timing:
event_time: "occurred_at, часы устройства, встречается сдвиг до суток"
arrival_sla: "p99 < 5 минут"
late_events_window_hours: 72
late_policy: "пересчёт витрины за последние 3 дня"
schema:
version: 4
on_violation: quarantine # не drop: иначе потери становятся невидимыми
volume_baseline:
events_per_day_p50: 12400000
alert_if_below_pct_of_median: 60
Секция does_not_cover важнее всех остальных вместе взятых: она превращает неявное предположение в утверждение, которое можно оспорить. Шире эта практика описана в главе про качество данных и управление ими.
Опросы и другие данные, порождённые людьми
Опрос — это не «данные о мнениях», а данные о мнениях тех, кто согласился ответить. Типичная доля ответивших в продуктовых опросах — единицы процентов, и отвечают не случайные люди: непропорционально много очень довольных и очень злых, середина молчит. Распределение получается двугорбым, и среднее по нему не описывает никого (Groves R. et al., «Survey Methodology», Wiley; про веб-опросы — Bethlehem J., «Selection Bias in Web Surveys», International Statistical Review, 2010).
- Всегда фиксируйте знаменатель: сколько увидело приглашение, сколько начало, сколько дошло до конца. Без этого «85% довольны» — не число.
- Опрос отвечает на вопрос «что люди говорят», события — «что люди делают». Расхождение не ошибка, а находка. Методики — в главах про исследования пользователей и продуктовое открытие.
- Формулировка и порядок вопросов меняют ответы: один и тот же опрос, заданный по-разному, — два разных источника, склеивать в один ряд нельзя.
- Ручные таблицы (менеджер заполняет статус сделки) заполняются под то, за что спрашивают. Это закон Гудхарта на уровне сбора; системная природа эффекта — в главе про обратные связи.
Типичные ошибки, которые дорого стоят
- Анализ по таблице событий вместо полного списка сущностей. Знаменатель молча подменяется на «видимых». Проверяется запросом 3 выше.
- Смена SDK или трекера посреди периода. Излом читают как изменение поведения. Лечится полем
sdk_versionи правилом: любое сравнение «до/после» проверяется на смену инструмента. - Фильтр ботов, отсекающий людей. «Убрать сессии короче 2 секунд» выкидывает и тех, у кого страница не загрузилась. Фильтр — это тоже отбор: его надо описывать в паспорте и мерить объём отфильтрованного.
- Дефолт SDK, неотличимый от осознанного значения:
country = 'US'для неопределившихся,plan = 'free'вместоNULL. Через полгода вы анализируете несуществующий сегмент. - Логи с ротацией 7 дней при вопросе про квартал. Период вопроса и период покрытия источника сверяются всегда.
- Поздние события и незакрытое окно. График «за сегодня» всегда падает к правому краю просто потому, что события ещё не доехали; как не подать этот артефакт как тренд — в главе про визуализацию.
- Ретроспективное определение группы. «Возьмём пользователей с тремя заказами и посмотрим их удержание» — группа определена через будущее, и удержание в ней прекрасно по построению. Механика ловушки — в главе про причинность.
Чек-лист: что спросить у источника перед анализом
- Кто владелец источника и кому писать, когда он встанет?
- Каким кодом порождается запись — клиент, сервер, ночной джоб, человек руками?
- С какой даты источник покрывает реальность и что было до неё?
- Какие сущности он не покрывает: платформы, регионы, сегменты, состояния?
- Какова оценка потерь и чем она измерена?
- Что происходит с невалидными записями — карантин или тихий
DROP? - Возможны ли дубликаты и по какому ключу они снимаются?
- Какие времена доступны и по какому корректно агрегировать?
- Как долго приходят поздние события и когда витрина считается закрытой?
- Менялись ли схема, SDK или определения ключевых полей за анализируемый период?
- Что означает «уникальный пользователь» в этом источнике: идентификатор, устройство, аккаунт?
- Сойдётся ли эта величина с независимым источником и кто-нибудь это уже проверял?
Двенадцатый вопрос самый сильный. Триангуляция — одна величина, измеренная двумя независимыми способами — даёт больше уверенности, чем любые статистические процедуры над одним источником: клиентские события против серверных логов, серверные логи против выписки платёжного провайдера, продуктовая метрика против выручки в бухгалтерии. Не сходится — вы узнали что-то важное до того, как построили на этом вывод.
Мини-итог
- Данные не «есть» — их производит конкретный процесс, и он определяет, на какие вопросы вы сможете ответить.
- Снимок состояния не отвечает на вопросы со словами «на момент» и «изменилось ли»: нужны события, CDC или хотя бы ежедневные снимки.
- Клиентский и серверный сбор слепы по-разному. Критичное меряется дважды, а расхождение источников — измеренный размер слепой зоны.
- Потери событий молчаливы: отсутствующая строка неотличима от «этого не было». Карантин и счётчики делают потерю видимой метрикой.
- Смещение не уменьшается с ростом $n$: $\text{bias} = (1 - p)(\mathbb{E}(Y \mid S{=}1) - \mathbb{E}(Y \mid S{=}0))$. Корреляция отбора 0,04 при охвате 70% превращает 700 000 записей в эквивалент 1700 случайных.
- Доверительный интервал описывает разброс, а не сдвиг: узкий интервал вокруг смещённой оценки — уверенно неверный ответ.
- «Уникальные пользователи» — это уникальные идентификаторы за окно. Формулируйте метрику так, как она посчитана.
- У каждого источника есть паспорт с секцией «на что не отвечает». Это дешевле любого расследования постфактум.
Источники
- Mangel M., Samaniego F. «Abraham Wald’s Work on Aircraft Survivability», Journal of the American Statistical Association, 1984 — разбор классической задачи об отборе исходом.
- Meng X.-L. «Statistical paradises and paradoxes in big data (I)», Annals of Applied Statistics, 12(2), 2018 — формула дефекта данных и эффективный размер выборки.
- Groves R. et al. «Survey Methodology», Wiley — нонреспонс и смещение самоотбора в опросах.
- Kahneman D. «Thinking, Fast and Slow» — принцип WYSIATI: мозг строит связный вывод из того, что видит, не отмечая отсутствующего.
- Segment: Tracking Plan и Segment Spec — рабочий формат словаря событий.
- Snowplow Docs: схемы и валидация — карантин невалидных событий как явный этап пайплайна.
- MDN: Navigator.sendBeacon(), Page Lifecycle API — надёжная отправка при уходе со страницы.
- WebKit: Intelligent Tracking Prevention 2.1 — семидневный срок жизни клиентских cookie.
Что дальше
Мы разобрались, откуда приходят данные и какой класс вопросов каждый источник в принципе способен закрыть. Дальше — то, что происходит с данными, которые всё-таки доехали: пропуски, дубликаты, поздние события и способы отличить дефект от сигнала.