Learning Platform
Глоссарий Troubleshooting
Урок 13.04 · 17 мин
Начальный
measuresadditivityfact-tableaggregation

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 и делятся на три класса.

Три класса measures по правилам SUM
AdditiveАддитивные: SUM корректен по ВСЕМ разрезам, включая время. Например выручка
Semi-additiveПолуаддитивные: SUM корректен по части разрезов, но НЕ по времени. Например остаток, баланс
Non-additiveНеаддитивные: SUM некорректен вообще. Например цена за единицу, процент, отношение

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   (средний уровень — корректно)
WARNING

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). Поэтому отношение нельзя ни сложить, ни даже усреднить наивным AVGAVG по отношениям тоже даст неверный результат (среднее процентов скидки не равно общему проценту скидки).

Как правильно работать с 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 компоненты, отношение восстанавливают на последнем шаге запроса.

TIP

Это и есть глубинная причина правила из модуля 11 «не клади средние и проценты в fact-таблицу». Среднее и процент — non-additive, их нельзя свернуть. В fact кладут additive слагаемые (суммы, количества), а средние и проценты вычисляет запрос: AVG как SUM/COUNT, процент как SUM(часть)/SUM(целое). Правило «в fact аддитивные числа» — это применение классификации аддитивности.

GROUP BY и агрегатные функции — как вычислять меры разных классов в SQL

Три класса рядом

Итоговая карта. По каждому 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) по дням месяца — посчитайте на придуманных числах, во сколько раз и почему результат завышен, и перепишите запрос правильно (последнее значение или среднее).


Проверка знанийKnowledge check
Объясни три класса measures по аддитивности — additive, semi-additive, non-additive — и как правильно работать с non-additive measure.
ОтветAnswer
Аддитивность — это про то, по каким dimensions measure можно корректно суммировать через SUM, основную операцию аналитики. Three класса. Additive (аддитивный) — суммируется по всем разрезам без исключения, включая время; это поток, величина, которая возникает порциями и накапливается (выручка, количество проданного, сумма платежа); сложить порции из разных мест и времён корректно. Semi-additive (полуаддитивный) — суммируется по некоторым разрезам, но не по времени; это уровень, величина, которая существует в каждый момент и держится, а не накапливается (баланс счёта, остаток на складе). Баланс всех счетов клиента на один день сложить можно, а баланс одного счёта за 30 дней — нельзя: если весь месяц лежало 1000, SUM даст 30000, но это одна и та же 1000, посчитанная 30 раз. По времени для semi-additive measure корректны не SUM, а последнее значение или AVG. Non-additive (неаддитивный) — не суммируется ни по одному разрезу вообще; это отношение, числитель делить на знаменатель (цена за единицу, процент скидки, маржа, конверсия). Причина: сумма отношений не равна отношению сумм, (a/b)+(c/d) не равно (a+c)/(b+d); такое число нельзя даже наивно усреднить через AVG. Правильная работа с non-additive measure: не хранить готовое отношение в fact-таблице, а хранить его аддитивные компоненты — числитель и знаменатель отдельно (вместо discount_percent класть discount_amount и gross_amount), оба additive и складываются по любым разрезам; а само отношение вычислять запросом в самом конце как SUM(числитель)/SUM(знаменатель), то есть отношение сумм. Это и есть глубинная причина правила "не клади средние и проценты в fact-таблицу": их нельзя свернуть, в fact кладут аддитивные слагаемые.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что определяет, к какому классу аддитивности относится measure?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5