На предыдущем месте работы я разбирала, почему помесячный прогноз выручки стабильно недобирает. Не скачет, а именно недобирает: факт всегда выше прогноза, из месяца в месяц, примерно в одну и ту же сторону.
Стабильное смещение — это уже не шум, это баг, поэтому я полезла в модель. Модель была нормальная, на логарифме выручки, обученная коллегой, метрики на валидации приличные, так что сначала я смотрела вообще не туда. Ошибка нашлась в последней строчке пайплайна: прогноз в логарифмах возвращали в рубли через np.exp(), просто экспонента, безо всего, и выглядит это абсолютно логично, преобразование же обратимое. На нашем распределении это занижало прогноз примерно вдвое, не на процент и не на пять. Чинится тремя строчками, и об этом будет раздел 7. Но сначала про то, о чем меня всегда спрашивают, когда я эту историю рассказываю: как такое прожило в проде несколько месяцев и почему не сработала валидация.

А валидация не могла сработать в принципе, и вот это самое интересное. Модель обучалась минимизировать ошибку на логарифме, метрики качества считались там же, на логарифме, и в этой системе координат она была честной: остатки центрированы около нуля, смещения нет, MAE и RMSE в порядке. Смещение рождается не в модели, а в момент выхода из логарифмов, потому что exp() от несмещенной оценки среднего логарифма дает оценку медианы, а не среднего. То есть ошибка была структурно невидима для проверок: они жили в той же системе координат, в которой ошибки еще нет.

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

С тех пор у меня в чек‑листе приемки любой модели, где есть преобразование таргета, стоит один пункт: отношение суммы прогнозов к сумме факта на отложенном периоде, в исходных единицах. Одна строчка, считается за секунду, и должна лежать около единицы, у нас она лежала около 0,5, и этого хватило бы, чтобы поймать все за день, а не за квартал.

Меня же тогда зацепило другое: ошибка была не в SQL, не в фичах и не в модели. Она была в том, что человек (и я, когда ревьюила первый раз, тоже) считал логарифм технической деталью, а не сменой системы координат. А после логарифмирования все выводы живут в мире процентов, и возврат в рубли — это отдельное действие, которое надо делать сознательно.

Поэтому здесь я собрала все места, где логарифм в аналитике реально работает, и все места, где он тихо портит результат.

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

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

Почему деньги и время никогда не бывают нормальными

Нормальное распределение появляется там, где на результат влияет много независимых слагаемых. Рост человека такой: гены плюс питание плюс болезни в детстве плюс еще сотня мелочей, все складывается, получается колокол.

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

Логарифм превращает умножение в сложение, поэтому логарифм логнормальной величины нормален, и после логарифмирования начинает работать вся привычная машинерия: t‑тесты, регрессия, доверительные интервалы.

Это, собственно, единственная причина, по которой логарифм вообще нужен аналитику. Не «сделать числа поменьше», а «перевести мультипликативный мир в аддитивный, где мы умеем считать».

Сразу оговорюсь, потому что дальше я этим предположением пользуюсь везде: реальные данные строго логнормальными не бывают. Логарифм не делает распределение нормальным по щелчку, он делает его сильно ближе к нормальному, и вот это «сильно ближе» надо проверять глазами, а не верить на слово, минимум гистограммой до и после плюс QQ‑plot. У меня были случаи, когда после логарифмирования вылезала вторая мода, и это оказывалось не проблемой распределения, а двумя склеенными сегментами клиентов в одной витрине.

1. Три средних (на это опирается все остальное)

Про среднее и медиану написано столько, что повторять неловко, но мне нужен один конкретный факт из этого раздела для разделов 5 и 7, поэтому проговорю коротко.

import numpy as np
rng = np.random.default_rng(42)

x = rng.lognormal(mean=8.0, sigma=1.2, size=100_000)  # условная выручка на клиента

print(f"среднее           {x.mean():,.0f}")
print(f"медиана           {np.median(x):,.0f}")
print(f"среднее геометр.  {np.exp(np.log(x).mean()):,.0f}")
print(f"доля ниже среднего        {(x < x.mean()).mean():.3f}")
print(f"доля выручки у топ-1%     {np.sort(x)[-len(x)//100:].sum() / x.sum():.3f}")

Получаем:

