Математика для аналитика
Модуль 9 · Энтропия, KL, числа с плавающей точкой

Энтропия, информация и численная точность

Меры информации, лежащие под деревьями решений и мониторингом дрейфа данных, и та арифметика больших таблиц, о которую спотыкаются на проде.

Прочитано
— из 6
9.1

Энтропия как мера неопределённости

Энтропия превращает расплывчатое «это распределение непредсказуемое» в конкретное число в битах, которое затем используют деревья решений, метрики дрейфа и оценки качества классификаторов.

Суть

Начнём не с формулы, а с вопроса: сколько информации несёт сообщение о том, что произошло событие с вероятностью p? Интуиция подсказывает три требования. Первое: если событие достоверно (p = 1), сообщение не несёт ничего нового, значит информация должна равняться нулю. Второе: чем реже событие, тем больше информации в сообщении о нём. Третье, самое важное: если два независимых события произошли вместе, информация должна складываться, а вероятности при этом перемножаются.

Функция, которая переводит умножение в сложение, ровно одна — логарифм. Отсюда мера информации отдельного исхода: I(p) = −log₂ p. Знак минус нужен потому, что логарифм числа меньше единицы отрицателен, а информацию удобно считать неотрицательной величиной. Основание 2 выбрано так, чтобы единицей измерения был бит: исход с вероятностью 1/2 несёт ровно 1 бит.

Энтропия — это средняя информация по всем исходам, то есть математическое ожидание величины I(p). Мы уже умеем считать ожидание: сумма значений, взвешенных вероятностями.

H(X) = − Σ pᵢ × log₂ pᵢ, где Σ pᵢ = 1

Содержательно H — это среднее число двоичных вопросов «да/нет», которое нужно задать, чтобы узнать исход, если задавать их оптимально. Отсюда практическая интерпретация: энтропия равна нижней границе средней длины кода в битах на одно наблюдение.

Как это считается

Допустим, в нашем условном примере заказы маркетплейса распределены по пяти укрупнённым категориям с долями 0.40, 0.25, 0.20, 0.10 и 0.05. Считаем по шагам.

Логарифмы: log₂ 0.40 = −1.3219, log₂ 0.25 = −2.0000, log₂ 0.20 = −2.3219, log₂ 0.10 = −3.3219, log₂ 0.05 = −4.3219. Дальше каждое слагаемое — доля, умноженная на модуль логарифма: 0.40 × 1.3219 = 0.5288; 0.25 × 2.0000 = 0.5000; 0.20 × 2.3219 = 0.4644; 0.10 × 3.3219 = 0.3322; 0.05 × 4.3219 = 0.2161. Сумма: 0.5288 + 0.5000 + 0.4644 + 0.3322 + 0.2161 = 2.0414 бита.

H = 0.5288 + 0.5000 + 0.4644 + 0.3322 + 0.2161 = 2.0414 бита

Проверка разумности результата: максимум энтропии для пяти исходов достигается при равных долях по 0.2 и равен log₂ 5 = 2.3219 бита. Наше значение 2.0414 меньше, и это правильно: распределение перекошено в сторону первой категории. Нормированная энтропия H / log₂ 5 = 2.0414 / 2.3219 = 0.879 — распределение использует около 88 % теоретически возможной неопределённости.

Границы, перплексия и что энтропия не видит

Энтропия всегда лежит в интервале от 0 до log₂ k, где k — число исходов. Ноль достигается тогда и только тогда, когда одна из вероятностей равна единице. Слагаемое с p = 0 по соглашению считают нулевым: предел p × log₂ p при p → 0 равен нулю, поэтому пустые категории не ломают формулу и не увеличивают энтропию.

Удобная производная величина — перплексия 2ᴴ. Для нашего примера 2^2.0414 ≈ 4.12. Читается так: распределение по пяти категориям ведёт себя как равномерное распределение примерно по четырём. Перплексия измеряется в «эффективном числе вариантов» и понятнее заказчику, чем биты.

Теперь про ограничения. Энтропия зависит только от набора вероятностей, но не от того, что это за исходы. Она не знает про порядок и расстояние: распределение по возрастным группам с долями 0.9 и 0.1 и распределение по двум городам с теми же долями дают одинаковые 0.469 бита, хотя во втором случае понятия «близко» вообще нет. Для непрерывных величин энтропия зависит от того, как вы разбили ось на интервалы: удвоив число бинов, вы почти наверняка увеличите H, ничего не изменив в данных.

Второе ограничение — смещение оценки. Энтропия, посчитанная по выборочным частотам, систематически занижена по сравнению с истинной. На больших таблицах это несущественно, но если в категории попало 30 наблюдений на 40 возможных значений, оценка будет заметно ниже реальной.

Пример

Допустим, вы следите за разнообразием выдачи в поиске классифайда уровня Авито. В контрольной группе доли кликов по пяти типам объявлений составили 0.40 / 0.25 / 0.20 / 0.10 / 0.05, энтропия 2.0414 бита. В тестовой группе с новым ранжированием доли стали 0.62 / 0.18 / 0.10 / 0.06 / 0.04. Считаем: 0.62 × 0.6897 = 0.4276; 0.18 × 2.4739 = 0.4453; 0.10 × 3.3219 = 0.3322; 0.06 × 4.0589 = 0.2435; 0.04 × 4.6439 = 0.1858. Сумма 1.6344 бита, перплексия 2^1.6344 ≈ 3.10. Выдача стала концентрированнее: эффективное число типов упало с 4.1 до 3.1. Само по себе это ни хорошо ни плохо, но это измеримый факт, который стоит положить рядом с метрикой конверсии.

