Модель обучается, loss падает, метрики растут. На какой‑то эпохе loss внезапно становится nan и больше не восстанавливается, как бы вы ни понижали learning rate. Или инференс на проде иногда возвращает вероятность ровно 1.0 для класса, которого в обучающей выборке почти не было, а в логах при этом тихо мелькает RuntimeWarning: overflow encountered in exp. Код не менялся, данные те же, гиперпараметры те же. Просто в какой‑то момент промежуточное число вышло за границу того, что тип float умеет хранить, и дальше вся арифметика поехала.

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

Где у float заканчиваются числа

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

  • Первая — диапазон: float32 представляет величины примерно до 3.4·10³⁸, дальше inf, а снизу наименьшее нормальное число около 1.18·10⁻³⁸, ещё ниже денормализация и ноль.

  • Вторая — точность: float32 хранит порядка семи значащих десятичных цифр, float64 — около пятнадцати‑шестнадцати, остальное отбрасывается.

import numpy as np

np.finfo(np.float32).max     # 3.4028235e+38  — дальше inf
np.finfo(np.float32).tiny    # 1.1754944e-38  — наименьшее нормальное
np.finfo(np.float32).eps     # 1.1920929e-07  — машинный эпсилон
np.finfo(np.float16).max     # 65504.0

Машинный эпсилон — наименьшее число, ещё отличимое от единицы при сложении, и для float32 он около 1.2·10⁻⁷. Всё, что меньше, при сложении с единицей теряется бесследно:

np.float32(1.0) + np.float32(1e-8) == np.float32(1.0)   # True — слагаемое исчезло

Половинная точность, в которой сегодня по умолчанию идёт значительная часть обучения, ужимает обе границы радикально: float16 переполняется уже на 65504, а его наименьшее нормальное число около 6·10⁻⁵.

bfloat16 устроен иначе, он сохраняет широкий диапазон экспоненты float32, жертвуя мантиссой, поэтому почти не переполняется, но хранит всего два‑три значащих знака.

Дальше станет понятно, почему именно переход на половинную точность вытаскивает наружу ошибки, незаметные в float64-ноутбуке.

Softmax переполняется на больших логитах

Softmax превращает вектор логитов в распределение: каждый элемент возводится в экспоненту и делится на сумму всех экспонент. Логиты на выходе сети ничем не ограничены, и стоит одному дорасти примерно до 89, как exp в float32 упирается в потолок диапазона, потому что exp(89) уже за 3.4·10³⁸. Деление inf на inf даёт nan, и одна переполнившаяся экспонента отравляет весь батч.

def softmax_naive(x):
    e = np.exp(x)
    return e / e.sum()

x = np.array([1000.0, 1001.0, 1002.0], dtype=np.float32)
softmax_naive(x)
# RuntimeWarning: overflow encountered in exp
# array([nan, nan, nan])

Лекарство опирается на свойство самой функции: softmax не меняется при вычитании из всех логитов одной и той же константы, потому что общий множитель в числителе и знаменателе сокращается. Вычитаем максимум — наибольший аргумент экспоненты становится нулём, exp(0) равен единице, переполнение исключено по построению. Наименьшие логиты после сдвига могут уйти в underflow и обнулиться, но член, настолько малый относительно максимума, и так не вносит заметного вклада в сумму.

def softmax_stable(x):
    z = x - x.max()      # максимум становится нулём
    e = np.exp(z)
    return e / e.sum()

softmax_stable(x)        # array([0.09003057, 0.24472848, 0.66524094])

Тот же приём — вынести максимум за скобку — дальше встретится под именем log‑sum‑exp и окажется сквозным мотивом всей темы.

Произведение вероятностей уходит в ноль

Целый класс моделей перемножает вероятности: наивный байес считает произведение по признакам, forward‑алгоритм в скрытых марковских моделях — по шагам времени, языковая модель — по токенам последовательности. Каждый множитель меньше единицы, и произведение нескольких сотен таких множителей проваливается ниже наименьшего представимого числа. Вероятность 0.01 в двухсотой степени — это 10⁻⁴⁰⁰, недостижимое даже для float64 с нижней границей около 10⁻³⁰⁸.

p = np.float32(0.01)
p ** 200                 # 0.0 — underflow, 10^-400 непредставимо

После обнуления логарифм правдоподобия даёт -inf, а нормировка делит на ноль и возвращает nan. Выход — не хранить вероятности в линейном пространстве вообще: логарифм произведения равен сумме логарифмов, а сумма умеренно отрицательных чисел никуда не убегает.

log_p = np.log(0.01)
200 * log_p              # -921.034... — в рабочем диапазоне

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

Сложность возникает, когда вероятности, которые вы держите в логарифмах, нужно сложить, а не перемножить, — например, при маргинализации или нормировке. У вас есть log(a) и log(b), а нужен log(a+b), и наивный путь через возведение обратно в экспоненту возвращает ровно ту проблему переполнения, от которой вы только что ушли. Решение — снова вынести максимум:

log(Σ exp(lᵢ)) = m + log(Σ exp(lᵢ − m)), где m — максимум среди lᵢ

def logsumexp(l):
    m = l.max()
    return m + np.log(np.exp(l - m).sum())

После вычитания максимума наибольшая экспонента равна единице, переполнение исключено, а недопредставленные члены безопасно обнуляются. Все это не пишут руками — есть scipy.special.logsumexp, torch.logsumexp и np.logaddexp для пары значений.

log_softmax считается напрямую, а не как log от softmax

