На предыдущем месте работы я разбирала, почему помесячный прогноз выручки стабильно недобирает. Не скачет, а именно недобирает: факт всегда выше прогноза, из месяца в месяц, примерно в одну и ту же сторону.
Стабильное смещение — это уже не шум, это баг, поэтому я полезла в модель. Модель была нормальная, на логарифме выручки, обученная коллегой, метрики на валидации приличные, так что сначала я смотрела вообще не туда. Ошибка нашлась в последней строчке пайплайна: прогноз в логарифмах возвращали в рубли через 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, а не голой экспонентой.
Итоговая табличка
Что | Зачем | Ключевое | Где подстава |
|---|---|---|---|
Логарифм скошенной метрики | стабилизировать дисперсию, приблизить к симметрии |
| нули и отрицательные, нормальность не гарантирована |
Среднее геометрическое | «типичное» значение |
| для логнормального это медиана, а не среднее |
Декомпозиция роста | вклад множителей |
| вклады бывают >100% и <0 |
log‑log регрессия | эластичность | коэффициент =% на% | только положительные, и это не причинность |
log‑linear регрессия | темп прироста |
| не |
Логарифм в A/B | рост мощности | – | меняется гипотеза, нули не берутся |
| обойти нули |
| зависит от единиц измерения |
Обратное преобразование | вернуться в рубли | поправка Дуана | занижение на десятки процентов |
Логарифмическая шкала | темпы вместо абсолюта | – | читатель не заметит подписи |
Итог
Логарифм — это не «сделать большие числа маленькими», это переход из мира, где величины перемножаются, в мир, где они складываются. Все, что перечислено выше, растет из этого одного факта: и эластичность, и точная декомпозиция, и рост мощности в тесте, и то самое занижение вдвое, с которого я начала.
Практический вывод у меня один, и он не про математику: логарифм в коде выглядит как техническая деталь, одна функция в одну строку, и ревьюится соответственно, а на самом деле это смена системы координат для всего, что идет дальше по пайплайну. Поэтому если в вашей витрине или модели есть log, где‑то ниже по течению обязательно должно быть явное место, где вы из этой системы координат выходите обратно, и это место стоит проверять отдельно.
Если у вас есть свои случаи, когда логарифм спас или наоборот все испортил, напишите в комментариях, интересно собрать больше примеров:).
📈 Прошлая статья на близкую тему: Зачем аналитику математика
✔️ Больше про будни и задачи аналитика данных в моем тг канале 🌸Таня и Данные📊