Частая ошибка

Сравнивают энтропию за два периода, когда справочник категорий между этими периодами менялся. В апреле было 24 категории, в мае маркетинг разбил одну крупную на четыре подкатегории — и энтропия выросла на 0.3 бита без единого изменения в поведении пользователей. Заметить подделку простым способом: рядом с H всегда выводите число ненулевых категорий и log₂ k. Если верхняя граница поехала, сравнение недействительно. Тот же эффект даёт категория «Прочее»: схлопывание хвоста в один бакет всегда занижает энтропию, поэтому правило формирования хвоста должно быть зафиксировано в запросе, а не меняться от отчёта к отчёту.

Запомнить
  • Энтропия — среднее значение величины −log₂ p, то есть средняя «неожиданность» исхода, измеренная в битах.
  • H лежит между 0 и log₂ k; публиковать её осмысленно только вместе с k или в нормированном виде H / log₂ k.
  • Перплексия 2ᴴ переводит биты в эффективное число вариантов и лучше читается в отчёте.
  • Энтропия чувствительна к схеме категоризации и биннингу, поэтому сравнима только при неизменной схеме.
9.2

Прирост информации и критерий Джини: как дерево выбирает признак

Дерево решений не «понимает» смысл признаков — оно на каждом шаге перебирает разбиения и берёт то, которое сильнее всего снижает неопределённость целевой переменной, и полезно знать, как именно измеряется это снижение.

Суть

Пусть в узле лежит набор объектов с известным распределением классов. Его неопределённость мы умеем измерять энтропией. Если разбить узел признаком на две или больше ветвей, в каждой ветви получится своё распределение классов и своя энтропия. Взвесив энтропии ветвей долями попавших в них объектов, получим ожидаемую неопределённость после разбиения — условную энтропию. Разность и называется приростом информации.

IG = H(родитель) − Σ (nᵥ / n) × H(ветвь v)

Прирост информации никогда не отрицателен: усреднённая по ветвям энтропия не может превысить энтропию родителя. Ноль означает, что признак не связан с целевой переменной — распределение классов в ветвях такое же, как было.

Критерий Джини устроен по той же схеме, но вместо энтропии берёт другую меру нечистоты: вероятность того, что два случайно выбранных объекта узла окажутся из разных классов.

Gini = 1 − Σ pᵢ² ; ΔGini = Gini(родитель) − Σ (nᵥ / n) × Gini(ветвь v)

Обе меры равны нулю на чистом узле и максимальны на равномерном распределении: для двух классов энтропия достигает 1, Джини — 0.5. Кривые близки по форме, поэтому выбор между ними меняет структуру дерева редко, а скорость расчёта у Джини выше: там нет логарифмов.

Как это считается

Допустим, банк уровня Т-Банка строит скоринговую модель. В обучающем узле 1000 условных заявок: 700 закрытых без просрочки (класс «хороший») и 300 с просрочкой 90+ (класс «плохой»). Доли 0.7 и 0.3.

Родитель: H = 0.7 × 0.5146 + 0.3 × 1.7370 = 0.3602 + 0.5211 = 0.8813 бита. Gini = 1 − (0.49 + 0.09) = 0.42.

Разбиение A — по отношению платежа к доходу. Левая ветвь (600 заявок, платёж не выше 35 % дохода): 510 хороших и 90 плохих, доли 0.85 / 0.15. H = 0.85 × 0.2345 + 0.15 × 2.7370 = 0.1993 + 0.4106 = 0.6099. Gini = 1 − (0.7225 + 0.0225) = 0.2550. Правая ветвь (400 заявок): 190 и 210, доли 0.475 / 0.525. H = 0.475 × 1.0740 + 0.525 × 0.9296 = 0.5102 + 0.4880 = 0.9982. Gini = 1 − (0.2256 + 0.2756) = 0.4988.

Взвешиваем: H = 0.6 × 0.6099 + 0.4 × 0.9982 = 0.3659 + 0.3993 = 0.7652. IG = 0.8813 − 0.7652 = 0.1161 бита. Для Джини: 0.6 × 0.2550 + 0.4 × 0.4988 = 0.1530 + 0.1995 = 0.3525, значит ΔGini = 0.42 − 0.3525 = 0.0675.

Разбиение B — по наличию зарплатного проекта. Левая ветвь (400 заявок): 320 и 80, доли 0.8 / 0.2. H = 0.8 × 0.3219 + 0.2 × 2.3219 = 0.2575 + 0.4644 = 0.7219, Gini = 1 − (0.64 + 0.04) = 0.32. Правая ветвь (600 заявок): 380 и 220, доли 0.6333 / 0.3667. H = 0.6333 × 0.6590 + 0.3667 × 1.4475 = 0.4174 + 0.5308 = 0.9481, Gini = 1 − (0.4011 + 0.1344) = 0.4644.

Взвешиваем: H = 0.4 × 0.7219 + 0.6 × 0.9481 = 0.2888 + 0.5689 = 0.8577, IG = 0.8813 − 0.8577 = 0.0236 бита. ΔGini = 0.42 − (0.4 × 0.32 + 0.6 × 0.4644) = 0.42 − 0.4067 = 0.0133.

РазбиениеУсловная HIG, битВзвеш. GiniΔGini
Родитель0.88130.4200
A: платёж / доход0.76520.11610.35250.0675
B: зарплатный проект0.85770.02360.40670.0133

