Additive, semi-additive, non-additive measures
Прошлые уроки модуля несколько раз касались темы: остаток нельзя складывать по времени, среднее в fact-таблицу не кладут. Этот урок собирает всё в систему. Не каждое число в fact-таблице ведёт себя одинаково при суммировании — и Кимбалл делит measures на три класса по правилам аддитивности: additive, semi-additive, non-additive.
Это не теоретическая классификация для экзамена. Неверное обращение с аддитивностью — одна из самых частых и самых незаметных ошибок в аналитике: запрос с SUM по неаддитивному measure не падает, а тихо возвращает бессмысленное число. Понять три класса — значит научиться не порождать такие ошибки и распознавать их в чужих отчётах.
Аддитивность — это про SUM по разрезам
Суть аддитивности в одном вопросе: по каким dimensions этот measure можно корректно суммировать (SUM)?
Аналитика почти всегда сворачивает данные: берёт миллионы строк fact-таблицы и складывает measure по выбранным разрезам — по товару, по магазину, по времени. Операция SUM — основа любого аналитического отчёта. И вот ключевой факт: для одних measures SUM по любому разрезу даёт осмысленное число, для других — только по части разрезов, для третьих — никогда. По этому признаку measures и делятся на три класса.
Additive: складывается по всем разрезам
Additive measure (аддитивный) — это measure, который можно корректно суммировать по всем dimensions без исключения, включая время. Это самый простой, самый удобный и самый частый класс.
Классический пример — sales_amount, сумма продажи. Можно сложить продажи по товарам — получится осмысленная выручка по группе товаров. По магазинам — выручка сети. По дням, неделям, месяцам — выручка за период. По любому сочетанию разрезов сразу. SUM(sales_amount) всегда даёт корректное число.
Почему так? Потому что sales_amount — это поток, величина, которая возникает порциями и накапливается. Каждая продажа добавляет свою порцию денег к общей сумме. Сложить порции, возникшие в разных местах и в разное время, — корректно: это и есть «сколько всего денег пришло». Потоки складываются всегда.
Типичные additive measures: сумма продажи, количество проданных единиц, сумма скидки, число событий, сумма платежа, поступило штук за день, отгружено штук за день. Все они отвечают на вопрос «сколько чего-то произошло / накопилось», и все суммируются по любым разрезам.
-- additive measure: SUM корректен по любым разрезам и их сочетаниям
SELECT d.month_name, p.category, SUM(f.sales_amount) AS revenue
FROM fct_sales f
JOIN dim_date d ON f.date_key = d.date_key
JOIN dim_product p ON f.product_key = p.product_key
GROUP BY d.month_name, p.category;
-- складываем по месяцу И по категории одновременно — всё корректно
В fact-таблице стремятся держать именно additive measures — с ними аналитика проще и безопаснее всего. Если измеримую величину можно выразить как additive — её так и выражают.
Semi-additive: складывается, но не по времени
Semi-additive measure (полуаддитивный) — это measure, который можно корректно суммировать по некоторым dimensions, но НЕ по времени. Этот класс уже встречался в уроке про periodic snapshot — теперь разберём его строго.
Классический пример — account_balance, баланс счёта на конец дня. Или quantity_on_hand, остаток товара на складе.
По разрезам, кроме времени, — складывается. Баланс всех счетов одного клиента на конкретный день — SUM(account_balance) по счетам — корректно: это общая сумма денег клиента на этот момент. Остаток товара по всем складам на одну дату — корректно.
По времени — не складывается. Сложить баланс счёта за 30 дней месяца — бессмысленно. Если на счёте весь месяц лежало 1000 рублей, SUM по 30 дням даст 30 000 — но 30 000 рублей не существует, это одна и та же 1000, посчитанная 30 раз.
Причина — в природе величины. Баланс и остаток — это не поток, а уровень (level / balance): величина, которая существует в каждый момент и держится, а не накапливается. Уровень в понедельник и уровень во вторник — это не две порции, которые надо сложить, а одно и то же состояние, измеренное дважды. Складывать измерения уровня за разные моменты времени нельзя — это считать одно и то же многократно.
Что с semi-additive measure делать по времени корректно: не SUM, а другие агрегаты. Последнее значение — баланс на конец месяца — это значение последнего дня (LAST_VALUE или строка с максимальной датой). Среднее — AVG(account_balance) за месяц — корректный средний уровень. Минимум / максимум — тоже осмысленны. Запрещён по времени именно SUM; прочие агрегаты допустимы.
-- ПРАВИЛЬНО: SUM semi-additive measure по счетам на ОДИН день
SELECT SUM(account_balance) AS client_total
FROM fct_account_balance_daily
WHERE customer_key = 5012 AND date_key = 20250731;
-- client_total | 84000 (сумма всех счетов клиента на дату — корректно)
-- НЕПРАВИЛЬНО: SUM semi-additive measure по дням месяца
SELECT SUM(account_balance) AS wrong
FROM fct_account_balance_daily
WHERE account_key = 7 AND date_key BETWEEN 20250701 AND 20250731;
-- wrong | 31000 (БЕССМЫСЛЕННО: баланс 1000 посчитан 31 раз)
-- ПРАВИЛЬНО для времени: средний баланс за месяц
SELECT AVG(account_balance) AS avg_balance
FROM fct_account_balance_daily
WHERE account_key = 7 AND date_key BETWEEN 20250701 AND 20250731;
-- avg_balance | 1000 (средний уровень — корректно)
Semi-additive measures — главный источник тихих ошибок в аналитике. SUM по времени для баланса или остатка не падает — он возвращает число, завышенное во столько раз, сколько периодов попало в диапазон. Запомните признак: semi-additive measure отвечает на вопрос «сколько ЕСТЬ в этот момент» (остаток, баланс, число активных). Additive отвечает «сколько ПРОИЗОШЛО» (продано, поступило). «Есть» по времени не складывается, «произошло» — складывается.
Non-additive: не складывается вообще
Non-additive measure (неаддитивный) — это measure, который нельзя корректно суммировать ни по одному разрезу, включая отдельные строки. SUM для него бессмыслен всегда.
Типичные non-additive measures — это отношения, доли, проценты и цены за единицу: unit_price (цена за единицу), discount_percent (процент скидки), profit_margin (маржа в процентах), conversion_rate (конверсия).
Почему отношение нельзя складывать? Возьмём unit_price. Товар A стоит 100, товар B стоит 200. SUM(unit_price) по двум товарам даст 300 — но 300 не значит ничего: это не «цена двух товаров» (она зависит от количеств) и не «средняя цена». Сумма цен за единицу — бессмысленное число. То же с процентами: сложить «скидка 10%» и «скидка 20%» в «30%» — очевидная нелепость.
Глубокая причина: отношение — это числитель / знаменатель, и сумма отношений не равна отношению сумм. (a/b) + (c/d) не равно (a+c)/(b+d). Поэтому отношение нельзя ни сложить, ни даже усреднить наивным AVG — AVG по отношениям тоже даст неверный результат (среднее процентов скидки не равно общему проценту скидки).
Как правильно работать с non-additive measure. Правило: не хранить готовое отношение, а хранить его аддитивные компоненты — числитель и знаменатель отдельно — и вычислять отношение в самом конце, после суммирования.
Конкретно. Вместо того чтобы класть в fact-таблицу discount_percent, кладут два additive measure: discount_amount (сумма скидки) и gross_amount (сумма до скидки). Оба — потоки, оба складываются по любым разрезам. А процент скидки вычисляют запросом в самом конце:
-- ПРАВИЛЬНО: храним аддитивные компоненты, отношение считаем последним шагом
SELECT
p.category,
SUM(f.discount_amount) AS total_discount,
SUM(f.gross_amount) AS total_gross,
-- процент вычислен ПОСЛЕ суммирования числителя и знаменателя:
SUM(f.discount_amount) * 100.0 / SUM(f.gross_amount) AS discount_pct
FROM fct_sales f
JOIN dim_product p ON f.product_key = p.product_key
GROUP BY p.category;
-- category | total_discount | total_gross | discount_pct
-- Electronics | 48200.00 | 920000.00 | 5.24
-- Apparel | 31100.00 | 410000.00 | 7.59
discount_pct посчитан как SUM(числитель) / SUM(знаменатель) — отношение сумм, и это правильно. Если бы fact-таблица хранила готовый discount_percent, корректно свернуть его было бы невозможно. Принцип универсален: non-additive отношение раскладывают на additive компоненты, отношение восстанавливают на последнем шаге запроса.
Это и есть глубинная причина правила из модуля 11 «не клади средние и проценты в fact-таблицу». Среднее и процент — non-additive, их нельзя свернуть. В fact кладут additive слагаемые (суммы, количества), а средние и проценты вычисляет запрос: AVG как SUM/COUNT, процент как SUM(часть)/SUM(целое). Правило «в fact аддитивные числа» — это применение классификации аддитивности.
Три класса рядом
Итоговая карта. По каждому measure задайте вопрос «по каким разрезам корректен SUM» — ответ определяет класс.
| Класс | SUM по нечасовым разрезам | SUM по времени | Природа величины | Примеры |
|---|---|---|---|---|
| Additive | да | да | поток (накапливается порциями) | выручка, количество, сумма платежа |
| Semi-additive | да | нет (брать последнее или AVG) | уровень (существует и держится) | баланс счёта, остаток на складе |
| Non-additive | нет | нет | отношение (числитель/знаменатель) | цена за единицу, процент, маржа, конверсия |
Практический алгоритм при проектировании fact-таблицы. Для каждой кандидатной measure спросите: это поток, уровень или отношение? Поток — additive, кладите смело. Уровень — semi-additive, кладите, но в документации модели отметьте «не суммировать по времени». Отношение — non-additive, в fact-таблицу его не кладите вовсе: вместо него положите аддитивные числитель и знаменатель, а отношение пусть вычисляет запрос. Эта дисциплина превращает аддитивность из источника тихих ошибок в осознанное решение на этапе дизайна.
Попробуй сам
Возьмите fact-таблицу подписочного сервиса fct_subscriptions_daily (periodic snapshot, зерно — состояние подписок на конец дня) со следующими measures: active_subscriptions (число активных подписок на конец дня), new_subscriptions (новых за день), churned_subscriptions (отменённых за день), mrr (месячная регулярная выручка на конец дня), avg_revenue_per_user (средняя выручка на пользователя).
Для каждого из пяти measures определите класс — additive, semi-additive или non-additive — и обоснуйте через природу величины (поток, уровень, отношение). Для avg_revenue_per_user объясните, почему хранить его в fact-таблице — ошибка, и какие два аддитивных measure нужно положить вместо него.
Затем напишите два запроса. Первый, корректный: число новых подписок за месяц (additive measure, SUM по времени допустим). Второй, демонстрирующий ошибку: SUM(active_subscriptions) по дням месяца — посчитайте на придуманных числах, во сколько раз и почему результат завышен, и перепишите запрос правильно (последнее значение или среднее).