Кросс‑энтропийная функция потерь требует логарифм вероятности правильного класса, то есть log(softmax(x)). Наивная последовательность — сначала softmax, потом логарифм — теряет точность дважды: для уверенного класса softmax выдаёт число, близкое к единице, и логарифм около нуля съедает значащие цифры, а для маловероятного класса значение стоит у границы underflow, и логарифм усиливает уже накопленную ошибку. Аналитическое свёртывание убирает промежуточный шаг целиком:

log softmax(x)ᵢ = xᵢ − log Σⱼ exp(xⱼ) = xᵢ − logsumexp(x)

Остаётся только разность логита и устойчиво посчитанного log‑sum‑exp, без деления и без промежуточного крошечного числа. Это и есть log_softmax.

def log_softmax(x):
    return x - logsumexp(x)

Поэтому функции потерь в фреймворках принимают сырые логиты, а не вероятности: torch.nn.CrossEntropyLoss и F.cross_entropy внутри делают log_softmax и отрицательное логарифмическое правдоподобие одним устойчивым проходом.

Отсюда же одна из самых частых ошибок новичка — поставить softmax последним слоем сети и подать результат в CrossEntropyLoss: лосс применит log_softmax ещё раз, поверх уже готовых вероятностей, и обучение получит дважды искажённый градиент без единого сообщения об ошибке. Для бинарного случая работает та же логика: BCEWithLogitsLoss объединяет сигмоиду и кросс‑энтропию устойчиво, а отдельные sigmoid и BCELoss — нет.

Вычитание близких чисел теряет значащие цифры

До сих пор речь шла о диапазоне, числа улетали в inf или в ноль. Вторая граница, точность, ломается тише и потому опаснее. Когда вы вычитаете две почти равные величины, старшие значащие цифры взаимно уничтожаются, и в результате остаётся только шум из младших разрядов, которые и так хранились неточно. Это называется катастрофическим сокращением.

Самый частый случай в ML — выражения вида log(1 − p) для вероятностей, близких к единице. При достаточно близком к единице p сумма 1 + x для малого x округляется обратно к единице, и логарифм возвращает ноль там, где ответ заведомо ненулевой:

x = np.float32(1e-10)
np.log(np.float32(1.0) + x)   # 0.0 — 1 + 1e-10 округлилось к 1.0
np.log1p(x)                   # ~1e-10 — верно

Парные функции log1p(x) и expm1(x) считают log(1 + x) и exp(x) − 1 точно для малых x, не теряя разрядов на вычитании единицы, и встречаются всюду, где есть устойчивые сигмоиды и softplus, расчёт энтропии и KL‑дивергенции.

Тот же эффект бьёт по дисперсии, посчитанной как E[x²] − (E[x])². Формула верна, но при большом среднем относительно разброса оба слагаемых получаются огромными и почти равными, разность теряет почти все значащие цифры, и не спасает даже float64: на величинах порядка 10⁹ наивная формула возвращает отрицательную дисперсию, которой по определению не существует. np.var использует устойчивую двухпроходную схему, а в самописной нормализации или в накоплении бегущей статистики, как в батч‑нормализации, спасает предварительное центрирование признаков — после вычитания среднего амплитуда падает и сокращаться становится нечему.

Как поймать, где появляется nan

Главная сложность диагностики в том, что nan заразен: любая операция с ним возвращает nan, поэтому к моменту, когда вы увидели его в значении loss, исходная операция давно осталась позади и спряталась за сотней последующих. Искать нужно не там, где nan обнаружен, а там, где он родился.

Первый сигнал появляется раньше самого nan — это предупреждения вида overflow encountered in exp, divide by zero encountered in log и invalid value encountered, которые numpy печатает до того, как результат окончательно испортится; их стоит превращать в исключения через np.seterr(over='raise', invalid='raise'), а не пролистывать.

Дальше помогает проверка конечности на границах: np.isfinite и torch.isfinite после подозрительных операций — экспонент, логарифмов, делений — локализуют шаг, на котором значения перестают быть числами. В PyTorch для обратного прохода есть torch.autograd.set_detect_anomaly(True), который указывает на операцию в графе, породившую nan в градиенте, и заметно замедляет вычисления, поэтому включается только на время отладки.

Отдельно стоит причина, по которой логиты вообще дорастают до переполнения, — расходящиеся градиенты, и здесь работает клиппинг через torch.nn.utils.clip_grad_norm_, удерживающий норму градиента и не дающий весам разогнаться до значений, при которых exp выходит за диапазон.

Наконец, переход на половинную точность резко повышает вероятность переполнения, потому что у float16 потолок всего 65504, и именно поэтому смешанная точность не сводится к простому приведению типов, а сопровождается масштабированием лосса, которое сдвигает мелкие градиенты подальше от underflow, и хранением мастер‑копии весов в float32. bfloat16 решает ту же задачу с другой стороны — сохраняет широкий диапазон ценой точности, поэтому переполняется реже, но требует большей аккуратности там, где важны значащие цифры.

В итоге

У числа с плавающей точкой две границы — диапазон и точность, и математика ML упирается в обе: экспонента в softmax вылетает в overflow на больших логитах, произведения вероятностей проваливаются в underflow, а вычитание близких величин теряет значащие цифры в катастрофическом сокращении.

Сквозное лекарство одно — считать в логарифмах и выносить максимум за скобку, что даёт log-sum-exp, log_softmax и привычку подавать в функции потерь логиты, а не вероятности; для разностей с единицей есть log1p и expm1, для статистик — устойчивые двухпроходные схемы.

Если после статьи хочется не только понять, откуда берутся nan, inf и переполнения, но и аккуратнее работать с ML‑моделями на практике, присмотритесь к бесплатным урокам:

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

А если хотите выбрать не только ML‑темы, загляните в дайджест бесплатных уроков июня: там собрали вебинары по разработке, инфраструктуре, аналитике, ИИ и другим IT‑направлениям.

Комментарии (0)