Оба критерия выбирают разбиение A, и порядок предпочтений совпадает. Это типичная картина: расхождения между энтропией и Джини возникают в основном на сильно несбалансированных многоклассовых задачах.

Где критерий ломается

Главная слабость прироста информации — смещение в сторону признаков с большим числом значений. Возьмём признак с 1000 уникальных значений, например идентификатор клиента. Он разобьёт узел на 1000 ветвей по одному объекту, каждая ветвь чистая, условная энтропия равна нулю, а IG = 0.8813 — максимально возможный. Модель получит идеальное разбиение и нулевую предсказательную способность на новых данных.

Лечится это нормировкой на собственную энтропию разбиения: split info = −Σ (nᵥ / n) × log₂ (nᵥ / n), а gain ratio = IG / split info. Для нашего идентификатора split info = log₂ 1000 = 9.97, поэтому gain ratio = 0.8813 / 9.97 = 0.088 — заметно ниже, чем 0.1161 / 0.971 = 0.120 у бинарного разбиения A (split info для долей 0.6 / 0.4 равен 0.6 × 0.7370 + 0.4 × 1.3219 = 0.4422 + 0.5288 = 0.9710). Реализации на основе Джини решают ту же проблему иначе: бинаризуют категориальные признаки и ограничивают минимальный размер листа.

Вторая слабость: критерий жадный и одношаговый, поэтому признак, полезный только в паре с другим, дерево на верхнем уровне не выберет. Третья: IG измеряет статистическую связь, а не причинность. Признак «клиент открыл вклад в этом же банке» даёт огромный прирост и оказывается утечкой целевой переменной, если вклад открывают уже после одобрения кредита.

Пример

В нашем условном скоринге аналитик добавил признак «код отделения», 340 значений. IG подскочил до 0.52 и признак ушёл в корень дерева. Проверка: split info у такого разбиения близка к log₂ 340 = 8.41, поэтому gain ratio = 0.52 / 8.41 = 0.062, тогда как у отношения платежа к доходу gain ratio = 0.1161 / 0.9710 = 0.120. После ограничения min_samples_leaf = 200 признак «код отделения» дал IG всего 0.014 и опустился к листьям. Вывод для отчёта: высокий прирост информации у признака с большой мощностью — повод проверить размер листьев, а не повод радоваться.

Частая ошибка

Считают прирост информации, забыв взвесить ветви размерами. В запросе получается что-то вроде AVG(entropy_per_branch) вместо суммы с весами nᵥ / n. Для разбиения A невзвешенное среднее равно (0.6099 + 0.9982) / 2 = 0.8041, «прирост» получается 0.0772 вместо 0.1161. Ошибка систематически завышает ценность разбиений, где одна ветвь крошечная и потому почти чистая. Заметить можно проверкой инварианта: пересчитайте взвешенную сумму долей классов по ветвям и убедитесь, что она возвращает распределение родителя. Здесь 0.6 × 0.85 + 0.4 × 0.475 = 0.51 + 0.19 = 0.70 — сходится.

Запомнить
  • IG и ΔGini — это снижение нечистоты при переходе к разбиению; ветви обязательно взвешиваются долями объектов.
  • Энтропия и Джини почти всегда дают один и тот же порядок признаков; Джини считается быстрее, так как обходится без логарифмов.
  • Прирост информации завышает ценность признаков с большим числом значений; gain ratio и ограничение размера листа снимают этот перекос.
  • Аномально большой прирост у одного признака — сигнал проверить утечку целевой переменной, а не принять признак в модель.
9.3

Расхождение Кульбака—Лейблера и контроль дрейфа данных

KL-дивергенция отвечает на вопрос «насколько дорого обходится описывать сегодняшние данные моделью вчерашнего мира» и служит основой почти всех метрик дрейфа, включая PSI.

Суть

Пусть есть два распределения на одном наборе исходов: P — то, что происходит на самом деле, и Q — то, что мы предполагали. Если бы мы знали P, оптимальный код тратил бы в среднем H(P) бит на наблюдение. Но код построен по Q, поэтому исход с реальной вероятностью pᵢ кодируется длиной −log₂ qᵢ. Средняя длина такого кода называется перекрёстной энтропией.

H(P, Q) = − Σ pᵢ × log₂ qᵢ

Разница между тем, что мы платим, и тем, что платили бы при точном знании, — это и есть KL-дивергенция: цена ошибки в модели, выраженная в битах на наблюдение.

D(P ‖ Q) = H(P, Q) − H(P) = Σ pᵢ × log₂ (pᵢ / qᵢ)

Отсюда три свойства. Величина всегда неотрицательна. Она равна нулю тогда и только тогда, когда P и Q совпадают во всех точках. И она несимметрична: D(P ‖ Q) в общем случае не равно D(Q ‖ P), поэтому KL — не расстояние, и говорить «расстояние между распределениями» про неё неверно.

Как это считается

Допустим, у маркетплейса уровня Ozon есть профиль пользователей по четырём сегментам частоты покупок. Эталон Q — апрель, месяц обучения модели: 0.35 / 0.28 / 0.22 / 0.15. Текущий срез P — май: 0.45 / 0.30 / 0.15 / 0.10. Считаем по шагам.

Отношения pᵢ / qᵢ: 0.45 / 0.35 = 1.2857; 0.30 / 0.28 = 1.0714; 0.15 / 0.22 = 0.6818; 0.10 / 0.15 = 0.6667. Логарифмы: log₂ 1.2857 = 0.3626; log₂ 1.0714 = 0.0995; log₂ 0.6818 = −0.5525; log₂ 0.6667 = −0.5850. Слагаемые: 0.45 × 0.3626 = 0.1632; 0.30 × 0.0995 = 0.0299; 0.15 × (−0.5525) = −0.0829; 0.10 × (−0.5850) = −0.0585.