среднее           6,155
медиана           2,949
среднее геометр.  2,966
доля ниже среднего        0.729
доля выручки у топ-1%     0.134

Ниже среднего оказались 72,9% клиентов.
Это не кривой датасет, это нормальное свойство скошенных данных, и оно повторится на любых чеках, любых длительностях и любых счетчиках. Верхний процент клиентов при этом дает 13,4% всей выручки.

Кстати, 13,4% — это еще скромно, потому что логнормальное распределение заметно недооценивает реальную концентрацию: на живых B2B‑данных мне регулярно попадалось, что верхний процент клиентов дает от трети выручки и больше, потому что там хвост ближе к степенному (Парето), а у него он тяжелее. Так что если считаете на симуляции, помните, что в бою будет хуже.

Отдельно обратите внимание на среднее геометрическое: 2966 против медианы 2949. Почти совпали, и это не случайность: среднее геометрическое — это exp(среднее от логарифмов). Медиана сохраняется при любом монотонном преобразовании, а логарифм логнормальной величины нормален, и у нормального распределения среднее совпадает с медианой. Значит exp(среднее логарифмов) = exp(медиана логарифмов) = медиана исходной величины. Для произвольного распределения такого равенства нет, там среднее геометрическое просто лежит где‑то между медианой и средним арифметическим.

Визуализация кода
Визуализация кода

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

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

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

Что работает лучше, по моему опыту: не воевать за замену, а добавить рядом. Медиана и квантили 25/75/90 отдельными полями, в том же дашборде, без снятия старой метрики. Через квартал люди сами начинают смотреть на квантили, потому что по ним видно то, чего не видно по средней, и вот тогда замену можно проводить как формальность. И отдельно: если хвост — это не выбросы, а другой тип клиента (обычно так и есть), то настоящее решение вообще не в выборе среднего, а в сегментации. Логарифм здесь диагностика, а не лекарство.

2. Декомпозиция роста: как логарифм превращает произведение в сумму

Вот этим я пользуюсь чаще, чем всем остальным вместе взятым, а в статьях почему‑то почти не встречаю, классика: выручка = пользователи × конверсия × средний чек. Метрика выросла на 14%, приходит вопрос «за счет чего». Все три множителя изменились одновременно, часть в плюс, часть в минус.

Наивный подход — сложить проценты изменения каждого множителя, он не работает:

u0, c0, a0 = 100_000, 0.020, 3000.0   # было
u1, c1, a1 = 115_000, 0.018, 3300.0   # стало

r0, r1 = u0*c0*a0, u1*c1*a1
print(f"выручка {r0:,.0f} -> {r1:,.0f}, рост {100*(r1/r0-1):.2f}%")

naive = (u1/u0-1) + (c1/c0-1) + (a1/a0-1)
print(f"наивная сумма процентов: {100*naive:+.1f}%  против факта {100*(r1/r0-1):+.2f}%")
выручка 6,000,000 -> 6,831,000, рост 13.85%
наивная сумма процентов: +15.0%  против факта +13.85%

Больше процентного пункта потерялось между пальцев (1.15, если точно). На трех множителях это терпимо, на пяти‑шести уже нет, а если изменения крупные, расхождение становится неприличным. Причина в перекрестных членах: рост пользователей умножается на изменившийся чек, и это произведение никуда не девается.

А в логарифмах все складывается точно, без остатка:

du, dc, da = np.log(u1/u0), np.log(c1/c0), np.log(a1/a0)
tot = np.log(r1/r0)

print(f"dlogU {du:+.4f}   dlogC {dc:+.4f}   dlogA {da:+.4f}")
print(f"сумма {du+dc+da:+.4f}   dlogR {tot:+.4f}")
print(f"вклады: пользователи {100*du/tot:+.1f}%, конверсия {100*dc/tot:+.1f}%, чек {100*da/tot:+.1f}%")
dlogU +0.1398   dlogC -0.1054   dlogA +0.0953
сумма +0.1297   dlogR +0.1297
вклады: пользователи +107.7%, конверсия -81.2%, чек +73.5%

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

И читается это гораздо интереснее, чем «выручка +14%»: трафик дал больше ста процентов роста, конверсия съела восемьдесят, средний чек добавил семьдесят три. То есть на самом деле мы залили трафик, просадили на нем конверсию и вытянули результат чеком. Вот это уже разговор, из которого следуют действия, а не констатация зеленой цифры.

