Если вы не читали P20 про chunking — читать эту статью необязательно, но рекомендую: контекст кратно полезнее. Там полная постановка задачи, обзор классической DaxPatterns-версии и разбор chunking-алгоритма. Здесь — только концептуально другой путь к той же задаче.
Chunking решает исходную O(N²)-проблему в лоб: бьёт каталог на части, точный cum-sum делает только в «граничных» чанках. Хорошо, но внутри чанков мы всё равно делаем O(N²) — просто N стало меньше. Это улучшение коэффициента, не сложности.
Binary search делает другое: мы вообще не решаем задачу точно. Мы приближаемся к ответу логарифмически (10 итераций = точность 2⁻¹⁰ ≈ 0.1%), и точный расчёт делаем только на финальном узком срезе. Теоретическая сложность снижается с O(N²) до O(log K × N) + O(M²), где M — размер этого узкого среза, обычно ~0.1% от N.
Постановка задачи (коротко)
Есть таблица продаж, есть справочник товаров, надо разбить ассортимент на три группы по накопительной доле выручки: класс A — товары дающие первые 80%, класс B — следующие 15%, класс C — оставшиеся 5%. Классические границы в отдельной таблице Сегментация ABC, пользователь в отчёте двигает слайсер — мера возвращает выручку выбранного класса.
Стандартный DAX-паттерн от Russo & Ferrari делает cumulative sum через FILTER + SUMX для каждого товара. Асимптотически O(N²). На 10 тыс. товаров — 18 секунд. На 100 тыс. — timeout.
Chunking в P20 улучшил это до 913 мс (20× speedup). Binary search идёт дальше.
Если совсем на пальцах
Пересказ идеи без единой формулы — если за мат-формулами в следующем разделе теряется главное.
Представьте: вам нужно угадать число от 1 до 1000. После каждой попытки вам говорят «больше» или «меньше». Самый глупый способ — перебирать по одному: 1, 2, 3... До 1000 попыток.
Самый умный — начать с середины (500), получить «меньше», взять середину нижней половины (250), получить «больше», взять середину верхней четверти (375), потом (437), (468), (484)... На 10-й попытке вы гарантированно угадаете любое число из 1000.
Почему 10, а не 20 или 50? Потому что каждая попытка делит интервал неопределённости пополам. Две попытки — интервал сужен в 4 раза. Три — в 8. Десять — в 1024 раза.
У этой математики есть «обратная сторона» — легенда про изобретателя шахмат. Он попросил у шаха одно зерно риса на первую клетку доски, два — на вторую, четыре — на третью, восемь — на четвёртую... до 64-й. Шах посмеялся над скромностью просьбы. К 20-й клетке потребовалось уже больше полумиллиона зёрен. К 40-й — триллион. К 64-й — больше, чем всего риса на всей планете за тысячу лет. Шаху пришлось признать, что наградить изобретателя он не сможет. Такая сила у двойки в степени.
Бинарный поиск использует ту же математику, но в обратную сторону. Вместо удвоения — деление пополам. Десять итераций — и точность 1/1024 ≈ 0.1%. Двадцать — 0.0001%. На практике первых 10 с запасом хватает для любой ABC-задачи.
Как это применяется к нашей ABC-задаче. Мы не знаем, какое значение выручки отделяет «топ 80%» от остальных — это и есть «загаданное число». Мы делаем первую пробу (K = 0.5, медиана): видим «попали на 93% вместо 80%» — значит нужен более узкий топ, идём вправо. Вторая проба — «73%» — нужно расширить, идём влево, но на половину предыдущего шага. Третья — уже ближе. На десятой промахиваемся на 0.1%. Последняя доводка на 20-30 товарах вокруг цели — и получаем точный ответ.
И всё. Дальше — как это собрать в DAX, где есть готовая функция PERCENTILEX.INC как раз для таких проб.
Идея на техническом уровне
Нам нужно найти минимальную выручку товара, при которой сумма продаж всех товаров с выручкой больше или равной этому threshold даёт ровно 80% (или 95%, или любой другой Target) от общей. Чтобы отсечь топ-продукты и получить класс A.
В лоб это делается через сортировку и накопительную сумму — O(N²). Но есть нативная функция DAX PERCENTILEX.INC: она за один проход возвращает значение K-го процентиля. Vertipaq делает это быстро.
Дальше идея простая. Мы не знаем, какой именно процентиль даст ровно 80% кумулятивной выручки, поэтому ищем его бинарным поиском:
- Берём K = 0.5 (медиана по выручке). PERCENTILEX возвращает значение, выше которого 50% товаров.
- Считаем долю выручки этих топ-50% товаров. Если > 80% — значит порог слишком «мягкий», надо повысить K (взять более узкий топ). Если < 80% — снизить K (взять более широкий топ).
- Сдвигаемся на ±0.25, считаем снова.
- Сдвигаемся на ±0.125, ещё раз.
- ...
- После 10 итераций K уточнён с точностью 2⁻¹⁰ ≈ 0.001. Соответствующий процентиль отклоняется от идеального threshold не больше чем на 1-2 позиции в списке товаров.
- Fine-tune: на этой узкой зоне (20-50 товаров) делаем традиционный точный cum-sum, находим ровный cutoff.
Пример пошаговой конвергенции на Парето 80/20 (распределение первого графика P20):
iter K Share decision ────────────────────────────────────────── 1 0.5000 93.1% Share > Target → K вверх (узкий топ) → K = 0.7500 2 0.7500 73.4% Share < Target → K вниз (шире топ) → K = 0.6250 3 0.6250 84.2% Share > Target → K вверх → K = 0.6875 4 0.6875 78.9% Share < Target → K вниз → K = 0.6563 5 0.6563 81.6% Share > Target → K вверх → K = 0.6719 6 0.6719 80.2% Share > Target → K вверх → K = 0.6797 7 0.6797 79.6% Share < Target → K вниз → K = 0.6758 8 0.6758 79.9% Share < Target → K вниз → K = 0.6738 9 0.6738 80.0% ~hit → K = 0.6729 10 0.6729 80.0% ~hit Fine-tune в окне [_MinMA1, _MaxMA1]: ≈ 30 товаров → точный cutoff_A = X₀ (выручка граничного товара)
На каждой итерации выполняется: один PERCENTILEX.INC + один SUMX по FILTER. Сложность каждой — линейная по N. 10 итераций = 10×N. Плюс fine-tune на ~30 строках = 30². Итого O(10×N + 30²) — на 10 тыс. товаров это ~100 тыс. операций. Для сравнения naive O(N²) = 100 млн операций.
Бенчмарк: 3 подхода на одной модели
Тот же PBIX, что в P20: 9999 товаров, 1 млн транзакций, 31.05 млн ₽ общая выручка, распределение ~Парето 80/15/5. Замеры через MCP + Power BI Analysis Services trace. Каждая мера — 3 прогона с ClearCache между ними, берём минимум.
| Мера | Run 1 | Run 2 | Run 3 | Min | FE % | vs DaxPatterns |
|---|---|---|---|---|---|---|
| ABC (by DaxPatterns.com) | 18 020 | — | — | 18 020 | 99% | 1× |
| Chunking (P20, refactored) | 913 | 995 | 1 540 | 913 | 96% | 20× |
| Binary Classic (P23) | 342 | 347 | 377 | 342 | 84% | 53× |
| Binary Top 80 (один порог) | 306 | 459 | 441 | 306 | 74% | — |
Все три подхода возвращают идентичные значения для классов A/B/C (21.7 млн / 6.2 млн / 3.1 млн ₽). Сошлось до копейки. Разница только в скорости.
Ещё любопытное: FE-доля у binary всего 84%, у chunking — 96%, у DaxPatterns — 99%. Binary жмёт из Storage движок заметно больше — Vertipaq отрабатывает PERCENTILEX.INC быстро, и значительная часть работы уходит из тяжёлого Formula движок в лёгкий SE. Алгоритм идёт путями, для которых движок изначально заточен.
Алгоритм шаг за шагом
Полный DAX полностью — под шевронами ниже, здесь — ключевые блоки с объяснением.
Шаг 1. Подготовка таблицы товаров
VAR _Table = FILTER(
SUMMARIZE(
ALL('Номенклатура'), 'Номенклатура'[ID],
"@Measure", IF([Продажи руб] > 0, [Продажи руб], BLANK())
),
NOT ISBLANK([@Measure])
)
VAR _TotalSum = SUMX(_Table, [@Measure])
Стандарт — строим виртуальную таблицу товар → выручка, отбрасываем нули. ALL('Номенклатура') снимает фильтры от текущего сегмента ABC в отчёте, иначе мера будет зацикливаться.
Шаг 2. 10 итераций бинарного поиска
Каждая итерация — 3 строчки:
VAR _M1 = PERCENTILEX.INC(_Table, [@Measure], _K0) // процентиль → threshold
VAR _S1 = DIVIDE(SUMX(FILTER(_Table, [@Measure] >= _M1), [@Measure]), _TotalSum) // доля выше threshold
VAR _K1 = IF(_S1 < _Target, -1, 1) * _K0 / 2 ^ 1 + _K0 // следующий K
Умножение IF(...) * _K0 / 2^N — компактное выражение для «±шаг, который делится пополам на каждой итерации». Знак зависит от того, промахнулись мы над Target или под ним.
Повторяется 10 раз, с последовательными K₁, K₂, ... K₁₀. Каждый новый K сужает коридор неопределённости вдвое.
Шаг 3. Таблица оценок + обрамление Target
VAR _Est = {
(_K1, _S1, _M1), (_K2, _S2, _M2), (_K3, _S3, _M3), ...
}
VAR _MinMA = MAXX(FILTER(_Est, [Value2] >= _Target), [Value3])
VAR _MaxMA = MINX(FILTER(_Est, [Value2] <= _Target), [Value3])
После 10 итераций у нас 10 пар (K, Share, Median). Находим «обрамление» Target: максимальный Median из тех итераций, где Share ≥ Target (это верхняя граница узкой зоны), и минимальный Median из тех где Share ≤ Target (нижняя). Между ними — финальное окно точного расчёта.
Шаг 4. Fine-tune на узком срезе
VAR _ShareBeforeA = DIVIDE(SUMX(FILTER(_Table, [@Measure] > _MaxMA1), [@Measure]), _TotalSum)
VAR _PartTableA = FILTER(_Table, [@Measure] >= _MinMA1 && [@Measure] <= _MaxMA1)
VAR _BoundaryA =
MINX(
FILTER(_PartTableA,
VAR _This = [@Measure]
RETURN _ShareBeforeA + DIVIDE(SUMX(FILTER(_PartTableA, [@Measure] >= _This), [@Measure]), _TotalSum) <= _Target
),
[@Measure]
)
На узком окне (~20-50 строк) делаем классический точный cum-sum. _ShareBeforeA — доля уже «закрытая» товарами выше окна. К ней прибавляем доли внутри окна, ищем минимальный amount, при котором сумма все ещё ≤ Target. Это точный cutoff класса A.
Шаг 5. Классификация товаров
Для полной ABC A/B/C повторяем шаги 2-4 для Target B = 95%. Получаем второй cutoff_B. Классифицируем:
VAR _ProductsInA = SELECTCOLUMNS(FILTER(_Table, [@Measure] >= _CutoffA), "ID", [ID])
VAR _ProductsInB = SELECTCOLUMNS(FILTER(_Table, [@Measure] >= _CutoffB && [@Measure] < _CutoffA), "ID", [ID])
VAR _ProductsInC = SELECTCOLUMNS(FILTER(_Table, [@Measure] < _CutoffB), "ID", [ID])
И финальный фильтр по текущему сегменту в отчёте — тот же паттерн, что в P20.
Готовый DAX — две версии
ABC Binary Classic — полная классификация A/B/C (рекомендуется)
ABC Binary Classic =
// ══════════════════════════════════════════════════════════════════
// ABC-классификация A/B/C через binary search (2 порога: 80% и 95%).
// Сложность: 20 итераций PERCENTILEX + 2 fine-tune, вместо O(N²).
// ══════════════════════════════════════════════════════════════════
VAR _TargetA = CALCULATE([MaxBoundary], 'Сегментация ABC'[Сегмент] = "A")
VAR _TargetB = CALCULATE([MaxBoundary], 'Сегментация ABC'[Сегмент] = "B")
VAR _TargetC = CALCULATE([MaxBoundary], 'Сегментация ABC'[Сегмент] = "C")
VAR _K0 = 0.5
VAR _Table = FILTER(
SUMMARIZE(
ALL('Номенклатура'), 'Номенклатура'[ID],
"@Measure", IF([Продажи руб] > 0, [Продажи руб], BLANK())
),
NOT ISBLANK([@Measure])
)
VAR _TotalSum = SUMX(_Table, [@Measure])
// ─── Binary search для TargetA = 80% ───
VAR _MA1 = PERCENTILEX.INC(_Table, [@Measure], _K0)
VAR _SA1 = DIVIDE(SUMX(FILTER(_Table, [@Measure] >= _MA1), [@Measure]), _TotalSum)
VAR _KA1 = IF(_SA1 < _TargetA, -1, 1) * _K0 / 2 ^ 1 + _K0
VAR _MA2 = PERCENTILEX.INC(_Table, [@Measure], _KA1)
VAR _SA2 = DIVIDE(SUMX(FILTER(_Table, [@Measure] >= _MA2), [@Measure]), _TotalSum)
VAR _KA2 = IF(_SA2 < _TargetA, -1, 1) * _K0 / 2 ^ 2 + _KA1
VAR _MA3 = PERCENTILEX.INC(_Table, [@Measure], _KA2)
VAR _SA3 = DIVIDE(SUMX(FILTER(_Table, [@Measure] >= _MA3), [@Measure]), _TotalSum)
VAR _KA3 = IF(_SA3 < _TargetA, -1, 1) * _K0 / 2 ^ 3 + _KA2
VAR _MA4 = PERCENTILEX.INC(_Table, [@Measure], _KA3)
VAR _SA4 = DIVIDE(SUMX(FILTER(_Table, [@Measure] >= _MA4), [@Measure]), _TotalSum)
VAR _KA4 = IF(_SA4 < _TargetA, -1, 1) * _K0 / 2 ^ 4 + _KA3
VAR _MA5 = PERCENTILEX.INC(_Table, [@Measure], _KA4)
VAR _SA5 = DIVIDE(SUMX(FILTER(_Table, [@Measure] >= _MA5), [@Measure]), _TotalSum)
VAR _KA5 = IF(_SA5 < _TargetA, -1, 1) * _K0 / 2 ^ 5 + _KA4
VAR _MA6 = PERCENTILEX.INC(_Table, [@Measure], _KA5)
VAR _SA6 = DIVIDE(SUMX(FILTER(_Table, [@Measure] >= _MA6), [@Measure]), _TotalSum)
VAR _KA6 = IF(_SA6 < _TargetA, -1, 1) * _K0 / 2 ^ 6 + _KA5
VAR _MA7 = PERCENTILEX.INC(_Table, [@Measure], _KA6)
VAR _SA7 = DIVIDE(SUMX(FILTER(_Table, [@Measure] >= _MA7), [@Measure]), _TotalSum)
VAR _KA7 = IF(_SA7 < _TargetA, -1, 1) * _K0 / 2 ^ 7 + _KA6
VAR _MA8 = PERCENTILEX.INC(_Table, [@Measure], _KA7)
VAR _SA8 = DIVIDE(SUMX(FILTER(_Table, [@Measure] >= _MA8), [@Measure]), _TotalSum)
VAR _KA8 = IF(_SA8 < _TargetA, -1, 1) * _K0 / 2 ^ 8 + _KA7
VAR _MA9 = PERCENTILEX.INC(_Table, [@Measure], _KA8)
VAR _SA9 = DIVIDE(SUMX(FILTER(_Table, [@Measure] >= _MA9), [@Measure]), _TotalSum)
VAR _KA9 = IF(_SA9 < _TargetA, -1, 1) * _K0 / 2 ^ 9 + _KA8
VAR _MA10 = PERCENTILEX.INC(_Table, [@Measure], _KA9)
VAR _SA10 = DIVIDE(SUMX(FILTER(_Table, [@Measure] >= _MA10), [@Measure]), _TotalSum)
VAR _EstA = {
(_KA1, _SA1, _MA1), (_KA2, _SA2, _MA2), (_KA3, _SA3, _MA3), (_KA4, _SA4, _MA4), (_KA5, _SA5, _MA5),
(_KA6, _SA6, _MA6), (_KA7, _SA7, _MA7), (_KA8, _SA8, _MA8), (_KA9, _SA9, _MA9), (_K0, _SA10, _MA10)
}
VAR _MinMA = MAXX(FILTER(_EstA, [Value2] >= _TargetA), [Value3])
VAR _MaxMA = MINX(FILTER(_EstA, [Value2] <= _TargetA), [Value3])
VAR _MinMA1 = IF(ISBLANK(_MinMA), 0, IF(ISBLANK(_MaxMA), 0, _MinMA))
VAR _MaxMA1 = IF(ISBLANK(_MaxMA), _MinMA, _MaxMA)
VAR _ShareBeforeA = DIVIDE(SUMX(FILTER(_Table, [@Measure] > _MaxMA1), [@Measure]), _TotalSum)
VAR _BoundaryBeforeA = MINX(FILTER(_Table, [@Measure] > _MaxMA1), [@Measure])
VAR _PartTableA = FILTER(_Table, [@Measure] >= _MinMA1 && [@Measure] <= _MaxMA1)
VAR _BoundaryA =
MINX(
FILTER(_PartTableA,
VAR _This = [@Measure]
RETURN _ShareBeforeA + DIVIDE(SUMX(FILTER(_PartTableA, [@Measure] >= _This), [@Measure]), _TotalSum) <= _TargetA
),
[@Measure]
)
VAR _CutoffA = IF(NOT ISBLANK(_BoundaryA), _BoundaryA, _BoundaryBeforeA)
// ─── Binary search для TargetB = 95% (симметрично) ───
VAR _MB1 = PERCENTILEX.INC(_Table, [@Measure], _K0)
VAR _SB1 = DIVIDE(SUMX(FILTER(_Table, [@Measure] >= _MB1), [@Measure]), _TotalSum)
VAR _KB1 = IF(_SB1 < _TargetB, -1, 1) * _K0 / 2 ^ 1 + _K0
// ... 9 таких же итераций с _KB2, _KB3, ... _KB9
VAR _MB10 = PERCENTILEX.INC(_Table, [@Measure], _KB9)
VAR _SB10 = DIVIDE(SUMX(FILTER(_Table, [@Measure] >= _MB10), [@Measure]), _TotalSum)
VAR _EstB = { (_KB1, _SB1, _MB1), ..., (_K0, _SB10, _MB10) }
VAR _MinMB = MAXX(FILTER(_EstB, [Value2] >= _TargetB), [Value3])
VAR _MaxMB = MINX(FILTER(_EstB, [Value2] <= _TargetB), [Value3])
VAR _MinMB1 = IF(ISBLANK(_MinMB), 0, IF(ISBLANK(_MaxMB), 0, _MinMB))
VAR _MaxMB1 = IF(ISBLANK(_MaxMB), _MinMB, _MaxMB)
VAR _ShareBeforeB = DIVIDE(SUMX(FILTER(_Table, [@Measure] > _MaxMB1), [@Measure]), _TotalSum)
VAR _BoundaryBeforeB = MINX(FILTER(_Table, [@Measure] > _MaxMB1), [@Measure])
VAR _PartTableB = FILTER(_Table, [@Measure] >= _MinMB1 && [@Measure] <= _MaxMB1)
VAR _BoundaryB =
MINX(
FILTER(_PartTableB,
VAR _This = [@Measure]
RETURN _ShareBeforeB + DIVIDE(SUMX(FILTER(_PartTableB, [@Measure] >= _This), [@Measure]), _TotalSum) <= _TargetB
),
[@Measure]
)
VAR _CutoffB = IF(NOT ISBLANK(_BoundaryB), _BoundaryB, _BoundaryBeforeB)
// ─── Классификация товаров ───
VAR _ProductsInA = SELECTCOLUMNS(FILTER(_Table, [@Measure] >= _CutoffA), "ID", [ID])
VAR _ProductsInB = SELECTCOLUMNS(FILTER(_Table, [@Measure] >= _CutoffB && [@Measure] < _CutoffA), "ID", [ID])
VAR _ProductsInC = SELECTCOLUMNS(FILTER(_Table, [@Measure] < _CutoffB), "ID", [ID])
// ─── Фильтр по текущему сегменту ───
VAR _ProductsInCurrentClass =
FILTER(
UNION(
ADDCOLUMNS(_ProductsInA, "@Class", "A", "@Value", _TargetA),
ADDCOLUMNS(_ProductsInB, "@Class", "B", "@Value", _TargetB),
ADDCOLUMNS(_ProductsInC, "@Class", "C", "@Value", _TargetC)
),
[@Value] > [MinBoundary] && [@Value] <= [MaxBoundary]
)
RETURN
CALCULATE([Продажи руб], KEEPFILTERS(_ProductsInCurrentClass))
ABC Binary Top X% — упрощённая версия для одного порога
ABC Binary Top 80 =
// Топ товаров, дающих ~80% выручки. Один binary search, без сегментации.
VAR _Target = 0.80
VAR _K0 = 0.5
VAR _Table = FILTER(
SUMMARIZE(
ALL('Номенклатура'), 'Номенклатура'[ID],
"@Measure", IF([Продажи руб] > 0, [Продажи руб], BLANK())
),
NOT ISBLANK([@Measure])
)
VAR _TotalSum = SUMX(_Table, [@Measure])
// 10 итераций бинарного поиска (см. Binary Classic для полного кода)
VAR _M1 = PERCENTILEX.INC(_Table, [@Measure], _K0)
VAR _S1 = DIVIDE(SUMX(FILTER(_Table, [@Measure] >= _M1), [@Measure]), _TotalSum)
VAR _K1 = IF(_S1 < _Target, -1, 1) * _K0 / 2 ^ 1 + _K0
// ... K2, K3, ..., K10
VAR _Est = { (_K1, _S1, _M1), ..., (_K0, _S10, _M10) }
VAR _MinM = MAXX(FILTER(_Est, [Value2] >= _Target), [Value3])
VAR _MaxM = MINX(FILTER(_Est, [Value2] <= _Target), [Value3])
VAR _MinM1 = IF(ISBLANK(_MinM), 0, IF(ISBLANK(_MaxM), 0, _MinM))
VAR _MaxM1 = IF(ISBLANK(_MaxM), _MinM, _MaxM)
VAR _ShareBefore = DIVIDE(SUMX(FILTER(_Table, [@Measure] > _MaxM1), [@Measure]), _TotalSum)
VAR _BoundaryValueBefore = MINX(FILTER(_Table, [@Measure] > _MaxM1), [@Measure])
VAR _PartTable = FILTER(_Table, [@Measure] >= _MinM1 && [@Measure] <= _MaxM1)
VAR _BoundaryValue =
MINX(
FILTER(_PartTable,
VAR _This = [@Measure]
RETURN _ShareBefore + DIVIDE(SUMX(FILTER(_PartTable, [@Measure] >= _This), [@Measure]), _TotalSum) <= _Target
),
[@Measure]
)
VAR _ResultTable = FILTER(_Table, [@Measure] >= IF(NOT ISBLANK(_BoundaryValue), _BoundaryValue, _BoundaryValueBefore))
RETURN
CALCULATE([Продажи руб], KEEPFILTERS(_ResultTable))
Вспомогательные меры + справочник «Сегментация ABC»
MaxBoundary = MAX('Сегментация ABC'[Верхняя граница])
MinBoundary = MIN('Сегментация ABC'[Нижняя граница])
Продажи руб = SUM('Продажи'[Сумма])
-- Справочник (создать через 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 }
}
)
Когда какой подход использовать
| Размер каталога | DaxPatterns | Chunking (P20) | Binary (P23) | Рекомендация |
|---|---|---|---|---|
| < 1 000 SKU | < 100 мс | < 150 мс | < 150 мс | Любой, DaxPatterns читаемее |
| 1 000 — 5 000 | 0.5-3 с | 150-400 мс | 100-300 мс | Chunking или Binary, не существенно |
| 5 000 — 50 000 | 5-60 с | 0.5-3 с | 0.2-1 с | Binary выигрывает в 2-3× |
| > 50 000 | timeout | 3-15 с | 1-5 с | Binary обязателен |
| Несколько ABC на странице | мульти-секундный отчёт | сложение времён | все через один PERCENTILEX-проход | Binary |
Chunking может быть равно хорош в одном случае — когда дистрибутив НЕ Парето-образный (например, равномерный): тогда граничный чанк маленький, и его O(K²) копеечный. Но в реальных бизнес-задачах Парето — норма, и binary выигрывает почти всегда.
Типичные грабли
- PERCENTILEX.INC считает процентиль по значению amount, а не по cum_share. Это не баг, это feature: мы специально используем процентили выручки для бинарного поиска. Но если копировать формулу в модель и удивляться «почему K = 0.5 не даёт мне 50% выручки» — вы не учли, что в Парето 50% товаров по amount дают 93%, а не 50% по выручке.
- Хардкод числа итераций = 10. При точности 0.1% этого достаточно на практике. Если нужно больше — добавьте ещё итераций (каждая прибавит ~15-30 мс). Но реально fine-tune в конце уже даёт точный результат, 10 итераций — золотая середина.
- ALL('Номенклатура') в SUMMARIZE — обязательно. Иначе фильтр по сегменту ABC из отчёта попадёт в _Table и алгоритм зациклится.
- ISBLANK обрамления (_MinM1, _MaxM1) — защита от крайних случаев, когда binary search не нашёл полного обрамления Target. Обычно происходит на очень маленьких таблицах. Не убирайте этот код.
- Для класса C не нужен binary search. Класс C = всё, что не A и не B. 20 итераций (10 для A, 10 для B) достаточно, класс C вычисляется через EXCEPT или прямой фильтр по cutoff_B.
- _Target должен быть > 0 и < 1. Если в таблице Сегментация границы 0-100 (целые), делите на 100.
Ещё один сюрприз: PERCENTILEX.INC с K = 0.5 ≠ медиана выручки
Когда я впервые писал эту меру, интуитивно ожидал что при K = 0.5 мы получим товар с медианной выручкой, а товаров выше его будет 50% от количества. Но их выручка на Парето-каталоге НЕ 50% от общей — а ~93%.
Это потому что PERCENTILEX работает по количеству, а не по весу. Хорошо, что бинарный поиск сам по этому факту нечувствителен: мы не предполагаем никакой связи между K и долей выручки — мы просто ищем такой K, для которого доля сходится к Target. Первая итерация с K = 0.5 часто даёт Share 90-95% на Парето, что сразу подталкивает K вверх. Алгоритм выбирается из любой стартовой точки в диапазоне [0, 1] за те же 10 итераций.
Что дальше
Пока что binary search — мой лучший результат для ABC на DAX. 53× против naive, 2.7× против chunking, тянет сотни тысяч SKU без деградации, результаты идентичны до копейки.
Если у кого-то есть идея алгоритма ещё быстрее — пишите, сравнимся. Chunking и binary уже выиграли у DaxPatterns в 20-50 раз. Не знаю, остался ли там запас ещё в 10×. Но и обратного не утверждаю — оптимизации в DAX работают не линейно. Иногда одно изменение формулы уносит время в разы.
Возможные направления:
- Материализация в ETL. Посчитать ABC-класс каждого товара в SQL или Power Query раз в сутки, положить в
dim_product[abc_class]. DAX-мера становится тривиальной CALCULATE. Потеря — динамичность (нельзя двигать границы в отчёте). - Calculated column на уровне модели. То же что ETL, но через DAX. Считает один раз при загрузке, потом используется как обычная колонка. Динамика только если добавить пару calculated columns на разные Target.
- Aggregation tables для часто используемых срезов ABC × период × регион. Не помогает на живом срезе, но отлично работает для стандартных отчётов.
Но все эти подходы — про компромисс (теряем динамичность). На чистом live-DAX binary search выглядит практическим пределом.
А пока
- Если ABC-отчёт у вас тормозит 5+ секунд — просто перенесите Binary Classic, замените имена таблиц/мер. Почти гарантированный 10-50× speedup.
- Если вы используете chunking (P20) и он в принципе устраивает — можно не трогать. Binary выиграет 2-3×, это важно на большой матрице с 10-20 визуалами, но на одной карточке KPI почти неотличимо.
- Если не уверены в переносе — 30-минутный звонок. Принесите модель, разберём вместе, соберём работающую версию за встречу.
- Более тяжёлые DAX-кейсы — BI-аудит за 5 дней даёт полный план оптимизации самых долгих мер в отчёте, с замерами до/после.
Связанные материалы:
- P20 — Chunking ABC: 14-17× ускорение — первая статья серии с полным контекстом задачи и разбором chunking-алгоритма
- Рефакторинг Power BI в 10 раз — общая методика DAX-оптимизации
- TOP N и TOP N% с «Прочими» — близкая задача с RANKX-паттернами
- RFM-сегментация на DAX — ещё одна классификация