D(P ‖ Q) = 0.1632 + 0.0299 − 0.0829 − 0.0585 = 0.0516 бита

Обратите внимание: отдельные слагаемые бывают отрицательными, а сумма — нет. Проверим через перекрёстную энтропию. H(P) = 0.45 × 1.1520 + 0.30 × 1.7370 + 0.15 × 2.7370 + 0.10 × 3.3219 = 0.5184 + 0.5211 + 0.4106 + 0.3322 = 1.7822. H(P, Q) = 0.45 × 1.5146 + 0.30 × 1.8365 + 0.15 × 2.1844 + 0.10 × 2.7370 = 0.6816 + 0.5510 + 0.3277 + 0.2737 = 1.8339. Разность 1.8339 − 1.7822 = 0.0517 — сходится с прямым расчётом с точностью до округления.

Теперь посчитаем в обратную сторону: D(Q ‖ P) = 0.35 × log₂ (0.35 / 0.45) + 0.28 × log₂ (0.28 / 0.30) + 0.22 × log₂ (0.22 / 0.15) + 0.15 × log₂ (0.15 / 0.10) = 0.35 × (−0.3626) + 0.28 × (−0.0995) + 0.22 × 0.5525 + 0.15 × 0.5850 = −0.1269 − 0.0279 + 0.1216 + 0.0878 = 0.0545. Значения 0.0516 и 0.0545 близки, но не равны — вот она, асимметрия. Договоритесь в команде раз и навсегда: в мониторинге дрейфа P — текущий период, Q — эталон.

PSI, пороги и нулевые вероятности

В банковской практике вместо KL чаще считают PSI (Population Stability Index) — симметризованную версию с натуральным логарифмом.

PSI = Σ (pᵢ − qᵢ) × ln (pᵢ / qᵢ) = D(P ‖ Q) + D(Q ‖ P), в натах

Для наших данных: (0.45 − 0.35) × ln 1.2857 = 0.10 × 0.2513 = 0.0251; (0.30 − 0.28) × ln 1.0714 = 0.02 × 0.0690 = 0.0014; (0.15 − 0.22) × ln 0.6818 = (−0.07) × (−0.3830) = 0.0268; (0.10 − 0.15) × ln 0.6667 = (−0.05) × (−0.4055) = 0.0203. Сумма 0.0736. Отраслевая шкала: до 0.1 — сдвиг несущественный, 0.1–0.25 — умеренный, требует внимания, свыше 0.25 — распределение изменилось, модель пора пересматривать. Наши 0.0736 не дотягивают до порога, хотя доля первого сегмента выросла с 35 % до 45 %. Это полезно помнить: PSI не чувствителен к сдвигам, которые кажутся большими в процентных пунктах.

Главное место, где KL ломается, — нулевые вероятности в эталоне. Если в мае появился сегмент с долей 0.07, которого в апреле не было (q = 0), отношение p / q обращается в бесконечность и метрика перестаёт существовать. Обычный приём — сглаживание: прибавить ко всем qᵢ малое ε и перенормировать. Но результат тогда зависит от ε произвольным образом: при ε = 10⁻⁶ расхождение получается около 1.08 бита, при ε = 10⁻³ — около 0.38 бита. Одни и те же данные, разница втрое. Поэтому порог на сглаженной KL не имеет устойчивого смысла: практичнее вынести появление и исчезновение категорий в отдельную проверку, а расхождение считать по общему носителю, либо перейти к дивергенции Йенсена—Шеннона, которая симметрична, конечна и ограничена одним битом.

Второй источник ложных тревог — мелкие бины. Дрейф по непрерывным признакам считают на 10–20 квантильных интервалах. Если в бин текущего среза попало 12 наблюдений, его доля — шум, а вклад в PSI может быть большим. Разумное правило: минимум 30–50 наблюдений на бин, иначе бины объединяют.

Пример

В нашем условном мониторинге скоринговой модели PSI по признаку «доход» рос три недели подряд: 0.06, 0.14, 0.31. Разложение по бинам показало, что почти весь вклад даёт нижний интервал: его доля упала с 0.18 до 0.04. Причина оказалась не в клиентах, а в пайплайне — партнёрский канал перестал передавать доход, и поле заполнялось значением по умолчанию, которое попадало в другой бин. PSI сработал как детектор поломки данных, а не изменения аудитории. Это его типичная роль: метрика говорит «что-то поехало», а разложение по бинам показывает, где именно.

Частая ошибка

Бины пересчитывают заново на каждом срезе. Аналитик пишет NTILE(10) OVER (ORDER BY income) и по эталону, и по текущему месяцу — и получает PSI, близкий к нулю, при любом сдвиге распределения, потому что квантильное разбиение по определению даёт по 10 % в каждом бине. Метрика превращается в константу и никогда не срабатывает. Границы бинов должны быть зафиксированы один раз по эталонной выборке и храниться как данные, а текущий срез только раскладывается по этим границам. Заметить ошибку помогает та же проверка: если доли текущего периода подозрительно ровные (0.10 в каждом из десяти бинов), разбиение считается не там.