Пара оговорок, чтобы потом не удивляться:

  • вклады могут превышать 100% и быть отрицательными, это нормально, когда множители ходят в разные стороны;

  • если суммарное изменение около нуля, знаменатель tot стремится к нулю и проценты вклада разлетаются, в этом случае показывайте сами dlog, а не доли;

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

Отдельно про подачу, потому что здесь я обожглась. Строчку «вклад трафика +107.7%» в отчете читают как ошибку в расчетах, и следующие двадцать минут встречи уходят на объяснение, почему больше ста процентов — это нормально. С тех пор я в отчет кладу вклады не долями, а в рублях, и делается это не пропорциональным пересчетом на глаз, а честной формулой.

Уточню, потому что тут легко смешать две разные вещи. То, что мы посчитали выше, — это разложение изменения логарифма. Чтобы разложить изменение самой метрики в рублях, причем тоже точно, без остатка, нужны веса по логарифмическому среднему. Это и есть LMDI (logarithmic mean Divisia index), метод из энергетической экономики, где им раскладывают потребление энергии на факторы

L = (r1 - r0) / np.log(r1 / r0)          # логарифмическое среднее выручки
parts = np.array([du, dc, da]) * L        # вклады в рублях

print(f"трафик     {parts[0]:+,.0f}")
print(f"конверсия  {parts[1]:+,.0f}")
print(f"чек        {parts[2]:+,.0f}")
print(f"сумма      {parts.sum():+,.0f}   факт {r1-r0:+,.0f}")
трафик     +895,388
конверсия  -674,994
чек        +610,607
сумма      +831,000   факт +831,000

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

Есть и другие честные варианты разложения: метод Шепли, последовательная подстановка из классического факторного анализа. Логарифмический хорош тем, что он симметричен (не зависит от порядка множителей) и считается в три строки, а не в двадцать.

3. Эластичность: коэффициент, который можно сравнивать

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

Логарифмируем обе стороны, и коэффициент начинает читаться в процентах на процент. Это эластичность, и она сравнима между сегментами, рынками и годами.

n = 5000
spend = rng.lognormal(6, 0.8, n)
revenue = np.exp(3.0) * spend**0.6 * rng.lognormal(0, 0.25, n)   # истинная эластичность 0.6

def ols(x, y):
    X = np.column_stack([np.ones(len(x)), x])
    return np.linalg.lstsq(X, y, rcond=None)[0]

print(f"log-log:   {ols(np.log(spend), np.log(revenue))[1]:.3f}")
print(f"линейная:  {ols(spend, revenue)[1]:.3f}")

lo = spend < np.median(spend)
print(f"линейная на нижней половине:  {ols(spend[lo], revenue[lo])[1]:.3f}")
print(f"линейная на верхней половине: {ols(spend[~lo], revenue[~lo])[1]:.3f}")
print(f"log-log нижняя {ols(np.log(spend[lo]), np.log(revenue[lo]))[1]:.3f}, "
      f"верхняя {ols(np.log(spend[~lo]), np.log(revenue[~lo]))[1]:.3f}")
log-log:   0.593
линейная:  0.785
линейная на нижней половине:  1.413
линейная на верхней половине: 0.664
log-log нижняя 0.582, верхняя 0.593

Модель в логарифмах вытащила 0.593 при истинных 0.6, читается это так: рост затрат на 1% дает рост выручки на 0.59%, отдача убывающая, что для рекламных бюджетов ожидаемо.

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

Эластичность же на обеих половинах 0.582 и 0.593, то есть разъехалась на сотую, и вот ее уже можно класть в отчет, сравнивать с прошлым кварталом и с соседним сегментом.

Как читать разные комбинации:

Модель

Коэффициент означает

Единица

y ~ x

на сколько единиц вырастет y

единица y на единицу x

log(y) ~ x

на сколько процентов вырастет y

проценты на единицу x

y ~ log(x)

на сколько вырастет y при росте x на 1%

примерно b/100 единиц y

log(y) ~ log(x)

эластичность, проценты на процент

безразмерная

