ABC нужен везде, где есть ассортимент: товары, клиенты, счета, ошибки в логах — что угодно, что даёт условную «выручку». Математика простая: сортируем по убыванию, считаем накопительную долю, режем на 80/15/5.
Все открытые DAX-реализации, которые я встречал, сводятся к одной и той же идее из шаблона Russo & Ferrari на daxpatterns.com. Читается идеально. Тормозит так же идеально: как только каталог переваливает за несколько тысяч строк, отчёт с ABC-матрицей становится неюзабельным. На 100 тыс. SKU я видел, как она падает с таймаутом.
Корень проблемы — один FILTER в середине формулы, делающий полный скан таблицы товаров для каждого товара. Алгоритмически O(N²), 10⁸ операций на 10 тыс. SKU. Дальше — как это починить без потери читаемости.
Продажи (date, product_id, amount, qty), Номенклатура (id, name), Сегментация ABC (классы с границами), 000 Календарь. База замеров: Power BI Desktop, DAX Studio-совместимые метрики через MCP-трейсинг.
Классическая мера (DaxPatterns) и её проблема
Референс-реализация из The Definitive Guide to DAX выглядит так (адаптированная под нашу модель):
ABC (by DaxPatterns.com) =
VAR SalesByProduct =
CALCULATETABLE (
ADDCOLUMNS (
SUMMARIZE ('Продажи', 'Номенклатура'[ID]),
"@ProdSales", [Продажи руб]
),
ALLSELECTED ('Номенклатура')
)
VAR AllSales =
CALCULATE ([Продажи руб], ALLSELECTED ('Номенклатура'))
VAR CumulatedPctByProduct =
ADDCOLUMNS (
SalesByProduct,
"@CumulatedPct",
VAR CurrentSalesAmt = [@ProdSales]
VAR CumulatedSales =
FILTER (SalesByProduct, [@ProdSales] >= CurrentSalesAmt)
VAR CumulatedSalesAmount = SUMX (CumulatedSales, [@ProdSales])
RETURN
MIN (DIVIDE (CumulatedSalesAmount, AllSales), 1)
)
VAR ProductsInClass =
FILTER (
CROSSJOIN (CumulatedPctByProduct, 'Сегментация ABC'),
AND (
[@CumulatedPct] > 'Сегментация ABC'[Нижняя граница],
[@CumulatedPct] <= 'Сегментация ABC'[Верхняя граница]
)
)
RETURN
CALCULATE ([# SKU], KEEPFILTERS (ProductsInClass))
Для каждого товара считается накопительный процент (сумма продаж товаров с выручкой не меньше текущей, делённая на общий итог), затем CROSSJOIN с таблицей границ кладёт товар в нужный класс. Всё динамически, пороги меняются через фильтры отчёта — именно эта часть и нужна.
Плохая часть — строка FILTER (SalesByProduct, [@ProdSales] >= CurrentSalesAmt). Она выполняется для каждого из 9999 товаров и каждый раз делает SUMX по всем 9999 строкам. Получается ~10⁸ операций на одну ABC-ячейку. Математически корректно, на практике — катастрофа.
Цифры с нашей модели:
| Прогон | Total | FE | SE | SE queries | Peak mem |
|---|---|---|---|---|---|
| DaxPatterns, холодный старт | 6 670 мс | 6 604 | 66 | 7 | 8.3 MB |
| DaxPatterns, тёплая модель | 13 797 мс | 13 666 | 130 | 7 | 8.3 MB |
99% времени в Formula движок. Storage движок отработал за 130 мс — данные подняты мгновенно, дальше всё умирает в DAX-движке. Тёплая модель (когда Power BI Desktop поработал минут 15 и потерял часть ресурсов на фоновые процессы) удваивает время — именно это увидит пользователь у себя в проде.
На модели со 100 тыс. товаров эта же мера у меня не возвращается вообще. Таймаут, OOM, что-то между — неважно, результата нет.
Идея: chunking в два прохода
Когда квадратичная сложность сидит на кумулятивной сумме, стандартный приём — разбить данные на блоки и решать в два прохода:
- Грубый проход по блокам. Делим отсортированный список товаров на чанки по ~1500 штук. Для каждого чанка считаем накопительную долю выручки до конца чанка — дешёвая операция, один раз на чанк.
- Точный проход только в граничных чанках. Находим чанки, где кумулятивная доля пересекает порог 80% (граница A) и 95% (граница B). Только внутри этих чанков (~1500 товаров) считаем точный O(N²) cumulative sum. Остальные 8500 товаров либо гарантированно в «своём» классе, либо гарантированно не в нём — детально разбирать их не надо.
Сложность схлопывается до O(N + K² × число классов), где K — размер чанка. При N=10 тыс. и K=1500: 10 000 + 1500²×2 = 4.5 млн операций вместо 100 млн. На N=100 тыс. разрыв уже на два порядка — поэтому naive-версия там ложится, а chunking работает.
Алгоритм пошагово
Собираем продажи по товарам, отбрасываем нули
Стандартный SUMMARIZE + FILTER по Amount > 0. Нулевые/BLANK исключаются сразу — они не должны классифицироваться ни в A, ни в B, ни в C и ломать алгоритм тоже не должны.
Ранжируем с tie-breaker по ID
Главный трюк: обычный RANKX по [@Amount] может дать одинаковый ранг двум товарам с равной выручкой. Это ломает последующее разбиение на чанки по рангу. Решение — микро-tie-breaker: ROUND([@Amount], 2) × 100 + [ID] / 1 000 000. ID младшего порядка гарантированно не переопределяет Amount, но делает ранг уникальным.
Дальше нормируем ранг в диапазон [0 .. RANK_SCALE]. RANK_SCALE = 1 000 000 выбран с запасом на любой разумный каталог (хоть 1 млн SKU).
Разбиваем на чанки через GENERATESERIES
ChunkCount = ROUNDUP(ActiveCount / CHUNK_SIZE). Для 9999 товаров и CHUNK_SIZE = 1500 получаем 7 чанков. GENERATESERIES(1, 7) — даёт таблицу с номерами чанков.
Считаем накопительные метрики по чанкам
Для каждого чанка считаем три столбца:
- @CumShare — накопительная доля выручки «до конца чанка N»
- @MaxRank — максимальный ранг (граница чанка по рангу)
- @MinAmount — минимальная выручка (пригодится для фильтрации граничных товаров)
Операция линейная по числу товаров (N × число_колонок), не квадратичная.
Определяем граничный чанк для класса A
Ищем первый чанк, где @CumShare уже превысил 80%, и последний, где ещё не превысил. Граничный чанк — между ними. Товары с рангом до «@MaxRank последнего чанка до границы» гарантированно в A. Остальным нужна точная проверка.
Точный кумулятивный расчёт ТОЛЬКО в граничном чанке
Для 1500 товаров граничного чанка делаем классический O(K²) cum-sum с прибавлением накопленной доли предыдущих полностью-A чанков. Получаем точный порог по выручке (ThresholdAmount), который делит A и B внутри граничного чанка.
Симметрично для класса B, формируем таблицу ID → Class
Повторяем шаги 5-6 с порогом 95%. Класс C — всё, что не в A и не в B (через EXCEPT). Финальный UNION с лейблами — в CALCULATE + KEEPFILTERS.
Production-мера с чистым кодом
Вот оптимизированная DAX-мера целиком. Имена переменных говорящие, логика разбита блок-комментариями, константы вынесены наверх. Под твою модель заменяешь 'Продажи руб', 'Номенклатура', '000 Календарь', 'Сегментация ABC' на свои имена.
ABC Продажи руб (refactored) =
// ══════════════════════════════════════════════════════════════════
// ABC-классификация по накопительной доле выручки (chunking).
// Сложность: O(N) + O(CHUNK_SIZE²) вместо O(N²).
// ══════════════════════════════════════════════════════════════════
VAR CHUNK_SIZE = 1500 // эмпирически оптимальный размер чанка
VAR RANK_SCALE = 1000000 // масштаб нормированного ранга
VAR BoundaryA = CALCULATE([MaxBoundary], 'Сегментация ABC'[Сегмент] = "A")
VAR BoundaryB = CALCULATE([MaxBoundary], 'Сегментация ABC'[Сегмент] = "B")
VAR BoundaryC = CALCULATE([MaxBoundary], 'Сегментация ABC'[Сегмент] = "C")
// ─── ШАГ 1-2. Подготовка и ранжирование ───
VAR AllProducts =
SUMMARIZE(
ALLSELECTED('Номенклатура'),
'Номенклатура'[ID],
"@Amount", CALCULATE([Продажи руб], ALLSELECTED('000 Календарь'))
)
VAR ActiveProducts = FILTER(AllProducts, [@Amount] > 0)
VAR ActiveCount = COUNTROWS(ActiveProducts)
VAR TotalAmount = SUMX(ActiveProducts, [@Amount])
VAR RankedProducts =
ADDCOLUMNS(
ActiveProducts,
"@Rank",
ROUND(
DIVIDE(
RANKX(
ActiveProducts,
IF(NOT ISBLANK([@Amount]), ROUND([@Amount], 2) * 100, 0)
+ [ID] / RANK_SCALE, // tie-breaker
, DESC, DENSE
),
ActiveCount
) * RANK_SCALE,
0
),
"@Share", DIVIDE([@Amount], TotalAmount)
)
// ─── ШАГ 3-4. Чанкинг и накопительные доли ───
VAR ChunkCount = ROUNDUP(DIVIDE(ActiveCount, CHUNK_SIZE), 0)
VAR ChunkSeries = GENERATESERIES(1, IF(ISBLANK(ChunkCount), 1, ChunkCount))
VAR ChunkCumulatives =
ADDCOLUMNS(
ChunkSeries,
"@CumShare",
DIVIDE(
SUMX(
FILTER(RankedProducts, [@Rank] <= DIVIDE([Value], ChunkCount) * RANK_SCALE),
[@Amount]
),
TotalAmount
),
"@MaxRank",
MAXX(
FILTER(RankedProducts, [@Rank] <= DIVIDE([Value], ChunkCount) * RANK_SCALE),
[@Rank]
),
"@MinAmount",
MINX(
FILTER(RankedProducts, [@Rank] <= DIVIDE([Value], ChunkCount) * RANK_SCALE),
[@Amount]
)
)
// ─── ШАГ 5-6. Граница класса A ───
VAR RankBeforeA = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryA), [@MaxRank])
VAR MinAmountAfterA = MAXX(FILTER(ChunkCumulatives, [@CumShare] > BoundaryA), [@MinAmount])
VAR CumBeforeA = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryA), [@CumShare])
VAR BoundaryChunkA =
FILTER(RankedProducts, [@Rank] > RankBeforeA && [@Amount] >= MinAmountAfterA)
VAR BoundaryChunkA_WithCum =
ADDCOLUMNS(
BoundaryChunkA,
"@CumShareExact",
VAR CurrentRank = [@Rank]
RETURN
SUMX(FILTER(BoundaryChunkA, [@Rank] <= CurrentRank), [@Share])
+ IF(ISBLANK(CumBeforeA), 0, CumBeforeA)
)
VAR ThresholdAmountA =
MINX(FILTER(BoundaryChunkA_WithCum, [@CumShareExact] <= BoundaryA), [@Amount])
// ─── ШАГ 5-6. Граница класса B (симметрично) ───
VAR RankBeforeB = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryB), [@MaxRank])
VAR MinAmountAfterB = MAXX(FILTER(ChunkCumulatives, [@CumShare] > BoundaryB), [@MinAmount])
VAR CumBeforeB = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryB), [@CumShare])
VAR BoundaryChunkB =
FILTER(RankedProducts, [@Rank] > RankBeforeB && [@Amount] >= MinAmountAfterB)
VAR BoundaryChunkB_WithCum =
ADDCOLUMNS(
BoundaryChunkB,
"@CumShareExact",
VAR CurrentRank = [@Rank]
RETURN
SUMX(FILTER(BoundaryChunkB, [@Rank] <= CurrentRank), [@Share])
+ IF(ISBLANK(CumBeforeB), 0, CumBeforeB)
)
VAR ThresholdAmountB =
MINX(FILTER(BoundaryChunkB_WithCum, [@CumShareExact] <= BoundaryB), [@Amount])
// ─── ШАГ 7. Классификация и финальный фильтр ───
VAR ProductsInA =
UNION(
SELECTCOLUMNS(FILTER(BoundaryChunkA_WithCum, [@Amount] >= ThresholdAmountA), "ID", [ID]),
SELECTCOLUMNS(FILTER(RankedProducts, [@Rank] <= RankBeforeA), "ID", [ID])
)
VAR ProductsInAB =
UNION(
SELECTCOLUMNS(FILTER(BoundaryChunkB_WithCum, [@Amount] >= ThresholdAmountB), "ID", [ID]),
SELECTCOLUMNS(FILTER(RankedProducts, [@Rank] <= RankBeforeB), "ID", [ID])
)
VAR ProductsInB = EXCEPT(ProductsInAB, ProductsInA)
VAR ProductsInC = EXCEPT(SELECTCOLUMNS(RankedProducts, "ID", [ID]), ProductsInAB)
VAR ProductsInCurrentClass =
FILTER(
UNION(
ADDCOLUMNS(ProductsInA, "@Class", "A", "@Value", BoundaryA),
ADDCOLUMNS(ProductsInB, "@Class", "B", "@Value", BoundaryB),
ADDCOLUMNS(ProductsInC, "@Class", "C", "@Value", BoundaryC)
),
[@Value] > [MinBoundary] && [@Value] <= [MaxBoundary]
)
RETURN
CALCULATE([Продажи руб], KEEPFILTERS(ProductsInCurrentClass))
Плюс две вспомогательные меры, которые читают динамические границы классов из таблицы-справочника:
MinBoundary = MIN('Сегментация ABC'[Нижняя граница])
MaxBoundary = MAX('Сегментация ABC'[Верхняя граница])
И справочник:
Сегментация ABC =
DATATABLE(
"Классификация", STRING, "Сегмент", STRING,
"Нижняя граница", DOUBLE, "Верхняя граница", DOUBLE,
{
{"ABC", "A", 0.00, 0.80},
{"ABC", "B", 0.80, 0.95},
{"ABC", "C", 0.95, 1.00}
}
)
Пользователь двигает слайсер «Сегмент» — мера мгновенно пересчитывает, какие товары попадают в этот сегмент и какую выручку они дают. Классы лежат в отдельной таблице, пороги меняются без пересборки модели — поэтому «динамический» в названии.
Бенчмарк: chunking vs DaxPatterns
Сравниваем на той же модели, одинаковый SUMMARIZECOLUMNS-запрос с осью по сегменту ABC:
| Мера | Cold start | Warm model | FE % | Speedup | Результат A/B/C |
|---|---|---|---|---|---|
| ABC (by DaxPatterns.com) | 6 670 | 13 797 | 99% | 1× | 4845/2420/2734 |
| ABC Продажи руб (chunking, original) | 407 | 1 026 | 96% | 16.4× | 4845/2420/2734 ✓ |
| ABC Продажи руб (refactored) | 388 | 989 | 94% | 17.2× / 13.9× | 4845/2420/2734 ✓ |
Оба варианта chunking (оригинальный и рефакторенный) дают одинаковый speedup и идентичные значения A=4845, B=2420, C=2734. Рефакторинг — только про читаемость, без регрессии по скорости.
На модели 100 тыс. товаров DaxPatterns-версия не возвращает результат в разумное время (timeout/OOM), chunking-версия работает стабильно.
Grid-search: какой ChunkSize выбрать
Хочется сделать адаптивный ChunkSize: пусть формула сама подстраивается под размер каталога. Прогнали 10 фиксированных значений плюс адаптивную эвристику 15%. Минимум из 2-3 прогонов на каждый:
Я ожидал плавную U-кривую — где-то оптимум, по краям хуже. Получилось не совсем так:
- Зависимость НЕ монотонная. Оптимум резко выделен на CS=1500, соседние 1200 и 1700 хуже в 2-4 раза. Значения рядом лежат в кратерах, а не на склонах.
- При CS=5000 мера медленнее, чем naive DaxPatterns. Всего два чанка, в граничном — 5000 товаров, точный cum-sum внутри O(25 млн) — сопоставимо с исходной квадратичной задачей.
- Adaptive 15% даёт те же 7 чанков, что и CS=1500. Математически идентично. Но 917 мс vs 388 мс — в 2.4 раза медленнее. Разница в одной строке:
INT(ActiveCount * 0.15)против константы 1500. DAX-оптимизатор не умеет folding-ать выражение, которое зависит от runtime-значения — весь план меры перестраивается хуже. Я проверил это специально — реально зависит только от INT().
Почему именно 1500? На нашей модели с Парето 80/15/5 класс A занимает ~1500 топовых товаров по выручке. Первый чанк почти целиком попадает в A, граничный чанк A остаётся почти пустым, точный cum-sum внутри делает минимум работы. Если у вас другое распределение (резкий long-tail, мягкое 70/30 или смешанные категории) — оптимум уедет. Прогоните grid-search у себя, это 10 минут.
Типичные ошибки при переносе в свою модель
- Забыли tie-breaker в RANKX. При одинаковых выручках два товара получат один ранг, разбиение на чанки поедет, на границе классов могут быть «пропавшие» или «удвоенные» товары.
- ID не целочисленный или > RANK_SCALE. Tie-breaker через
[ID] / RANK_SCALEрассчитан на то, что ID ≤ 1 000 000. Для GUID/UUID нужен отдельный суррогатный integer key. - FILTER по T2 вместо T3. Классика багов: ADDCOLUMNS возвращает новую таблицу, но в следующих VAR легко сослаться на старую и потерять
@Rank/@Share. В рефакторинге одно имяRankedProductsвезде — ошибка невозможна. - ActiveCount считаем от всех продуктов, а не от продуктов с Amount > 0. Тогда ChunkCount включает нулевые товары, границы чанков сбиваются, класс C может переполниться.
- Пропущен
ALLSELECTED('000 Календарь')в CALCULATE. Тогда ABC реагирует на фильтр по дате в визуале. Иногда это нужно, чаще — нет. Решите, должен ли ABC-класс меняться при выборе периода, и сознательно выбирайте ALLSELECTED / ALL / KEEPFILTERS. - Класс D (нулевые продажи) не вынесен явно. В нашей мере товары с Amount = 0 игнорируются (отфильтрованы в ActiveProducts). Если в отчёте нужно различать «нет продаж» и «остатки есть, но нет продаж» — добавьте отдельный класс D и ветку в ProductsInCurrentClass.
- Слишком большой CHUNK_SIZE. Если чанков становится 2-3, мера может стать медленнее naive-версии. Проверьте через свой grid-search.
Когда chunking оправдан
| Размер каталога | Naive DaxPatterns | Chunking CS=1500 | Рекомендация |
|---|---|---|---|
| < 1 000 SKU | < 100 мс | < 150 мс | Naive хватает. Сложность chunking не окупается. |
| 1 000 — 5 000 | 0.5-3 с | 150-400 мс | Начинаем задумываться: UX уже ощутимо страдает на матрицах. |
| 5 000 — 50 000 | 5-60 с | 0.5-3 с | Chunking обязателен. Naive даёт неюзабельный отчёт. |
| > 50 000 | timeout / OOM | 3-15 с | Только chunking, naive не выдерживает. |
Готовый DAX для копирования
Чтобы не переписывать формулы вручную — обе меры (naive базовый уровень и chunking-рефактор) ниже в раскрывающихся блоках. Копируешь, вставляешь в свою модель, меняешь имена таблиц (Продажи, Номенклатура, 000 Календарь, Сегментация ABC) на свои — всё.
ABC (by DaxPatterns.com) — naive базовый уровень для сравнения
ABC (by DaxPatterns.com) =
VAR SalesByProduct =
CALCULATETABLE (
ADDCOLUMNS (
SUMMARIZE ( 'Продажи', 'Номенклатура'[ID] ),
"@ProdSales", [Продажи руб]
),
ALLSELECTED ( 'Номенклатура' )
)
VAR AllSales =
CALCULATE ( [Продажи руб], ALLSELECTED ( 'Номенклатура' ) )
VAR CumulatedPctByProduct =
ADDCOLUMNS (
SalesByProduct,
"@CumulatedPct",
VAR CurrentSalesAmt = [@ProdSales]
VAR CumulatedSales =
FILTER ( SalesByProduct, [@ProdSales] >= CurrentSalesAmt )
VAR CumulatedSalesAmount = SUMX ( CumulatedSales, [@ProdSales] )
VAR Perc = DIVIDE ( CumulatedSalesAmount, AllSales )
RETURN MIN ( Perc, 1 )
)
VAR ProductsInClass =
FILTER (
CROSSJOIN ( CumulatedPctByProduct, 'Сегментация ABC' ),
AND (
[@CumulatedPct] > 'Сегментация ABC'[Нижняя граница],
[@CumulatedPct] <= 'Сегментация ABC'[Верхняя граница]
)
)
RETURN
CALCULATE ( [# SKU], KEEPFILTERS ( ProductsInClass ) )
ABC Продажи руб (refactored) — chunking-оптимизация, 14-17× быстрее
ABC Продажи руб (refactored) =
// ══════════════════════════════════════════════════════════════════
// ABC-классификация по накопительной доле выручки (chunking).
// Сложность: O(N) + O(CHUNK_SIZE²) вместо O(N²).
// ══════════════════════════════════════════════════════════════════
VAR CHUNK_SIZE = 1500 // эмпирически оптимальный размер чанка
VAR RANK_SCALE = 1000000 // масштаб нормированного ранга
VAR BoundaryA = CALCULATE([MaxBoundary], 'Сегментация ABC'[Сегмент] = "A")
VAR BoundaryB = CALCULATE([MaxBoundary], 'Сегментация ABC'[Сегмент] = "B")
VAR BoundaryC = CALCULATE([MaxBoundary], 'Сегментация ABC'[Сегмент] = "C")
// ─── ШАГ 1-2. Подготовка и ранжирование ───
VAR AllProducts =
SUMMARIZE(
ALLSELECTED('Номенклатура'),
'Номенклатура'[ID],
"@Amount", CALCULATE([Продажи руб], ALLSELECTED('000 Календарь'))
)
VAR ActiveProducts = FILTER(AllProducts, [@Amount] > 0)
VAR ActiveCount = COUNTROWS(ActiveProducts)
VAR TotalAmount = SUMX(ActiveProducts, [@Amount])
VAR RankedProducts =
ADDCOLUMNS(
ActiveProducts,
"@Rank",
ROUND(
DIVIDE(
RANKX(
ActiveProducts,
IF(NOT ISBLANK([@Amount]), ROUND([@Amount], 2) * 100, 0)
+ [ID] / RANK_SCALE, // tie-breaker
, DESC, DENSE
),
ActiveCount
) * RANK_SCALE,
0
),
"@Share", DIVIDE([@Amount], TotalAmount)
)
// ─── ШАГ 3-4. Чанкинг и накопительные доли ───
VAR ChunkCount = ROUNDUP(DIVIDE(ActiveCount, CHUNK_SIZE), 0)
VAR ChunkSeries = GENERATESERIES(1, IF(ISBLANK(ChunkCount), 1, ChunkCount))
VAR ChunkCumulatives =
ADDCOLUMNS(
ChunkSeries,
"@CumShare",
DIVIDE(
SUMX(
FILTER(RankedProducts, [@Rank] <= DIVIDE([Value], ChunkCount) * RANK_SCALE),
[@Amount]
),
TotalAmount
),
"@MaxRank",
MAXX(
FILTER(RankedProducts, [@Rank] <= DIVIDE([Value], ChunkCount) * RANK_SCALE),
[@Rank]
),
"@MinAmount",
MINX(
FILTER(RankedProducts, [@Rank] <= DIVIDE([Value], ChunkCount) * RANK_SCALE),
[@Amount]
)
)
// ─── ШАГ 5-6. Граница класса A ───
VAR RankBeforeA = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryA), [@MaxRank])
VAR MinAmountAfterA = MAXX(FILTER(ChunkCumulatives, [@CumShare] > BoundaryA), [@MinAmount])
VAR CumBeforeA = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryA), [@CumShare])
VAR BoundaryChunkA =
FILTER(RankedProducts, [@Rank] > RankBeforeA && [@Amount] >= MinAmountAfterA)
VAR BoundaryChunkA_WithCum =
ADDCOLUMNS(
BoundaryChunkA,
"@CumShareExact",
VAR CurrentRank = [@Rank]
RETURN
SUMX(FILTER(BoundaryChunkA, [@Rank] <= CurrentRank), [@Share])
+ IF(ISBLANK(CumBeforeA), 0, CumBeforeA)
)
VAR ThresholdAmountA =
MINX(FILTER(BoundaryChunkA_WithCum, [@CumShareExact] <= BoundaryA), [@Amount])
// ─── ШАГ 5-6. Граница класса B (симметрично) ───
VAR RankBeforeB = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryB), [@MaxRank])
VAR MinAmountAfterB = MAXX(FILTER(ChunkCumulatives, [@CumShare] > BoundaryB), [@MinAmount])
VAR CumBeforeB = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryB), [@CumShare])
VAR BoundaryChunkB =
FILTER(RankedProducts, [@Rank] > RankBeforeB && [@Amount] >= MinAmountAfterB)
VAR BoundaryChunkB_WithCum =
ADDCOLUMNS(
BoundaryChunkB,
"@CumShareExact",
VAR CurrentRank = [@Rank]
RETURN
SUMX(FILTER(BoundaryChunkB, [@Rank] <= CurrentRank), [@Share])
+ IF(ISBLANK(CumBeforeB), 0, CumBeforeB)
)
VAR ThresholdAmountB =
MINX(FILTER(BoundaryChunkB_WithCum, [@CumShareExact] <= BoundaryB), [@Amount])
// ─── ШАГ 7. Классификация и финальный фильтр ───
VAR ProductsInA =
UNION(
SELECTCOLUMNS(FILTER(BoundaryChunkA_WithCum, [@Amount] >= ThresholdAmountA), "ID", [ID]),
SELECTCOLUMNS(FILTER(RankedProducts, [@Rank] <= RankBeforeA), "ID", [ID])
)
VAR ProductsInAB =
UNION(
SELECTCOLUMNS(FILTER(BoundaryChunkB_WithCum, [@Amount] >= ThresholdAmountB), "ID", [ID]),
SELECTCOLUMNS(FILTER(RankedProducts, [@Rank] <= RankBeforeB), "ID", [ID])
)
VAR ProductsInB = EXCEPT(ProductsInAB, ProductsInA)
VAR ProductsInC = EXCEPT(SELECTCOLUMNS(RankedProducts, "ID", [ID]), ProductsInAB)
VAR ProductsInCurrentClass =
FILTER(
UNION(
ADDCOLUMNS(ProductsInA, "@Class", "A", "@Value", BoundaryA),
ADDCOLUMNS(ProductsInB, "@Class", "B", "@Value", BoundaryB),
ADDCOLUMNS(ProductsInC, "@Class", "C", "@Value", BoundaryC)
),
[@Value] > [MinBoundary] && [@Value] <= [MaxBoundary]
)
RETURN
CALCULATE([Продажи руб], KEEPFILTERS(ProductsInCurrentClass))
Вспомогательные меры — нужны обеим
MaxBoundary = MAX('Сегментация ABC'[Верхняя граница])
MinBoundary = MIN('Сегментация ABC'[Нижняя граница])
Продажи руб = SUM('Продажи'[Сумма])
# SKU = DISTINCTCOUNT('Продажи'[Номенклатура ID])
Таблица-справочник «Сегментация ABC» (создать через Modeling → New table)
Сегментация ABC =
DATATABLE(
"Классификация", STRING,
"Сегмент", STRING,
"Нижняя граница", DOUBLE,
"Верхняя граница", DOUBLE,
{
{ "ABC", "A", 0.00, 0.80 },
{ "ABC", "B", 0.80, 0.95 },
{ "ABC", "C", 0.95, 1.00 }
}
)
Порядок действий для внедрения:
- Создайте таблицу
Сегментация ABCчерез Modeling → New table (вставьте DAX из четвёртого блока выше). - Добавьте вспомогательные меры
MaxBoundary,MinBoundary,Продажи руб,# SKU— если их ещё нет. - Скопируйте chunking-меру в свою модель, замените имена таблиц (
Продажи,Номенклатура,000 Календарь) на свои. - В DAX Studio (File → Connect → Power BI Desktop) включите Server Timings (Ctrl+F4), Clear Cache Then Run — получите свои замеры.
- Прогоните grid-search по ChunkSize (500 / 1000 / 1500 / 2000 / 3000), найдите оптимум под ваше распределение выручки.
Что дальше
На типовом каталоге chunking даёт 14-17× ускорения. Цифра выглядит скромно на фоне новостей «ускорили в 1000 раз», но на 100 тыс. SKU это разница между «открывается за секунду» и «отчёт не открывается вообще». А это уже принципиально.
И это не предел. У меня есть ещё один подход к той же задаче — на моих замерах он обгоняет chunking. Расскажу в следующей статье серии. Без спойлеров, просто держите в голове: 14× — ещё не финал.
А пока:
- Если ABC-отчёт в вашей модели тормозит — перенесите refactored-версию к себе, подгоните имена таблиц/мер и прогоните 5-6 значений ChunkSize в DAX Studio с Server Timings. Почти гарантированный 5-20× speedup.
- Если нужна помощь с переносом — 30-минутный звонок. Принесите свою модель и текущую формулу — за встречу соберём работающий рефакторинг.
- Более тяжёлые DAX-кейсы — BI-аудит за 5 дней включает оптимизацию самых долгих мер в отчёте, с замерами до/после и планом переработки.
Связанные материалы:
- Рефакторинг Power BI в 10 раз — общая методика DAX-оптимизации, 10 паттернов
- TOP N и TOP N% с «Прочими» — соседний DAX-кейс с RANKX-паттернами
- RFM-сегментация на DAX — ещё одна классификация через квинтили
- DAX для управленцев: 15 формул — базовые патттерны time intelligence, rank, % of total