Запомнить
  • D(P ‖ Q) — переплата в битах за использование Q вместо истинного P; ноль только при полном совпадении, отрицательной быть не может.
  • KL несимметрична: зафиксируйте соглашение «P — текущий период, Q — эталон» и не меняйте его между отчётами.
  • PSI = D(P ‖ Q) + D(Q ‖ P) в натах; ориентиры 0.1 и 0.25, но пороги стоит калибровать на своей истории.
  • Нулевые вероятности и мелкие бины делают метрику неустойчивой; границы бинов фиксируются по эталону, а новые категории проверяются отдельно.
9.4

Числа с плавающей точкой: где теряется точность

Почти все расхождения в копейках между витриной и бухгалтерией объясняются не логикой запроса, а тем, как компьютер хранит дробные числа.

Суть

Тип double (в стандарте IEEE 754 — binary64) хранит число в виде знак × мантисса × 2 в степени порядка. На мантиссу отведено 52 бита плюс один подразумеваемый, на порядок — 11 бит. Ключевое слово здесь — «2». Дробь представима точно только если она есть сумма отрицательных степеней двойки: 0.5, 0.25, 0.125, 0.75 и им подобные.

Десятичная дробь 0.1 в двоичной системе — бесконечная периодическая: 0.0001100110011… Хранилище обрезает её на 53 значащих битах и округляет. Получается не 0.1, а ближайшее к ней двоичное число:

0.1 → 0.1000000000000000055511151231257827…
0.2 → 0.2000000000000000111022302462515654…
0.3 → 0.2999999999999999888977697537484346…

Сложим первые два точно: 0.30000000000000001665… Результат тоже надо округлить до ближайшего представимого числа, и им оказывается 0.3000000000000000444089209850062616 — а это не то же самое двоичное число, что хранится под именем 0.3. Разница составляет 5.55 × 10⁻¹⁷, ровно 2⁻⁵⁴. Поэтому 0.1 + 0.2 == 0.3 возвращает ложь, а печать 0.1 + 0.2 в Python даёт 0.30000000000000004. Ошибки нет: обе стороны округлены корректно, но округлены в разные стороны.

Относительная погрешность и поглощение

Полезнее думать не про абсолютную ошибку, а про сетку. Между соседними представимыми числами есть шаг, который называют ULP; он пропорционален величине самого числа. Для binary64 относительный шаг около 2.2 × 10⁻¹⁶, то есть примерно 15–17 значащих десятичных цифр. Рядом с 1 шаг равен 2.2 × 10⁻¹⁶, рядом с миллионом — 2.3 × 10⁻¹⁰, рядом с 10¹⁶ — уже 2.

Отсюда эффект поглощения: 1e16 + 1 в binary64 равно ровно 1e16, прибавленная единица меньше половины шага сетки и исчезает. Для аналитика это не экзотика: суммарная выручка крупного маркетплейса за год в копейках вполне доходит до 10¹⁴–10¹⁵, и точность там уже не безгранична. Для типа float (binary32, 24 бита мантиссы) всё гораздо хуже: относительный шаг около 1.2 × 10⁻⁷, целые числа представимы точно только до 16 777 216, а рядом с 20 000 000 шаг сетки равен 2 — прибавление 50 копеек не меняет ничего вообще.

Почему деньги нельзя сравнивать через равенство float

Допустим, в заказе три позиции: 299.41, 2542.10 и 197.10 рубля. Точная сумма — 3038.61. Складываем в double: 299.41 + 2542.10 даёт 2841.51 (уже с крошечной погрешностью), прибавляем 197.10 и получаем 3038.6099999999997. Запрос WHERE order_total = 3038.61 эту строку не найдёт. Хуже того, найдёт или не найдёт в зависимости от порядка позиций в сумме: сложение с плавающей точкой не ассоциативно, и перестановка слагаемых меняет последний бит.

Первое смягчение — сравнение с допуском: вместо равенства писать ABS(a - b) < 0.005. Это работает для сверок, но не решает проблему хранения: ошибка накапливается при каждой операции, и порог рано или поздно перестаёт быть достаточным. Второе смягчение — округление до двух знаков перед сравнением. Тоже частичное: округлять надо согласованно во всех местах, а один забытый джойн ломает всё.

Правильное решение — не хранить деньги в двоичной плавающей точке вообще. Два рабочих варианта. Первый: целые копейки в bigint. Наши три позиции превращаются в 29941 + 254210 + 19710 = 303861, сложение целых точно по определению, сравнение через равенство корректно, а деление на 100 делается только на выводе. Диапазон bigint — примерно 9.2 × 10¹⁸, то есть 92 квадриллиона рублей: запаса хватит любому. Второй: десятичный тип фиксированной точности, NUMERIC(18, 2) в PostgreSQL, Decimal(18, 2) в ClickHouse, decimal.Decimal в Python. Такие типы хранят число как целую мантиссу со степенью десятки, поэтому 0.1 и 0.3 представляются точно, а 0.1 + 0.2 равно 0.3 буквально.

Плата за десятичный тип — скорость и отсутствие аппаратной поддержки: агрегаты по сотням миллионов строк на NUMERIC считаются заметно медленнее, чем на double. Разумный компромисс, который применяют в хранилищах: суммы денег — целые копейки или NUMERIC, а доли, коэффициенты, вероятности и прочие безразмерные величины — double, потому что для них последний бит не имеет юридического смысла.

Пример

Допустим, витрина в банке уровня Сбера считает комиссию как 1.5 % от суммы перевода. Перевод 100.10 рубля, комиссия в double: 100.10 × 0.015 = 1.5014999999999998, округление до копеек даёт 1.50. Бухгалтерский модуль считает в NUMERIC: 1.5015, округление половины вверх даёт 1.51. Копейка расхождения на операцию, десятки миллионов операций в месяц — и сверка не сходится на сотни тысяч рублей. Разбор всегда начинается с одного вопроса: в каком типе считало каждое из двух мест.