Две оговорки, без которых раздел был бы нечестным:
Первая техническая: lstsq я взяла, чтобы код читался без лишних зависимостей, но в работе так делать не надо, точечная оценка без стандартной ошибки — это половина результата, берите statsmodels, там сразу будут и доверительный интервал, и p‑value, и диагностика остатков.
Вторая содержательная: эластичность — это все еще корреляционная штука, никакой логарифм сам по себе не превращает наблюдательные данные в причинно‑следственные, и если бюджеты распределялись не случайно (а они никогда не распределяются случайно), то ваши 0.59 — это описание того, как оно было, а не обещание того, что будет, если добавить денег.

И третья, раз уж я взялась быть честной до конца: данные я сгенерировала со степенной зависимостью и мультипликативным шумом, то есть ровно в пользу лог‑модели, но если бы шум был аддитивным, а зависимость линейной, победитель был бы другой, и это, собственно, и есть критерий выбора. Логарифм берут не потому, что он лучше, а потому, что процесс мультипликативный, и это надо решать до моделирования, глядя на природу величины и на остатки, а не подбором того, у чего R² больше.

4. Полулогарифм и грабли, на которые наступают

Разберу отдельно третью строчку из таблицы выше, log(y) ~ x, потому что тут часто читают коэффициент неправильно.

weeks = np.arange(52)
users = 1000 * np.exp(0.03*weeks) * rng.lognormal(0, 0.05, 52)
b = ols(weeks, np.log(users))[1]

print(f"b = {b:.4f}")
print(f"приближенно 100*b       = {100*b:.2f}%")
print(f"точно 100*(exp(b)-1)    = {100*(np.exp(b)-1):.2f}%")
b = 0.0295
приближенно 100*b       = 2.95%
точно 100*(exp(b)-1)    = 3.00%

Разница в пять сотых процентного пункта, никого не волнует, проблема в том, что правило «умножь на сто» работает только пока коэффициент маленький:

b

100·b

100·(eᵇ − 1)

ошибка

0.03

3.0%

3.0%

0.0 п.п.

0.05

5.0%

5.1%

0.1 п.п.

0.10

10.0%

10.5%

0.5 п.п.

0.20

20.0%

22.1%

2.1 п.п.

0.30

30.0%

35.0%

5.0 п.п.

0.50

50.0%

64.9%

14.9 п.п.

0.69

69.0%

99.4%

30.4 п.п.

При коэффициенте 0.69 вы рассказываете про рост на 69%, а модель говорит про удвоение, то есть тридцать процентных пунктов расхождения на ровном месте.

Правило: до 0.1 можно умножать на сто, дальше только exp(b) - 1.
Я считаю также и честную формулу, так как это лишние десять символов, зато не надо каждый раз вспоминать, маленький у меня коэффициент или уже нет.

5. Логарифм в A/B: плюс мощность, минус смысл

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

from scipy import stats

def power(effect, n=5000, sims=2000, seed=7):
    r = np.random.default_rng(seed)          # свой генератор, чтобы функция не зависела от места вызова
    raw = logs = 0
    for _ in range(sims):
        ctrl = r.lognormal(8, 1.2, n)
        test = r.lognormal(8, 1.2, n) * effect
        raw  += stats.ttest_ind(ctrl, test, equal_var=False).pvalue < 0.05
        logs += stats.ttest_ind(np.log(ctrl), np.log(test), equal_var=False).pvalue < 0.05
    return raw/sims, logs/sims

for eff in [1.00, 1.03, 1.05]:
    raw, logs = power(eff)
    print(f"эффект {100*(eff-1):4.0f}%   сырые {100*raw:5.1f}%   логи {100*logs:5.1f}%")
эффект    0%   сырые   4.7%   логи   5.1%
эффект    3%   сырые  14.3%   логи  24.3%
эффект    5%   сырые  30.0%   логи  53.6%

На A/A обе версии держат уровень значимости около 5%, все честно. А на эффекте в 5% тест по сырым данным ловит его в 30.0% случаев, тест по логарифмам — в 53.6%. Мощность выросла почти вдвое при том же размере выборки, то есть эквивалентно тому, как если бы вы удвоили длительность теста бесплатно.

Если прогнать это по сетке эффектов, картина такая:

Разрыв держится на всем диапазоне. Чтобы дотянуть до привычных 80% мощности, сырому тесту нужен эффект где‑то между 10 и 12%, а тесту на логарифмах хватает 5–7%, то есть вдвое меньше. Разница не косметическая, она определяет, какие гипотезы вы вообще способны проверить за разумное время.

Ровно поэтому логарифмирование метрики регулярно предлагают как способ «ускорить эксперименты». И ровно поэтому его стоит обсуждать не с аналитиком, а с тем, кто будет принимать решение по результату.

Логарифмируя метрику, вы меняете гипотезу. Тест на логарифмах сравнивает не средние, а средние геометрические (а если данные близки к логнормальным, то фактически медианы). Иногда это ровно то, что нужно. А иногда это значит, что вы не увидите ровно тот эффект, ради которого все затевалось:

r = np.random.default_rng(11)
ctrl = r.lognormal(8, 1.2, 200_000)
test = r.lognormal(8, 1.2, 200_000)

idx = r.choice(len(test), size=int(0.001*len(test)), replace=False)   # 0.1% пользователей
test[idx] *= 60                                                       # ушли на крупный тариф

print(f"по средним:  {100*(test.mean()/ctrl.mean()-1):+.1f}%")
print(f"по медианам: {100*(np.median(test)/np.median(ctrl)-1):+.1f}%")
print(f"по логарифмам (геом. среднее): "
      f"{100*(np.exp(np.log(test).mean())/np.exp(np.log(ctrl).mean())-1):+.1f}%")
print(f"p-value сырой:      {stats.ttest_ind(ctrl, test, equal_var=False).pvalue:.4f}")
print(f"p-value на логах:   "
      f"{stats.ttest_ind(np.log(ctrl), np.log(test), equal_var=False).pvalue:.4f}")
по средним:  +6.0%
по медианам: +1.4%
по логарифмам (геом. среднее): +1.2%
p-value сырой:      0.0000
p-value на логах:   0.0016

Обратите внимание, в чем именно тут беда, потому что она тоньше, чем кажется. Обе версии теста находят эффект значимым, лог‑версия даже уверенно. Проблема не в том, что вы его не заметите, а в том, что вы сообщите его неправильного размера: фича подняла выручку на 6%, тех самых денег, которые попадут в отчет о прибылях, а в вашем отчете будет стоять +1.2%. Разница в пять раз, и обнаружится она в тот момент, когда финансы сверят ваш процент с фактом.

Здесь нет правильного ответа на все случаи, есть развилка:

  • бизнес считает выручку суммой, значит тестируйте среднее, а с дисперсией боритесь иначе: винзоризация, CUPED, стратификация, бутстрап;

  • вопрос звучит как «изменилось ли поведение типичного пользователя», тогда логарифм подходит;

  • и в любом случае напишите в отчете, какую именно величину вы сравнивали. Разница между «средним» и «средним геометрическим» выглядит занудством ровно до того момента, когда кто‑то умножит ваш процент на годовую выручку.

Справедливости ради, у теста по сырым средним в этом же примере своя проблема: весь эффект держится на двухстах пользователях из двухсот тысяч, поэтому точечная оценка +6% крайне неустойчива, и на другой выборке она легко окажется +2% или +11%. Значимость есть, воспроизводимость сомнительная. Так что честный вывод здесь не «берите среднее», а «у вас эффект целиком в хвосте, и прежде чем считать проценты, надо решить, это находка или случайность» — а это уже вопрос к дизайну эксперимента, а не к выбору преобразования.

И еще одно, о чем в статьях про логарифм в A/B почти никогда не пишут, а на практике это первое, обо что спотыкаешься. В моей симуляции выручка у всех положительная, а в реальном тесте выручка на пользователя — это в основном нули. платит хорошо если каждый десятый, и логарифм на такой метрике не берется вообще, а log1p превращает всю массу нулей в нули и все равно оставляет распределение с гигантским горбом в точке ноль, никакой нормальности там не появится.

Рабочий вариант в этой ситуации — разложить метрику на две: конверсию в платеж (обычная пропорция, тестируется как есть) и средний чек среди заплативших (вот тут логарифм уже уместен). Тестируете отдельно, а потом собираете обратно, и заодно получаете более осмысленный ответ на вопрос «что именно изменилось», потому что «выручка выросла» и «платить стали чаще» — это разные новости для продукта.

6. log1p, ноль и единицы измерения

