Как устроен слой метрик для AI-аналитика
Про семантический слой пишут примерно одинаково: определи метрику один раз, используй везде. Проблема формулируется как пятнадцать определений оттока у пятнадцати авторов, лекарство — заставить всех пользоваться одним.
У слоя, который читает агент, есть особенность, которой нет у обычного: его исполняют двое. Первый — сборщик запроса, обычная функция. Второй — сама модель, которой файл уходит в промпт целиком. Они видят разное и ведут себя по-разному, и почти всё интересное растёт отсюда.
Файл маленький: сто семь строк, три метрики. Дальше — как он устроен, кто его читает и что из этого следует.
О чём дальше
- Как устроен файл — цепочка строка · ключ · решение
- Пять слов, которые встречаются постоянно
- Два исполнителя — сборщик и модель, и кто из них что читает
- Что будет, если повторить через неделю — два измерения из шести
- Что означают решения — виды выручки, комментарии, три дыры
- Как файл живёт — три коммита, две строки из прогонов
- Что происходит, когда метрик становится много — симуляция на 24
- Чего нет в каноне и чего нет у меня
- Границы и с чего начать у себя
Как устроен файл
Сто семь строк. Но не всякая строка что-то объявляет, и не всякое объявление влияет на ответ. Отсюда три ступени, и дальше вся статья про них.
Строка. Всего их 107.
Ключ — строка вида имя: значение, где что-то объявлено. Таких 36. Остальные 71 — комментарии и пустые.
Решение — ключ, который меняет ответ агента. Таких 15. Остальные 21 ключ — названия метрик, их описания и структура файла.
Вот как это выглядит на одной метрике — я взял самую короткую из трёх и пометил каждую строку:
И рядом с этой цепочкой — 55 строк комментариев. Они ничего не объявляют и потому в неё не входят. Но привязаны они именно к решениям: объясняют, что будет при другом выборе.
На пятнадцать решений — пятьдесят пять строк объяснений.
Где всё это лежит
Файл состоит из трёх частей:
| часть | строк | из них комментариев | решений |
|---|---|---|---|
| шапка | 12 | 9 | — |
as_of | 8 | 3 | 3 |
metrics | 87 | 43 | 12 |
| всего | 107 | 55 | 15 |
Шапка — версия формата и девять строк о том, зачем файл вообще нужен. Ни одного решения, но именно здесь сказано главное: строка ниже — это решение, а не настройка.
as_of отвечает на вопрос «на какой момент мы смотрим на данные». Три решения, действуют на все метрики разом.
as_of:
source: cursor
filter_on: received_at
timezone: UTC
metrics отвечает на вопрос «что именно считаем». Три метрики, у каждой свой набор:
| метрика | строк | из них комментариев | решений | служебных полей |
|---|---|---|---|---|
active_customers | 28 | 15 | 3 | 3 |
revenue | 41 | 23 | 6 | 3 |
return_rate | 16 | 5 | 3 | 3 |
| всего | 85 | 43 | 12 | 9 |
До восьмидесяти семи не хватает двух строк: заголовка metrics: и пустой строки под ним.
Выручка вдвое сложнее соседей и по числу решений, и по объёму объяснений. Двадцать три строки комментариев на шесть решений — больше, чем у двух других метрик вместе.
Как сходятся два счёта
Выше файл разложен дважды: по частям и по цепочке строка — ключ — решение. Это два разреза одних и тех же ста семи строк, и вот как они пересекаются:
| часть | комментариев | ключей | пустых | всего |
|---|---|---|---|---|
| шапка | 9 | 1 | 2 | 12 |
as_of | 3 | 4 | 1 | 8 |
metrics | 43 | 31 | 13 | 87 |
| всего | 55 | 36 | 16 | 107 |
Тридцать шесть ключей набираются как 1 + 4 + 31.
А внутри этих тридцати шести решения занимают меньше половины:
| ключей | |
|---|---|
| решения | 15 |
| служебные поля: название, описание, список таблиц | 9 |
структура: version, заголовки двух блоков, имена трёх метрик | 6 |
| продолжения многострочных описаний | 6 |
| всего | 36 |
Служебные поля ответ не меняют: название метрики и её описание нужны человеку. Список таблиц — особый случай, о нём ниже.
Чего в файле нет ни на одном уровне — колонок. Ни одного места, где сказано, что физически суммировать и по какому выражению. Список таблиц есть, а как из них получить число — нет.
Пять слов, которые дальше встречаются постоянно
Слой метрик — вот этот файл. Записано, как считается каждая метрика.
Сборщик — код, который читает ключи по именам и собирает из них SQL-запрос.
Модель — она получает файл целиком, вместе с комментариями, и пишет SQL сама.
Харнесс — всё, что вокруг модели: что ей показать, как запустить, как разобрать её ответ. Сборщик — часть харнесса.
Два исполнителя
Сборщик. Обычная функция: читает ключи по имени и подставляет значения в шаблон запроса. Комментарии для него — символ решётки и всё, что после. Из пятнадцати решений файла он читает десять.
Модель. Слой уходит ей в промпт целиком:
def layer_metrics() -> str:
return (ROOT / "metrics" / "metrics.yml").read_text().strip()
Ни фильтра, ни выборки по метрике, ни выбрасывания комментариев. Список таблиц агенту отбирают, а слой отдают всегда целиком: агент на любом вопросе видит все три метрики и все пятьдесят пять комментариев, включая те, что к вопросу отношения не имеют. SQL при этом пишет сам.
Пропорция в моём проекте: сборкой получено 4.7% чисел, чтением — 95.3%.
Кто из них читает какую строку
В файле есть ключ revenue.guests с самым длинным комментарием: двенадцать строк, история появления и приписка капсом о том, что решение действует на обе стороны формулы.
Кусок кода, который собирает запрос для выручки, этот ключ не читает. Ни разу. Я проверил не чтением кода, а прогоном: поменял значение на exclude, потом на третий вариант, потом написал в него заведомую бессмыслицу — собранный запрос совпал побайтово во всех случаях. Шесть разных состояний файла, один и тот же хеш SQL.
Для сборщика этот ключ всё равно что не существует — дальше я называю такие строки мёртвыми. Для модели он не мёртв: когда я снял строку вместе с комментарием, агент начал давать два варианта вместо одного, и в SQL появились четыре фильтра, по два на каждую сторону формулы. Он прочитал двенадцать строк объяснения и применил решение к обеим сторонам сразу.
Вот все пятнадцать и кто их читает:
| решение | сборщик | ещё кто-то | итог |
|---|---|---|---|
as_of.source | нет | нет | не читает никто |
as_of.filter_on | нет | печатается в сообщении | на ответ не влияет |
as_of.timezone | нет | нет | не читает никто |
active_customers.window_days | да | работает | |
active_customers.activity | да | работает | |
active_customers.guests | да | работает | |
revenue.window_days | да | работает | |
revenue.statuses | да | работает | |
revenue.basis | да | работает | |
revenue.returns_attribution | да | работает | |
revenue.compare_to | нет | расчёт цены развилки | сборщик не исполняет |
revenue.guests | нет | расчёт цены развилки | сборщик не исполняет |
return_rate.window_days | да | работает | |
return_rate.grain | да | работает | |
return_rate.maturity_days | да | работает |
Десять из пятнадцати доходят до запроса. Три не читает никто, ещё два читает не сборщик, а вспомогательный код.
Мёртвых строк в файле оказалось три вида, и они разные:
- мёртвая для сборщика, живая для модели —
revenue.guests; - мёртвая для обоих, но видимая человеку —
as_of.filter_on. Он не влияет ни на запрос, ни на число, но печатается в сообщении рядом с ответом. Рассинхронизируется с обработкой данных — будет уверенно сообщать неверную отсечку; - строка, на которую агент перестал реагировать — о ней в следующем разделе.
Что будет, если повторить через неделю
Различаются они не только охватом.
Сборщик детерминирован по построению. Шесть разных состояний файла — шесть разных контрольных сумм — и один и тот же собранный запрос, байт в байт. Числа могли бы совпасть случайно, текст запроса нет.
С моделью иначе, и разброс не сводится к повторам одного прогона.
Первого сентября снятие строки revenue.guests меняло ответ: два варианта вместо одного, симметричный фильтр. Восьмого сентября на том же файле, том же промпте и неизменённом коде — один вариант трижды, и гостей в SQL нет вовсе.
Я перепроверил остальные пять развилок, по которым были измерения недельной давности. Воспроизвелись два из шести.
Шесть развилок, которые я снимал первого сентября, и что вышло восьмого:
| что снимал | 1 сентября | 8 сентября | воспроизвелось |
|---|---|---|---|
| привязка возврата | 1 вариант → 2 | то же самое | да |
| что считать активностью | 1 вариант → 2, трижды | то же самое | да |
| строками или штуками | было 1, стало 3 | было 2, стало 3 | нет |
| до возвратов или после | разошлись по этой оси | разошлись по длине окна | нет |
| гости в выручке | 1 вариант → 2, трижды | 1 вариант трижды | нет |
| гости в клиентах | 2 варианта, разные числа | 2 варианта, числа одинаковые | нет |
Развалились они по-разному, и это интереснее самой доли.
Одна потеряла альтернативу, которая жила только в комментарии. У решения про гостей в метрике клиентов было три варианта, третий — считать каждый гостевой заказ отдельным человеком. Первого сентября агент его предлагал. Восьмого перестал: единственное место, где этот вариант описан, — снятый комментарий, и без него он не восстанавливается.
Другая сохранила форму и сменила содержание. У решения про базу расчёта число вариантов совпало точь-в-точь, а расходиться они стали по длине окна вместо gross/net. Если бы я считал по числу вариантов, вердикт был бы «воспроизвелось».
И третье, что выяснилось попутно. Чтобы понять, подействовало ли снятие строки, ответ на изменённом файле сравнивают с ответом на нетронутом. Второй и есть точка отсчёта.
Так вот, она не стоит на месте. По одному из вопросов ответ на нетронутом файле давал один вариант первого сентября, два третьего, снова смесь четвёртого. За двое суток точка отсчёта стала неотличима от изменённого файла, хотя файл никто не трогал.
Три правила, если будете мерить своё:
- У измерения на агенте есть дата. Писать её рядом с числом.
- Ответ на нетронутом файле снимается в том же запуске, что и на изменённом. Сравнивать с записями недельной давности нельзя.
- Мерить надо парой: по какой оси разошлись варианты и в скольких повторах она поднялась. Ни одна мера по отдельности не работает — я убедился в этом дважды, и оба раза в разные стороны.
Что означают решения
Пятнадцать решений — это по объявлению. По исполнению меньше: три ключа as_of не решения, а зафиксированные соглашения, они описывают то, что зашито в обработке данных.
У восьми решений из пятнадцати рядом записаны альтернативы:
# completed_order — активность это завершённый заказ.
# any_order — отменённый заказ тоже активность: человек приходил.
activity: completed_order
У остальных семи альтернативы не записаны — стоит одно значение без вариантов. Разница практическая: развилку с альтернативами можно проверить снятием, голое значение — нет. Про него неизвестно даже, был ли выбор.
Виды выручки. Одна метрика, шесть решений, и каждое даёт своё число.
| решение | выбрано | альтернатива | что меняется |
|---|---|---|---|
| гости | include | exclude | 13 141 142.28 → 11 479 710.42 · 09-01, не воспроизвелось 09-08 |
| привязка возврата | return_date | order_date | 13 141 142.28 → 13 402 161.46 · 09-01, воспроизвелось 09-08 |
| база | net_of_returns | gross | не измерено |
| статусы | [completed] | + cancelled | не измерено |
| окно | 90 дней | календарный месяц | измерено иначе |
Привязка возврата — самое понятное из решений. return_date: возврат вычитается из окна, когда он случился, месяц закрыт и больше не меняется. order_date: возврат вычитается из окна, где лежит заказ, и март пересчитывается ещё три недели после марта. Оба защитимы. Ни одно не является ошибкой. Числа разные.
Комментарии. Пятьдесят пять строк комментариев на пятнадцать решений. Они не объясняют, что делает ключ. Они объясняют, что произойдёт при другом выборе.
Раздел выше показал, почему это не документация: единственная альтернатива, описанная только в комментарии, исчезла из ответов вместе с ним.
Три дыры. У доли возвратов нет решения про гостей — она не различает гостевые заказы вовсе, при том что у двух соседних метрик это решение объявлено и с разными значениями. У выручки нет дозревания, хотя она вычитает возвраты. Окно наблюдения стоит у всех трёх метрик голым значением — самое влиятельное решение файла и единственное, которое я ни разу не проверил снятием.
Отдельно, чтобы не путать: пара 353.40 против 360.18, расхождение 1.9% — это не цена развилки про гостей. Там оба варианта гостей исключают, а расходится симметричность применения: фильтр поставлен в продажах и не поставлен в вычитаемых возвратах. Цена развилки — другая пара и 12.64%.
Как файл живёт
Три коммита за восемь дней, один человек, ревью не было.
Большинство решений объявлено заранее. Но два приехали из прогонов, и это записано прямо в файле:
Решение появилось после разбора: продакт попросил «выручку без гостей», а такого объекта в этом слое не было вовсе.
guestsстояло только у метрик о клиентах, и агент собрал комбинацию сам — непоследовательно: гостей отфильтровал в числителе и не отфильтровал в вычитаемом. Развилка, которую никто не объявил, опаснее незакрытой: спросить о ней было нельзя.
Второе — форма сравнения. Строки не хватило посреди опыта, и я дописал её, не останавливая прогон.
Разбираться пришлось долго. Сначала казалось, что дописанная строка испортила прогон. Потом выяснилось, что она вообще ничего не исполняет: ни одна ветка сборщика этот ключ не читает. И ось, за которую он отвечает, не разошлась ни разу — агент сравнивал с прошлым годом и до того, как строка появилась.
Испорчено было другое. Разбор шёл по прогону, где ключа ещё не было, а цена развилки считалась по слою, где он уже был. Числа посчитаны верно, но относятся к разным состояниям файла — и сошлись в отчёте так, будто к одному.
Восстановить хронологию не вышло: время коммита не совпадает со временем правки, а контрольной суммы файла в записях тогда не было. Отсюда правило: у каждого прогона должна быть записана ревизия слоя. Без него через неделю нельзя сказать, к какому состоянию файла относится число. У меня 895 чисел из 897 такой привязки не имеют, и это ограничение всех выводов, а не оговорка к одному.
Про остальное — версионирование, названный владелец, журнал изменений, согласование правок — индустрия давно всё описала под именем управления метриками. У меня ничего этого нет, и это честнее, чем изобретать своё.
Что происходит, когда метрик становится много
Мой файл маленький. Чтобы посмотреть на масштаб, я собрал симуляцию: тот же домен, двадцать четыре метрики вместо трёх, те же конвенции записи. Это не измерение — ни один прогон на нём не гонялся, — но арифметика видна.
Две сущности растут по-разному. as_of не вырос вовсе: свойство мира одно на любое число метрик. metrics вырос в восемь раз.
Словарь решений растёт медленнее метрик. Восьмикратный рост дал только вдвое больше разных ключей: восемнадцать против девяти. Окно понадобилось двадцати трём метрикам из двадцати четырёх, гости — одиннадцати.
А комментарии растут вместе с употреблениями. Уникального текста тридцать семь строк, всего — сто восемьдесят три. Семьдесят девять процентов комментариев оказались дублями. Объяснение про гостей записано одиннадцать раз, слово в слово.
Проблема не в размере файла, а в том, что при уточнении формулировки её надо поправить в одиннадцати местах.
И заполненность падает. У меня три метрики и девять решений: двадцать семь клеток, заполнено двенадцать. В симуляции двадцать четыре метрики и восемнадцать решений: четыреста тридцать две клетки, заполнена сто одна. С сорока четырёх процентов до двадцати трёх.
Чем больше метрик, тем труднее отличить «здесь решение не нужно» от «здесь про него забыли». Свои три дыры я нашёл глазами. На двадцати четырёх глазами не выйдет.
Напрашивается третий раздел — словарь решений, где альтернатива и объяснение лежат один раз, а метрика называет только выбранное значение. Файл сжимается примерно на треть, править надо в одном месте.
Я этого не делаю, и причина в разделе про двух исполнителей. Вынос объяснения из метрики убирает его оттуда, где его читает модель. А единственная альтернатива, описанная только в комментарии, исчезла из ответов ровно тогда, когда комментарий сняли.
Чего нет в каноне и чего нет у меня
Возьмите любое решение из таблицы выше и поищите ему поле в спецификации семантического слоя.
Привязка возврата уедет в выражение метрики. База — туда же. Дозревание не имеет соответствия вовсе. Общее у всех трёх одно: выбор превращается в кусок SQL и перестаёт быть отдельной строкой, которую можно прочитать. В файле остаётся одна формула, и то, что был выбор из двух защитимых вариантов, нигде не отражается.
Канон описывает структуру: по чему резать, что с чем джойнится. Выбор между двумя определениями он не описывает, потому что устроен под допущение, что верное определение одно.
Управление метриками, к слову, прямо советует документировать допущения. Но советует людям — чтобы не спорили на совещании. Куда положить допущение так, чтобы его прочитал исполнитель, не говорит никто.
Симметрично про мой файл. Ни сущностей, ни измерений, ни агрегаций. Посчитать по нему физически нельзя: там нет ни одного места, где сказано, как получить число. Это выясняет агент сам, читая схему данных.
Границы
Три метрики, пятнадцать решений, шесть таблиц. Слой заведомо помещается в контекст целиком — что будет на трёхстах метриках, я не мерил.
95.3% чисел получены чтением, а не сборкой. Значит выводы этой статьи — про слой как текст в промпте. Про слой как конфигурацию сборки известно мало: тринадцать ключей из пятнадцати я ни разу не менял, и теперь понятно, что не проверил их сразу по двум осям.
Два измерения из шести не воспроизвелись через неделю. Все числа здесь — с датами, и это не педантизм.
И восемь из десяти чисел получены на одном синтетическом стенде.
С чего начать у себя
Не строить слой. Взять одну метрику, которую считают чаще всего, и выписать по ней решения, которые уже принимались.
Отметить, у каких есть альтернативы, а какие стоят голым значением. Вторых обычно больше, и это именно те места, где никто не помнит, был ли выбор.
У меня на это ушло сто семь строк и три вечера.
Приложение: файл целиком
Сто семь строк, как есть.
Дальше файл целиком — он нужен, чтобы сверить любое число из статьи, читать его подряд не обязательно.
# Семантический слой. Написан руками и читается построчно — в этом весь смысл.
#
# Каждая строка ниже — решение, а не настройка. Метрика без владельца этих
# решений не метрика, а число, о происхождении которого никто не спорил,
# потому что никто его не формулировал.
#
# Проверка понимания: поменяй ОДНУ строку, не трогая данные, и посмотри,
# как переворачивается ответ. Обе цифры — в NOTES.md.
version: 1
# Общая отсечка. Действует на все метрики без исключения.
as_of:
source: cursor # момент из state/cursor.txt, он же world_cursor
filter_on: received_at # НЕ бизнес-дата: событие, доехавшее после курсора,
# не видно, даже если случилось до него. Иначе мир
# всеведущ и прошлое переписывается задним числом.
timezone: UTC # шесть магазинов пишут в локальной шкале;
# приведение делает staging, см. NOTES.md
metrics:
active_customers:
label: Активные клиенты
description: >
Сколько покупателей были активны в окне. Три решения, каждое меняет
ответ: длина окна, что считать активностью, что делать с гостями.
window_days: 90
# completed_order — активность это завершённый заказ.
# any_order — отменённый заказ тоже активность: человек приходил.
activity: completed_order
# exclude — гостевые заказы не считаем вовсе. Нижняя граница:
# отвечает на «сколько ИЗВЕСТНЫХ нам клиентов».
# count_each_order — каждый гостевой заказ считаем отдельным человеком.
# Верхняя граница: один и тот же человек с тремя
# гостевыми заказами превращается в трёх.
# ignore_guests_exist — то же, что exclude, но честно называется:
# гостей нет в знаменателе НИ ОДНОЙ метрики о клиентах.
guests: exclude
# Сущности, без которых метрику не посчитать. Список нужен не агенту,
# а ОТБОРУ КОНТЕКСТА: он раньше подбирал таблицы по словам вопроса,
# и в вопросе про выручку слова «возврат» нет — fct_returns не
# попадала в промпт, агент честно докладывал, что возвратов в схеме
# нет, и считал gross вместо net_of_returns. Определение метрики
# знает, что ей нужно; вопрос — не знает.
tables: [dim_customers, fct_order_items]
revenue:
label: Выручка
description: >
Сумма продаж в окне. Решения: какие статусы считаем, вычитаем ли
возвраты и по какой дате их относим.
window_days: 90
statuses: [completed] # добавь 'cancelled' — получишь спрос, а не выручку
# gross — до возвратов.
# net_of_returns — за вычетом возвратов.
basis: net_of_returns
# return_date — возврат вычитается из того окна, КОГДА он случился.
# Операционный взгляд: месяц закрыт и больше не меняется.
# order_date — возврат вычитается из окна, где лежит ЗАКАЗ.
# Взгляд на качество продаж: март пересчитывается ещё три
# недели после марта.
returns_attribution: return_date
# previous_period — сравнивать окно с предыдущим таким же окном.
# year_over_year — сравнивать окно с тем же окном год назад.
compare_to: year_over_year
# include — гостевые заказы считаются наравне с остальными: выручка
# есть выручка, платил её человек с аккаунтом или без.
# exclude — только заказы известных клиентов.
#
# Решение появилось после разбора: продакт попросил «выручку без
# гостей», а такого объекта в этом слое не было вовсе. guests стояло
# только у метрик о КЛИЕНТАХ, и агент собрал комбинацию сам —
# непоследовательно: гостей отфильтровал в числителе и не
# отфильтровал в вычитаемом. Развилка, которую никто не объявил,
# опаснее незакрытой: спросить о ней было нельзя.
#
# ВАЖНО: решение действует на ОБЕ стороны формулы — и на продажи,
# и на вычитаемые возвраты. Иначе получится тот самый чек 353.40
# вместо 360.18.
guests: include
tables: [fct_order_items, fct_returns]
return_rate:
label: Доля возвратов
description: >
Какая часть проданного вернулась. Решения: считать строками или
штуками и обрезать ли окно на дозревание.
window_days: 90
grain: line # line | unit
# Возврат приходит через 3-21 день. Заказы последних 21 дня перед
# курсором ещё не дозрели: часть их возвратов не случилась.
# 21 — считать только по дозревшим когортам.
# 0 — считать по всему окну и получить заниженную цифру, которая
# растёт задним числом при каждом сдвиге курсора.
maturity_days: 21
tables: [fct_returns, fct_order_items]