Частая ошибка

В джойне ключ — денежная сумма или доля в double: ON a.amount = b.amount. Часть строк не сматчится, и потеря будет не случайной, а зависящей от значений, поэтому она незаметна на глаз и не воспроизводится на маленькой выборке. Симптом: количество строк после джойна на доли процента меньше ожидаемого, и «пропавшие» строки при ручной проверке выглядят абсолютно корректными. Проверка: сравните COUNT(*) до и после и попробуйте джойн по ROUND(amount * 100) как целому. Если расхождение исчезло — дело было в типе. Общее правило: числа с плавающей точкой не годятся в ключи джойна, в GROUP BY и в DISTINCT.

Запомнить
  • Двоичная плавающая точка не может представить 0.1, 0.2 и 0.3 точно; 0.1 + 0.2 отличается от 0.3 на 2⁻⁵⁴.
  • Шаг сетки растёт вместе с числом: в binary64 около 15–17 значащих цифр, в binary32 — около 7, и мелкие слагаемые поглощаются.
  • Деньги хранят в целых копейках или в десятичном типе; double для сумм допустим только в черновых расчётах.
  • Плавающая точка не должна попадать в условия равенства, ключи джойна, GROUP BY и DISTINCT.
9.5

Суммирование миллионов строк и устойчивый расчёт дисперсии

На таблицах в десятки миллионов строк порядок операций перестаёт быть безразличным: наивное накопление в одном аккумуляторе теряет проценты суммы, а школьная формула дисперсии способна выдать отрицательное число.

Суть

Ошибка отдельного сложения в плавающей точке не превышает половины шага сетки — величина ничтожная. Проблема в том, что при последовательном накоплении шаг сетки задаётся размером аккумулятора, а не слагаемого. Когда в переменной накоплено сто тысяч, а прибавляем мы десятые доли, каждое сложение округляется относительно ста тысяч. На однородных данных такие ошибки не гасят друг друга: они систематически смещены в одну сторону и складываются линейно по числу строк.

Наивная сумма: ошибка растёт как n × ε × S, где ε ≈ 2.2 × 10⁻¹⁶ для binary64

Как это выглядит на числах

Возьмём миллион одинаковых значений 0.1 в типе binary32 и сложим их последовательно в аккумулятор того же типа. Истинная сумма представимых значений — 100000.0015. Наивное накопление даёт 100958.34. Ошибка почти 958 единиц, то есть 0.96 % — почти процент выручки, потерянный ниоткуда.

Механика такая. Пока аккумулятор мал, всё нормально. Но когда он переваливает за 65536, шаг сетки binary32 в этой области становится 2⁻⁷ ≈ 0.0078. Прибавление 0.1 не ложится на сетку и округляется, причём в данном случае систематически вверх. Миллион таких округлений и дают почти килоединицу.

Тот же эффект в денежном виде: если суммировать выручку в float и аккумулятор дошёл до 20 000 000 рублей, шаг сетки в этой области равен 2 рублям. Заказ на 50 копеек не изменит сумму вообще; заказ на 1500 рублей добавится с ошибкой до рубля. Чем больше накоплено, тем грубее становится сложение — ровно наоборот тому, чего ожидает автор отчёта.

Четыре рабочих способа лечения. Первый и самый дешёвый: считать в binary64. Относительный шаг падает с 1.2 × 10⁻⁷ до 2.2 × 10⁻¹⁶, и на 50 миллионах строк накопленная относительная ошибка остаётся порядка 10⁻⁸ — незаметна в любом отчёте. Второй: попарное суммирование, когда массив делится пополам рекурсивно; ошибка растёт как логарифм n, а не линейно. Именно так по умолчанию работает numpy.sum: для нашего миллиона значений в binary32 он даёт 100000.01 вместо 100958.34, при том же типе данных. Третий: компенсированное суммирование Кэхэна — во второй переменной хранится потерянный при округлении остаток и возвращается в следующее сложение; на тех же данных получается ровно 100000.0. Четвёртый, для денег: суммировать целые копейки, тогда ошибки нет по построению.

Способ (1 млн × 0.1, binary32)РезультатОтклонение
Истинная сумма100000.0015
Последовательное накопление100958.34+958.3
Попарное суммирование100000.01+0.01
Компенсация Кэхэна100000.00−0.002

Почему школьная формула дисперсии опасна

Формулу дисперсии часто пишут в удобном для одного прохода виде: среднее квадратов минус квадрат среднего. В SQL это выглядит как SUM(x*x)/COUNT(*) - POWER(SUM(x)/COUNT(*), 2) и соблазняет тем, что требует всего двух агрегатов. Беда в том, что при большом среднем и малом разбросе оба слагаемых почти равны, а их разность крошечная. Вычитание близких чисел уничтожает значащие цифры — это называется катастрофическим сокращением.

Возьмём четыре значения: 1000000004, 1000000007, 1000000013, 1000000016. Отклонения от среднего 1000000010 равны −6, −3, +3, +6, значит истинная дисперсия по совокупности равна (36 + 9 + 9 + 36) / 4 = 22.5. Наивная формула в binary64 даёт −128. Отрицательная дисперсия. Причина видна из порядков: сумма квадратов около 4 × 10¹⁸, шаг сетки в этой области 512, а искомая разность имеет порядок 22 — она целиком тонет в шуме округления.