Логарифма нуля не существует, а нули в данных есть всегда: клиент без покупок, день без показов. Стандартный обход — np.log1p(x), то есть log(1+x), и выглядит это безобидно ровно до того момента, пока кто‑нибудь не поменяет единицы измерения.

vals = np.array([0., 10., 100., 1000., 10000.])
print("в рублях:      ", np.round(np.log1p(vals), 3))
print("в тыс. рублей: ", np.round(np.log1p(vals/1000), 3))
print(f"отношение log1p(100)/log1p(10): в рублях {np.log1p(100)/np.log1p(10):.2f}, "
      f"в тысячах {np.log1p(0.1)/np.log1p(0.01):.2f}")
в рублях:       [0.    2.398 4.615 6.909 9.21 ]
в тыс. рублей:  [0.    0.01  0.095 0.693 2.398]
отношение log1p(100)/log1p(10): в рублях 1.92, в тысячах 9.58

Одни и те же деньги, одна и та же функция, а отношение между значениями отличается в пять раз.

Причина в том, что единица внутри log(1+x) имеет размерность, хотя мы об этом не думаем. Пока x много больше единицы, log1p ведет себя как обычный логарифм. Когда x порядка единицы и меньше, log1p(x) ≈ x, то есть преобразование становится линейным и логарифмом быть перестает. А что считать «меньше единицы» — зависит от того, в рублях вы мерите или в миллионах.

Практически: если в фичах модели есть log1p, единицы измерения зафиксированы навсегда. Кто‑то поменял рубли на тысячи — модель молча поехала, метрики просели, разбираться будет тот, кто последним трогал пайплайн.

Что делать:

  • зафиксировать единицы и написать это в описании витрины большими буквами;

  • если нулей мало, считать логарифм только на положительных, а факт нуля вынести в отдельный бинарный признак (некрасиво, зато без сюрпризов);

  • если нужно преобразование, которое работает и на нулях, и на отрицательных, посмотреть в сторону arcsinh, а если хочется подобрать степень преобразования по данным, то Box‑Cox и Yeo‑Johnson (второй умеет в отрицательные);

  • не подставлять «маленькую константу» наугад, результат от нее зависит сильнее, чем кажется.

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

7. Обратно нельзя: неравенство Йенсена

Самая дорогая ошибка из всех, и она регулярно доезжает до прода.

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

y = rng.lognormal(8, 1.2, 100_000)

print(f"настоящее среднее        {y.mean():,.0f}")
print(f"exp(среднее логарифмов)  {np.exp(np.log(y).mean()):,.0f}")
настоящее среднее        6,191
exp(среднее логарифмов)  2,991

Прогноз занижен на 51.7%, то есть больше чем вдвое.

Виновато неравенство Йенсена: экспонента выпуклая, поэтому exp(среднее) всегда меньше, чем среднее(exp). Возвращаясь через экспоненту, вы получаете медиану, а не среднее, ровно то, о чем шла речь в первом разделе.

Чинится поправкой Дуана (smearing estimator): домножить на среднее от экспонент остатков.

resid = np.log(y) - np.log(y).mean()
smear = np.exp(resid).mean()
print(f"с поправкой Дуана        {np.exp(np.log(y).mean()) * smear:,.0f}")
с поправкой Дуана        6,191

Совпало с точностью до рубля, три строчки кода против занижения вдвое. Если остатки нормальные, можно и аналитически, домножив на exp(σ²/2): поправка Дуана нормальности не требует, поэтому удобно брать ее

Еще про ограничения: пример выше нарочно вырожденный, там модель вообще без предикторов, отсюда и идеальное совпадение до рубля, на настоящей регрессии поправка убирает основную часть смещения, но не всю, и работает она в предположении, что дисперсия остатков в логарифмах одинакова по всей выборке. Если у вас гетероскедастичность (а на деньгах она обычная история, у крупных клиентов разброс шире), единый множитель для всех будет врать, и надо считать поправку хотя бы по группам.

8. Логарифмическая шкала на графике — это смена вопроса

Логарифмическая ось Y — это не «чтобы все влезло», это другой вопрос к данным.

Обычная шкала показывает абсолютный прирост, логарифмическая показывает темп, и постоянный процентный рост на ней выглядит прямой линией, и любое отклонение от прямой видно сразу.

