Files
progcode/editorial/agent-rewrites/089.json
T

9 lines
23 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 89,
"slug": "editorial-2025-07-mechanism-product-metrics",
"title": "Рост conversion не равен улучшению продукта: проверяем denominator и guardrail",
"excerpt": "Практический способ проверить продуктовую метрику до решения: зафиксировать событие, population, cohort, attribution, окно и denominator, затем сопоставить сигнал с guardrail и остановить вывод при разрыве данных.",
"readingMinutes": 9,
"contentHtml": "<p>На дашборде treatment показывает conversion 52%, а control — 48%. Команда готовит выпуск. На разборе выясняется, что в treatment считали уникальных открывших экран, а в control — все строки события. Часть подтверждений пришла без связи с вариантом. Проценты выглядят убедительно, но сравнивают разные множества.</p>\n<p>Цена ошибки — не только неверный график. Команда может раскатить изменение, которого пользователь не заметил, потерять доверие к аналитике и потратить следующий спринт на поиск причины. Если одновременно выросли ошибки рендера, задержка или отмены, локальный рост conversion скрывает ущерб.</p>\n<p>Метрика становится основанием для инженерного решения только вместе с контрактом измерения. В нём явно записаны субъект, событие успеха, population, cohort, attribution, окно, numerator, denominator и guardrail. При нарушении контракта результат получает статус <code>hold</code>: неполные данные нельзя выдавать за нулевой эффект или за победу варианта.</p>\n<h2>Сначала зафиксируйте вопрос, а потом считайте</h2>\n<p>Conversion — это не свойство экрана или кнопки, а дробь над выбранной population. До запроса к хранилищу ответьте на пять вопросов: кого считаем, какое событие открывает воронку, какое событие считается успехом, к какому варианту относим субъекта и в каком окне ждём результат.</p>\n<p>В этой статье учебный контракт такой: <code>subject</code> — обезличенный идентификатор пользователя, <code>population</code> — субъекты с <code>checkout_opened</code>, numerator — субъекты с <code>checkout_confirmed</code>, единица счёта — один субъект. Оба события должны относиться к одному cohort и дню. Поэтому формула выглядит так:</p>\n<pre><code>conversion = unique(subject where event = checkout_confirmed)\n / unique(subject where event = checkout_opened)</code></pre>\n<p>Если один субъект нажал кнопку трижды, три строки могут быть полезны для диагностики повторов, но не должны превращать одного человека в трёх участников знаменателя. Если вопрос другой — например, «сколько подтверждений на тысячу попыток» — это допустимая другая метрика. Её нельзя молча сравнивать с пользовательской conversion.</p>\n<h2>Где дробь начинает лгать</h2>\n<p>Первый источник разрыва — смена единицы счёта. Запрос по строкам события может показать рост после того, как клиент начал отправлять повторный <code>checkout_confirmed</code>. Запрос по уникальным субъектам этот повтор уберёт. Оба запроса технически корректны, но отвечают на разные вопросы.</p>\n<p>Второй источник — несовместимые population. Если знаменатель treatment строится по открывшим checkout, а знаменатель control — по всем посетителям, разница отражает состав групп, а не поведение продукта. В отчёте рядом с каждой долей должны быть абсолютные значения: <code>numerator</code>, <code>denominator</code>, число уникальных субъектов и число сырых строк.</p>\n<p>Третий источник — неверная attribution, то есть привязка outcome к exposure и варианту. Подтверждение без <code>subject</code>, <code>exposure_id</code> или времени нельзя надёжно приписать treatment. При отсутствии связи система должна возвращать причину <code>missing-attribution</code>, а не выбирать вариант по последнему известному значению.</p>\n<p>Событие само по себе тоже имеет контракт. В официальной спецификации OpenTelemetry событие — это именованное происшествие с временем возникновения и структурированными атрибутами; динамические идентификаторы не должны попадать в имя события. Для продуктовой аналитики это означает практическое правило: имя вроде <code>checkout_confirmed</code> остаётся стабильным, а <code>subject</code>, <code>exposure_id</code> и <code>cohort</code> хранятся отдельными полями. Спецификация не определяет вашу conversion, поэтому остальные поля нужно согласовать в проекте.</p>\n<figure><img src=\"/assets/editorial/2025/product-metrics-2025-metric-guardrail-matrix.svg\" alt=\"Матрица проверки продуктовой метрики: attribution, denominator, cohort и period проверяются до сопоставления local signal с guardrail; при нарушении расчёт получает HOLD\" loading=\"lazy\" /><figcaption>Локальное движение метрики — только сигнал. Сначала проверяется измерительный контракт, затем оцениваются guardrail и решение владельца продукта.</figcaption></figure>\n<h2>Воспроизводимый расчёт на маленьком наборе</h2>\n<p>Сохраните следующий фрагмент как <code>metrics-example.mjs</code> и запустите командой <code>node metrics-example.mjs</code>. Набор намеренно мал: в нём видны дедупликация субъектов и отдельный guardrail. Числа учебные и не описывают production-трафик.</p>\n<pre><code>const events = [\n { event: 'checkout_opened', subject: 'u-1', cohort: 'control' },\n { event: 'checkout_confirmed', subject: 'u-1', cohort: 'control' },\n { event: 'checkout_opened', subject: 'u-2', cohort: 'control' },\n { event: 'render_failed', subject: 'u-2', cohort: 'control' },\n { event: 'checkout_opened', subject: 'u-3', cohort: 'treatment' },\n { event: 'checkout_confirmed', subject: 'u-3', cohort: 'treatment' },\n { event: 'checkout_confirmed', subject: 'u-3', cohort: 'treatment' },\n { event: 'checkout_opened', subject: 'u-4', cohort: 'treatment' },\n];\n\nfunction uniqueSubjects(eventName, cohort) {\n return new Set(\n events\n .filter((item) =&gt; item.event === eventName &amp;&amp; item.cohort === cohort)\n .map((item) =&gt; item.subject),\n );\n}\n\nfunction ratio(numerator, denominator) {\n if (denominator === 0) return { status: 'hold', reason: 'empty-denominator' };\n return { status: 'ok', value: numerator / denominator };\n}\n\nfor (const cohort of ['control', 'treatment']) {\n const opened = uniqueSubjects('checkout_opened', cohort);\n const confirmed = uniqueSubjects('checkout_confirmed', cohort);\n const failed = uniqueSubjects('render_failed', cohort);\n const failedAfterOpen = [...failed].filter((subject) =&gt; opened.has(subject)).length;\n\n console.log(cohort, {\n conversion: ratio(\n [...confirmed].filter((subject) =&gt; opened.has(subject)).length,\n opened.size,\n ),\n renderFailure: ratio(failedAfterOpen, opened.size),\n openedSubjects: opened.size,\n confirmedSubjects: confirmed.size,\n });\n}</code></pre>\n<p>Ожидаемый результат: в каждой группе conversion равна <code>1 / 2 = 0.5</code>. В control guardrail рендера тоже равен <code>1 / 2</code>, а в treatment — <code>0</code>. Повторное подтверждение <code>u-3</code> не меняет conversion, потому что множество удаляет дубликат. Обратите внимание: этот код не проверяет, что вариант был назначен случайно, и не доказывает причинный эффект.</p>\n<p>Перед расчётом в рабочем отчёте полезно хранить не только число, но и описание запроса:</p>\n<pre><code>{\n \"name\": \"checkout_confirmation_rate\",\n \"unit\": \"unique_subject\",\n \"population\": \"checkout_opened\",\n \"outcome\": \"checkout_confirmed\",\n \"cohortKey\": \"checkout_v2\",\n \"period\": \"2025-07-14T00:00:00Z/2025-07-15T00:00:00Z\",\n \"attribution\": \"same subject and exposure_id\",\n \"guardrails\": [\"render_failure_rate\", \"cancel_rate\"]\n}</code></pre>\n<p>Такой объект не является универсальным стандартом. Это минимальный проектный шаблон, который делает запрос проверяемым через ревью, повторный запуск и сравнение версий схемы.</p>\n<h2>Attribution и окно наблюдения</h2>\n<p>Attribution нужно определить до просмотра результата. Для короткого checkout можно требовать тот же <code>subject</code>, связанный <code>exposure_id</code> и outcome после exposure. Для отложенной покупки понадобится другое окно и, возможно, серверное событие. Нельзя переносить правило из одного продукта в другой только потому, что названия событий совпадают.</p>\n<p>События должны различать время, когда действие произошло, и время, когда его приняла аналитическая система. Задержка доставки может сделать вчерашнее окно неполным. Практический отчёт поэтому содержит <code>event_time</code>, <code>received_at</code> и дату среза. Пока данные ещё догружаются, статус отчёта — <code>pending</code>, а не «конверсия равна нулю».</p>\n<p>Проверяйте и границы окна: включается ли начало, исключается ли конец, что делать с часовыми поясами, когда субъект открыл экран до полуночи, а подтвердил после неё. Одна и та же граница должна применяться treatment и control. Смешанное окно — причина пересобрать выборку.</p>\n<h2>Сигнал успеха и guardrail должны быть рядом</h2>\n<p>Success metric отвечает на вопрос о желаемом результате. Local metric показывает ближайший шаг. Guardrail ограничивает цену улучшения: ошибки рендера, отмены, задержку или обращение в поддержку. Guardrail — не украшение отчёта, а условие, при котором рост success metric перестаёт быть приемлемым.</p>\n<div class=\"table-scroll\"><table><caption>Симптом, проверка и безопасное действие</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Что проверить</th><th scope=\"col\">Безопасное действие</th></tr></thead><tbody><tr><td>Conversion выросла сразу после изменения tracking</td><td>Число субъектов, сырые строки, deduplication и долю доставки каждого события до и после изменения</td><td>Остановить интерпретацию и восстановить прежнее определение denominator</td></tr><tr><td>Treatment и control имеют неожиданное соотношение</td><td>Exposure, распределение вариантов, пропуски и задержку событий</td><td>Не объявлять победителя; проверить sample-ratio и pipeline</td></tr><tr><td>Outcome не содержит cohort или exposure_id</td><td>Схему события и цепочку от exposure до результата</td><td>Вернуть <code>hold: missing-attribution</code></td></tr><tr><td>Local metric растёт вместе с ошибками</td><td>Guardrail на той же population и в том же окне</td><td>Сравнить с заранее заданным порогом и привлечь владельца риска</td></tr><tr><td>События пришли после закрытия окна</td><td>Разницу между <code>event_time</code> и <code>received_at</code></td><td>Пометить отчёт <code>pending</code> или пересобрать окно</td></tr></tbody></table></div>\n<p>Порог guardrail задают до чтения результата и связывают с владельцем риска. Формулировка «ошибок стало больше» не годится: нужны числитель, знаменатель, окно и действие при нарушении. Если допустимый порог неизвестен, отчёт может показать наблюдение, но не должен сам объявлять выпуск безопасным.</p>\n<h2>Положительный и отрицательный путь</h2>\n<p>Положительный путь означает, что расчёт прошёл проверки и может попасть к человеку, принимающему решение. Он не означает, что изменение уже доказанно улучшает продукт. Отрицательный путь возвращает конкретную причину и сохраняет выборку для исправления.</p>\n<pre><code>function inspect(report) {\n const reasons = [];\n\n if (!report.attribution) reasons.push('missing-attribution');\n if (report.denominator !== 'unique-opened-subjects') {\n reasons.push('wrong-denominator');\n }\n if (report.periods.length !== 1) reasons.push('mixed-period');\n if (report.dataQuality !== 'ok') reasons.push('data-quality');\n if (report.guardrailRate &gt; report.guardrailLimit) {\n reasons.push('guardrail-breached');\n }\n\n return reasons.length === 0\n ? { status: 'eligible-for-human-review', reasons: [] }\n : { status: 'hold', reasons };\n}\n\n// Инспектор проверяет условия расчёта.\n// Он не читает telemetry и не запускает rollout.</code></pre>\n<p>В отрицательном пути нет подстановки default cohort, усреднения пропущенных событий или выбора удобного denominator. Если период смешан, выборку пересобирают. Если attribution отсутствует, чинят схему или правило связи. Если guardrail нарушен, владелец риска принимает отдельное решение. Функция не должна прятать эти причины в <code>null</code> или зелёный статус.</p>\n<p>Controlled rollout помогает разделить эксперимент и доставку: в работе Microsoft Research описаны одновременное сравнение вариантов, поэтапное расширение аудитории, работа с exposed populations, длительностью и pass criteria. Это поддерживает порядок проверки, но не делает учебные числа доказательством и не заменяет статистический дизайн конкретного эксперимента.</p>\n<h2>Порядок проверки перед решением</h2>\n<ol><li><strong>Назовите решение.</strong> Запишите, что возможно после расчёта: продолжить наблюдение, остановить rollout или передать данные владельцу.</li><li><strong>Опишите population.</strong> Укажите субъект, правило включения, cohort и точные границы окна.</li><li><strong>Разложите дробь.</strong> Напишите словами numerator и denominator. Сверьте единицу счёта, deduplication и повторные события.</li><li><strong>Проверьте attribution.</strong> У outcome должна быть воспроизводимая связь с exposure и вариантом.</li><li><strong>Проверьте качество данных.</strong> Сверьте доставку, expected ratio групп, пропуски, задержку и разницу event time/received time.</li><li><strong>Положите рядом guardrail.</strong> Используйте совместимые population и окно; заранее назовите порог и владельца риска.</li><li><strong>Прогоните испорченные входы.</strong> Подайте mixed period, wrong denominator, missing attribution и превышенный guardrail. Ожидайте <code>hold</code> с конкретными причинами.</li><li><strong>Передайте человеку.</strong> После проверок владелец продукта оценивает риск, ограничения, причинность и дальнейший rollout.</li></ol>\n<h2>Ограничения применимости</h2>\n<p>Контракт метрики не доказывает случайное распределение, достаточную мощность, отсутствие сезонности или причинный эффект. Он отвечает на более узкий вопрос: одинаково ли определены данные, на которых построено сравнение. Для причинного вывода нужны подходящий дизайн эксперимента, длительность и статистический анализ.</p>\n<p>Маленький набор в примере не моделирует реальный трафик. В production дополнительно проверяют идемпотентность отправки, потерю событий в клиенте, задержку очереди, часовые пояса, приватность, retention и доступ к идентификаторам. В финансовых, медицинских и других чувствительных сценариях эти требования нельзя заменить одним полем <code>subject</code>.</p>\n<p>Критерий готовности воспроизводим: независимый инженер по записи может восстановить population, numerator, denominator, cohort, период, attribution, качество данных и guardrail. На валидном наборе инспектор возвращает <code>eligible-for-human-review</code>, а на каждом специально испорченном наборе — <code>hold</code> с причиной. Ни один путь не публикует результат и не запускает rollout автоматически.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://opentelemetry.io/docs/specs/semconv/general/events/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenTelemetry: Semantic conventions for events</a> — официальная спецификация в статусе Development описывает именованные события, время возникновения и структурированные attributes. Она подтверждает правила для event schema, но не задаёт product metric, denominator или порог guardrail.</li><li><a href=\"https://www.microsoft.com/en-us/research/publication/safe-velocity-a-practical-guide-to-software-deployment-at-scale-using-controlled-rollout/\" target=\"_blank\" rel=\"noopener noreferrer\">Microsoft Research: Safe Velocity</a> — первичная публикация о controlled experiments, phased rollouts, exposed populations, длительности наблюдения и pass criteria. Она не подтверждает учебные числа статьи и не заменяет дизайн конкретного эксперимента.</li><li><a href=\"https://www.microsoft.com/en-us/research/publication/a-dirty-dozen-twelve-common-metric-interpretation-pitfalls-in-online-controlled-experiments/\" target=\"_blank\" rel=\"noopener noreferrer\">Microsoft Research: A Dirty Dozen</a> — первичная работа о типичных ошибках интерпретации метрик в online controlled experiments. Она обосновывает необходимость проверять состав наблюдений и условия сравнения, но не доказывает результат этой статьи.</li></ul>"
}