Устойчивая альтернатива — алгоритм Уэлфорда: одним проходом обновлять среднее и сумму квадратов отклонений от текущего среднего, никогда не возводя в квадрат сами значения.

δ = xₙ − mₙ₋₁ ; mₙ = mₙ₋₁ + δ / n ; M2ₙ = M2ₙ₋₁ + δ × (xₙ − mₙ)

Прогоним на значениях 4, 7, 13, 16. Шаг 1: δ = 4 − 0 = 4, m = 4, второе отклонение 4 − 4 = 0, M2 += 4 × 0 = 0. Шаг 2: δ = 7 − 4 = 3, m = 4 + 3/2 = 5.5, второе отклонение 7 − 5.5 = 1.5, M2 += 3 × 1.5 = 4.5. Шаг 3: δ = 13 − 5.5 = 7.5, m = 5.5 + 7.5/3 = 8, второе отклонение 13 − 8 = 5, M2 += 7.5 × 5 = 37.5, итого 42. Шаг 4: δ = 16 − 8 = 8, m = 8 + 8/4 = 10, второе отклонение 16 − 10 = 6, M2 += 8 × 6 = 48, итого 90.

Проверка: среднее действительно 10, сумма квадратов отклонений (−6)² + (−3)² + 3² + 6² = 90 — сходится. Дисперсия по совокупности 90 / 4 = 22.5, выборочная 90 / 3 = 30, стандартное отклонение √30 = 5.477. Смысл конструкции в том, что произведение δ × (xₙ − mₙ) берёт отклонения до и после обновления среднего, а величины в расчёте всегда порядка самого разброса, а не порядка квадрата среднего. Вычитания близких больших чисел не происходит, поэтому результат устойчив даже при среднем в миллиард.

Для распределённых вычислений есть парная версия: два частичных результата (nₐ, mₐ, M2ₐ) и (n_b, m_b, M2_b) объединяются как M2 = M2ₐ + M2_b + δ² × nₐ × n_b / (nₐ + n_b), где δ = m_b − mₐ. Так дисперсия считается по шардам, и поэтому встроенные VAR_SAMP и STDDEV устойчивы. Опасен не SQL, а рукописная формула через SUM(x*x).

Пример

Допустим, вы считаете дневную выручку маркетплейса по 40 миллионам строк заказов и одновременно стандартное отклонение чека. Суммы в копейках лежат в bigint, поэтому выручка точна. А стандартное отклонение аналитик посчитал вручную через SQRT(SUM(amount*amount)/COUNT(*) - POWER(AVG(amount),2)) прямо по копейкам. Средний чек 250 000 копеек, сумма квадратов около 2.5 × 10¹⁸ — и запрос вернул NaN, потому что под корнем оказалось отрицательное число. Замена на встроенный STDDEV_SAMP(amount) вернула 187 400 копеек. Ошибка была не в данных и не в фильтрах, а в способе вычисления.

Частая ошибка

Витрину строят инкрементально: к вчерашнему накопленному итогу в float каждый день прибавляют дневную сумму. Ошибка накапливается месяцами и никогда не проверяется, потому что пересчёта с нуля нет. Через год расхождение с бухгалтерией достигает заметных величин, а виновата не логика, а тип столбца. Заметить можно единственным способом — регулярной сверкой: раз в неделю пересчитывайте итог с нуля по исходным строкам и сравнивайте с накопленным. Если расхождение растёт монотонно, это не потерянные заказы, это округление. Лечение: bigint в копейках либо NUMERIC, плюс полный пересчёт по расписанию.

Запомнить
  • Ошибка последовательного суммирования растёт линейно с числом строк, потому что шаг округления задаётся размером аккумулятора, а не слагаемого.
  • Порядок средств защиты: целые копейки для денег, binary64 для остального, попарное или компенсированное суммирование при особых требованиях.
  • Формула «среднее квадратов минус квадрат среднего» при большом среднем даёт катастрофическое сокращение вплоть до отрицательной дисперсии.
  • Алгоритм Уэлфорда считает среднее и дисперсию за один проход устойчиво и объединяется по шардам; в SQL пользуйтесь встроенными VAR_SAMP и STDDEV_SAMP.
9.6

Масштаб, округление и как не соврать в отчёте

Числа в отчёте искажаются не только машинной арифметикой: неверно выбранный масштаб, преждевременное округление и усреднение уже усреднённого дают ошибки на порядки больше, чем последний бит мантиссы.

Суть

Отчёт — это цепочка преобразований: агрегация, деление, округление, форматирование. Каждое звено вносит погрешность, и важно, в каком порядке они стоят. Общее правило одно: округляйте в самый последний момент, непосредственно перед выводом, и никогда не подавайте округлённое значение на вход следующему расчёту. Округление до двух знаков вносит ошибку до 0.005; если после этого умножить на количество строк, ошибка умножится вместе с ним.

Второе правило касается того, что вообще имеет смысл усреднять. Средние нельзя усреднять без весов, доли нельзя усреднять как числа, а проценты от процентов почти всегда означают путаницу между процентами и процентными пунктами.

Округление долей: сумма не равна ста

Допустим, миллион заказов маркетплейса распался по пяти категориям: 412 345, 238 910, 175 442, 102 303 и 71 000. Доли в процентах: 41.2345, 23.8910, 17.5442, 10.2303, 7.1000. Проверим, что исходные числа складываются в миллион: 412 345 + 238 910 = 651 255; + 175 442 = 826 697; + 102 303 = 929 000; + 71 000 = 1 000 000.

Округлим доли до одного знака: 41.2, 23.9, 17.5, 10.2, 7.1. Складываем: 41.2 + 23.9 = 65.1; + 17.5 = 82.6; + 10.2 = 92.8; + 7.1 = 99.9. В таблице отчёта появится строка «Итого 99.9 %», и первый же читатель спросит, куда делась десятая процента.

Ответ: никуда, четыре доли округлились вниз. Способ, которым это решают, называется методом наибольших остатков: округлить все доли вниз, посчитать недостачу до 100 и раздать по единице последнего разряда тем категориям, у которых отброшенная часть была наибольшей. Здесь остатки после округления вниз (41.2, 23.8, 17.5, 10.2, 7.1 — сумма 99.8) равны 0.0345, 0.0910, 0.0442, 0.0303, 0.0000; недостача 0.2 распределяется двум наибольшим остаткам, второй и третьей категориям: 41.2, 23.9, 17.6, 10.2, 7.1 — сумма ровно 100.0. Альтернатива, которая тоже приемлема, — оставить как есть и подписать под таблицей, что сумма может не равняться 100 из-за округления. Недопустимо только молча подправить одну цифру руками.

Отдельная тонкость — правило округления половины. Питон и NumPy по умолчанию используют банковское округление к чётному: round(2.5) даёт 2, а round(3.5) — 4. Бухгалтерия ожидает округление половины вверх: 2.5 → 3. На больших объёмах разница между правилами систематическая, и если витрина считает в Python, а сверка идёт по бухгалтерским данным, расхождение будет расти пропорционально числу операций. Правило нужно зафиксировать явно и одинаково во всех слоях.

Масштаб: средние средних и проценты от процентов

Допустим, в нашем примере регион A дал 1200 заказов со средним чеком 1500 рублей, регион B — 98 800 заказов со средним чеком 2600 рублей. Невзвешенное среднее двух средних: (1500 + 2600) / 2 = 2050 рублей. Правильный расчёт: (1200 × 1500 + 98 800 × 2600) / 100 000 = (1 800 000 + 256 880 000) / 100 000 = 258 680 000 / 100 000 = 2586.80 рубля. Разница 537 рублей, или 26 % от истинного значения — целиком из-за того, что маленькому региону дали тот же вес, что большому.

Тот же класс ошибок в конверсии. Конверсия за месяц — это SUM(orders) / SUM(visits), а не AVG(daily_conversion). Второе даёт равный вес выходному дню с тысячей визитов и рабочему с сотней тысяч. При выраженной недельной сезонности расхождение доходит до десятых долей процентного пункта — а именно в этих долях обычно и обсуждают результат эксперимента.

Про проценты и процентные пункты. Если конверсия выросла с 2.0 % до 3.0 %, это рост на 1 процентный пункт и одновременно рост на 50 % относительно базы. Обе формулировки верны, но означают разное, и подменять одну другой — самый распространённый способ приукрасить отчёт, не написав ни одного ложного числа. В таблицах стоит выводить обе величины и подписывать их явно: «п.п.» и «%».

Наконец, значащие цифры. Если доверительный интервал среднего чека составляет ±80 рублей, публиковать 2586.8043 бессмысленно: четыре знака создают впечатление точности, которой в данных нет. Округляйте до разряда, сопоставимого с погрешностью, и указывайте интервал рядом. Обратная крайность тоже вредна: округление промежуточной величины до целых процентов уничтожает эффект, если он имеет порядок десятых.

Пример

Допустим, банк уровня Сбера начисляет кэшбэк 1 % и делит его между тремя категориями поровну. Покупка 100.00 рубля даёт кэшбэк 1.00 рубля, делим на три и округляем каждую часть до копеек: 0.33 + 0.33 + 0.33 = 0.99. Одна копейка исчезла. На 30 миллионах условных операций в месяц это 300 000 рублей необъяснимой разницы между начисленным и распределённым. Устойчивое решение — считать в копейках целыми: 100 копеек кэшбэка делятся как 34 + 33 + 33, остаток от деления детерминированно отдаётся первой категории. Тогда сумма частей всегда равна целому, и сверка сходится до копейки.

Частая ошибка

Средний чек по срезу считают как AVG(avg_check) по строкам предагрегированной витрины, где одна строка — это день или регион. Ошибка не бросается в глаза, потому что число выглядит правдоподобно и меняется в правильную сторону. Проверка занимает одну минуту: посчитайте SUM(revenue) / SUM(orders) по тем же строкам и сравните. Если значения расходятся, значит веса потеряны. Общий признак риска — любое агрегирование поверх столбца, который сам является отношением или средним: такие столбцы нельзя складывать и усреднять, к ним нужно тащить числитель и знаменатель отдельно. Хорошая привычка при проектировании витрины — хранить именно revenue и orders, а средний чек вычислять на лету.

Запомнить
  • Округляйте только на выводе; округлённое значение не должно становиться входом следующего расчёта.
  • Округлённые доли не обязаны давать 100 — применяйте метод наибольших остатков либо явно подписывайте расхождение.
  • Средние и доли складываются только с весами: конверсия и средний чек считаются как отношение сумм, а не как среднее отношений.
  • Различайте проценты и процентные пункты, а число знаков после запятой согласуйте с реальной погрешностью оценки.
Закрепление

Теория без задач не держится

Восемь задач по этому модулю: выбор ответа и расчёт, с разбором каждого решения. Задачи, в которых вы ошиблись, вернутся в следующий заход.

Перейти к тренажёру