d = np.arange(365)
a = 100 * 1.01**d   # ровно 1% в день, темп ни разу не меняется

print(f"{a[0]:.0f} -> {a[364]:,.0f}")
print(f"прирост за первые 30 дней:    {a[30]-a[0]:,.0f}")
print(f"прирост за последние 30 дней: {a[364]-a[334]:,.0f}")
100 -> 3,741
прирост за первые 30 дней:    35
прирост за последние 30 дней: 965

Темп все 365 дней одинаковый, а абсолютный прирост отличается почти в 28 раз (965 против 35), на обычной шкале вы увидите мертвый штиль в начале и взрывной рост в конце, хотя не изменилось ровным счетом ничего.

Слева хочется рассказывать про хоккейную клюшку и продуктовый прорыв. Справа видно, что это прямая, то есть не произошло ровно ничего, кроме сложного процента.

Когда что брать:

  • сравниваете темпы, ловите замедление роста, кладете на один график серии разного масштаба — логарифмическая;

  • считаете, сколько именно денег добавилось — обычная;

  • показываете график человеку, который видит его впервые — обычная, а логарифмическую только с явной подписью оси, иначе часть аудитории прочитает его неправильно.

9. Когда логарифм не нужен

Данные и так симметричные. Возраст, рост, доля, температура, NPS, логарифм тут ничего не улучшит, а интерпретацию сломает:

z = rng.normal(50, 10, 100_000)
print(f"среднее {z.mean():.2f}, медиана {np.median(z):.2f}, "
      f"геометрическое {np.exp(np.log(z[z>0]).mean()):.2f}")
среднее 50.02, медиана 50.02, геометрическое 48.97

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

Ответ нужен в рублях. Если вопрос звучит «сколько денег», логарифм — промежуточный шаг, и обратно надо возвращаться аккуратно (см. раздел 7).

Хвост — это не шум, а суть. Если вас интересуют именно крупные клиенты, логарифм их «успокоит» ровно тогда, когда нужно наоборот.

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

Модели на деревьях. Бустингу и лесу монотонное преобразование признака безразлично, сплиты получаются те же самые, так что логарифмировать фичи для CatBoost смысла нет. А вот с целевой переменной все наоборот и не про распределение: логарифм таргета меняет функцию потерь. MSE в логарифмах — это, грубо говоря, оптимизация относительной ошибки, и модель перестает гнаться за крупными клиентами в ущерб остальным. Иногда это ровно то, что нужно бизнесу, иногда прямо противоположное, но решается это осознанно, а не «чтобы распределение было красивое». И обратно потом возвращаемся через раздел 7, а не голой экспонентой.

Итоговая табличка

Что

Зачем

Ключевое

Где подстава

Логарифм скошенной метрики

стабилизировать дисперсию, приблизить к симметрии

np.log(x)

нули и отрицательные, нормальность не гарантирована

Среднее геометрическое

«типичное» значение

exp(mean(log x))

для логнормального это медиана, а не среднее

Декомпозиция роста

вклад множителей

Δlog R = Σ Δlog xᵢ, в рублях через LMDI

вклады бывают >100% и <0

log‑log регрессия

эластичность

коэффициент =% на%

только положительные, и это не причинность

log‑linear регрессия

темп прироста

exp(b) − 1

не 100·b при больших b

Логарифм в A/B

рост мощности

меняется гипотеза, нули не берутся

log1p

обойти нули

log(1+x)

зависит от единиц измерения

Обратное преобразование

вернуться в рубли

поправка Дуана

занижение на десятки процентов

Логарифмическая шкала

темпы вместо абсолюта

читатель не заметит подписи

Итог

Логарифм — это не «сделать большие числа маленькими», это переход из мира, где величины перемножаются, в мир, где они складываются. Все, что перечислено выше, растет из этого одного факта: и эластичность, и точная декомпозиция, и рост мощности в тесте, и то самое занижение вдвое, с которого я начала.

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

Если у вас есть свои случаи, когда логарифм спас или наоборот все испортил, напишите в комментариях, интересно собрать больше примеров:).

📈 Прошлая статья на близкую тему: Зачем аналитику математика
✔️ Больше про будни и задачи аналитика данных в моем тг канале 🌸Таня и Данные📊