Аналитика данных Откуда берутся данные: сбор, события, выживший источник
0%

Откуда берутся данные: сбор, события, выживший источник

Откуда берутся данные: сбор, события, выживший источник

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

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

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

Данных не существует, есть процесс их производства

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

Три вопроса к любому столбцу, прежде чем строить на нём вывод:

  1. Кто и когда его записал. Приложение при действии пользователя? Ночной джоб? Менеджер руками в админке? Импорт из чужой системы полтора года назад?
  2. Что происходит, когда записать не удалось. Строки нет? Остаётся NULL? Подставляется дефолт SDK, неотличимый от осознанного выбора?
  3. Что происходит при изменении. Пишется новая строка или затирается старая? Если затирается — истории просто не существует, и никакой SQL её не вернёт.

Третий вопрос убивает больше анализов, чем первые два. Классика: «покажи распределение тарифов на момент регистрации» по таблице users, где plan обновляется через UPDATE. Такого распределения в природе нет — есть сегодняшний срез, задним числом применённый к прошлому. Запрос отработает, число появится, график построится. Вывод будет ложным.

Каждая ветка отвечает на свой класс вопросов и молчит про остальные. Смешивать их можно — но только назвав вслух, чем отличается их покрытие. Про то, как эти потоки физически доезжают до хранилища, — трек по инженерии данных; нам здесь важна не транспортировка, а её последствия для вывода.

Состояние против события: разница, из которой растёт половина ошибок

Снимок состояния отвечает на вопрос «как сейчас»: строка в users, orders, subscriptions. Читается тривиально, обновляется на месте. Проблема одна — времени в нём нет.

Лог событий отвечает на вопрос «что произошло»: неизменяемые записи «в момент T пользователь U сделал X с параметрами P». Из событий состояние восстанавливается сворачиванием (идея event sourcing), обратно — нет. Асимметрия принципиальная: событие содержит больше информации, чем состояние, и стоит дороже.

Снимок состояния Лог событий
Отвечает на «как сейчас» «что и когда произошло»
Восстановление истории невозможно по построению
Объём размер сущностей размер активности, растёт со временем
Типичный носитель OLTP-таблица поток и колоночное хранилище
Что ломает анализ UPDATE затирает прошлое потери, дубликаты, поздние приходы

Практический вывод: если в вопросе есть слова «на момент», «до того как», «изменилось ли» — снимок состояния на него не отвечает никогда. Нужны либо события, либо медленно меняющиеся измерения SCD Type 2 из моделирования данных, либо CDC, включённый до того, как вопрос возник.

Половинчатое, но честное решение, когда событий нет: ежедневный снимок состояния в отдельную таблицу. Гранулярность — сутки, зато история появляется с завтрашнего дня. Это дёшево и почти всегда лучше, чем через полгода объяснять, почему на вопрос ответить нельзя.

Клиент или сервер: два разных мира слепоты

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

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

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

Что реально помогает:

  • navigator.sendBeacon() для отправки при уходе со страницы — запрос переживает выгрузку документа (MDN). Флашить надо по visibilitychange в hidden, а не по beforeunload: на мобильных beforeunload часто не срабатывает вовсе (Page Lifecycle API).
  • Приём событий со своего домена вместо домена трекера: снимает часть блокировок по спискам, но не антитрекинг браузеров (WebKit ITP, Firefox ETP).
  • Персистентная очередь на устройстве: событие переживает офлайн и перезапуск (офлайн-данные в мобильных).
  • Двойное измерение критичных шагов. То, что важно для денег, меряется и на клиенте, и на сервере. Расхождение — не баг, а ваша измеренная слепая зона, и её надо знать числом.

Ограничения согласия на сбор данных (приватность и комплаенс) — ещё один неслучайный фильтр поверх всех перечисленных.

Воронка выживания события

Воронка выживания клиентского события: из 100 действий до витрины доживает 61, и потери коррелируют с типом пользователя

Числа иллюстративные — у вашего продукта они другие. Важна структура: потери не распределены равномерно. Блокировщики стоят у более технически подкованных, офлайн-потери приходятся на мобильный трафик в дороге, отвал по 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 дней при вопросе про квартал. Период вопроса и период покрытия источника сверяются всегда.
  • Поздние события и незакрытое окно. График «за сегодня» всегда падает к правому краю просто потому, что события ещё не доехали; как не подать этот артефакт как тренд — в главе про визуализацию.
  • Ретроспективное определение группы. «Возьмём пользователей с тремя заказами и посмотрим их удержание» — группа определена через будущее, и удержание в ней прекрасно по построению. Механика ловушки — в главе про причинность.

Чек-лист: что спросить у источника перед анализом

  1. Кто владелец источника и кому писать, когда он встанет?
  2. Каким кодом порождается запись — клиент, сервер, ночной джоб, человек руками?
  3. С какой даты источник покрывает реальность и что было до неё?
  4. Какие сущности он не покрывает: платформы, регионы, сегменты, состояния?
  5. Какова оценка потерь и чем она измерена?
  6. Что происходит с невалидными записями — карантин или тихий DROP?
  7. Возможны ли дубликаты и по какому ключу они снимаются?
  8. Какие времена доступны и по какому корректно агрегировать?
  9. Как долго приходят поздние события и когда витрина считается закрытой?
  10. Менялись ли схема, SDK или определения ключевых полей за анализируемый период?
  11. Что означает «уникальный пользователь» в этом источнике: идентификатор, устройство, аккаунт?
  12. Сойдётся ли эта величина с независимым источником и кто-нибудь это уже проверял?

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

Мини-итог

  • Данные не «есть» — их производит конкретный процесс, и он определяет, на какие вопросы вы сможете ответить.
  • Снимок состояния не отвечает на вопросы со словами «на момент» и «изменилось ли»: нужны события, 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.

Что дальше

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

Качество данных: пропуски, дубликаты, поздние события

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов