Перейти к содержимому
Ступень 11.3 Вероятность и теория информации

Правдоподобие, softmax и cross-entropy

Как языковая модель превращает свои «сырые» оценки в вероятности и почему её учат минимизировать именно cross-entropy — с ручным счётом на маленьких примерах.

УРОКstage-1/1-3-probability/02-mle-softmax.mdx1 ч 40 минУрок 2 из 4
После урока вы сможете
  • Посчитать softmax и cross-entropy вручную на векторе из трёх логитов и объяснить, что делает каждый шаг
  • Объяснить, что такое метод максимального правдоподобия и почему loss языковой модели — это отрицательное лог-правдоподобие
  • Реализовать численно устойчивые softmax и cross-entropy на чистом Python
  • Предсказать начальный loss свежей LLM, зная только размер словаря

На последнем шаге любая большая языковая модельбольшая языковая модель (БЯМ, LLM)EN large language model (LLM)В китайском «большая модель» («да мосин») — разговорное общее название для LLM и вообще foundation models. выдаёт вектор чисел — по одному на каждый токен словаря. У Qwen это около 152 тысяч чисел, у DeepSeek-V3 — около 129 тысяч. Эти числа называются логитамилогитEN logitСтрого — логарифм шансов log(p / (1 − p)). В нейросетях логитами называют любые выходы до softmax или сигмоиды. (logits; вы встречали их в уроке 1.0.3). Они могут быть любыми: отрицательными, огромными, нулевыми.

Чтобы выбрать следующий токен, логиты нужно превратить в вероятности — в категориальное распределение из прошлого урока. Это делает функция softmax. А чтобы модель училась, нужно измерить, насколько её вероятности расходятся с правдой. Это делает перекрёстная энтропияперекрёстная энтропияEN cross-entropy. На этих двух функциях стоит обучение каждой языковой модели в мире, от GPT-2 до DeepSeek-V4.

flowchart LR
A["Контекст:<br/>«Карп прыгает через»"] --> B[Трансформер]
B --> C["Логиты<br/>z ∈ ℝ^V"]
C --> D[softmax]
D --> E["Вероятности<br/>p(«врата») = 0.71<br/>p(«реку») = 0.12 …"]
E --> F["Loss = −log p(правильный токен)"]
Последние шаги языковой модели

В уроке 1.0.3 вы уже прочитали формулы softmax и cross-entropy «по символам». Теперь разберёмся, почему они именно такие и как считать их так, чтобы компьютер не сломался на больших числах.

Softmax делает три вещи:

  1. Делает все числа положительными — с помощью экспоненты eze^{z} (она всегда больше нуля, урок 1.0.1).
  2. Нормирует их, чтобы сумма была равна 1.
  3. Усиливает различия: если логит больше на 1, вероятность больше в e≈2.7e \approx 2.7 раза.
softmax(z)i=ezi∑j=1Vezj\mathrm{softmax}(z)_i = \frac{e^{z_i}}{\sum_{j=1}^{V} e^{z_j}}

Читаем: «вероятность ii-го токена — это ee в степени его логита, делённое на сумму таких экспонент по всем VV токенам словаря». Знаменатель один на всех — он и делает сумму вероятностей равной 1.

Пример по шагам. Логиты z=(1,2,3)z = (1, 2, 3).

ШагТокен 1Токен 2Токен 3
логит ziz_i123
экспонента ezie^{z_i}2.7187.38920.086сумма 30.193
вероятность ezi/30.193e^{z_i} / 30.1930.0900.2450.665сумма 1

Самый большой логит забирает большую часть вероятности, но остальным тоже кое-что достаётся. Проверьте свойство 3: 0.245/0.090≈2.720.245 / 0.090 \approx 2.72 и 0.665/0.245≈2.720.665 / 0.245 \approx 2.72 — соседние логиты отличаются на 1, вероятности — в ee раз.

Та же формула в коде — «школьная» версия из урока 1.0.3:

import math
def softmax(z):
exps = [math.exp(v) for v in z] # шаг 1: e^{z_i} — все числа стали положительными
s = sum(exps) # знаменатель: сумма экспонент, считаем один раз
return [e / s for e in exps] # шаг 2: делим каждую на сумму
p = softmax([1.0, 2.0, 3.0])
print([round(x, 3) for x in p], round(sum(p), 3)) # [0.09, 0.245, 0.665] 1.0

Эта версия правильная, но на больших логитах ломается. Как именно и как это чинят — в разделе «Ловушка: переполнение».

Откуда вообще берутся «правильные» вероятности? Начнём с самого простого случая. Допустим, мы подбросили монету 10 раз и получили 7 орлов и 3 решки. Какая вероятность орла pp «самая правдоподобная»?

Правдоподобие (likelihood) — это вероятность увидеть именно наши данные, если вероятность орла равна pp. Броски независимы (урок 1.3.1), поэтому вероятности перемножаются: семь раз pp и три раза 1−p1 - p:

L(p)=p7(1−p)3L(p) = p^7 (1-p)^3

Подставим несколько значений:

pp0.30.50.60.70.80.9
L(p)L(p)0.0000750.000980.001790.002220.001680.00048

Максимум — при p=0.7p = 0.7, ровно доле орлов в данных. Это и есть метод максимального правдоподобия (MLE): выбрать параметры, при которых наблюдённые данные наиболее вероятны. Компьютер найдёт максимум перебором:

ps = [i / 100 for i in range(1, 100)] # p = 0.01, 0.02, …, 0.99
best = max(ps, key=lambda p: p ** 7 * (1 - p) ** 3) # p с наибольшим правдоподобием
print(best) # 0.7

(Если хочется строго: в максимуме производная равна нулю, урок 1.2.1. Производная ln⁡L(p)=7ln⁡p+3ln⁡(1−p)\ln L(p) = 7 \ln p + 3 \ln(1-p) равна 7p−31−p\frac{7}{p} - \frac{3}{1-p}, и она обращается в ноль при 7(1−p)=3p7(1 - p) = 3p, то есть при p=0.7p = 0.7.)

Зачем логарифм. Правдоподобие текста — произведение тысяч вероятностей токенов (цепное правило вероятностей из урока 1.3.1), и оно быстро становится машинным нулём (урок 1.0.2). Логарифм превращает произведение в сумму и не меняет точку максимума: ln⁡L(p)\ln L(p) тоже максимален при p=0.7p = 0.7, потому что логарифм — растущая функция. А поскольку оптимизаторы привыкли минимизировать (градиентный спуск идёт вниз, урок 1.0.3), ставят минус:

L(θ)=−∑tlog⁡pθ(xt∣x<t)\mathcal{L}(\theta) = -\sum_{t} \log p_\theta(x_t \mid x_{<t})

Читаем: «loss при параметрах θ\theta — это минус сумма по всем позициям tt логарифмов вероятности, которую модель дала настоящему токену xtx_t, зная все предыдущие токены x<tx_{<t}». Это отрицательное лог-правдоподобие (NLLотрицательное лог-правдоподобие (NLL)EN negative log-likelihood (NLL)−log P(данные | модель), обычно усреднённое по токенам. Для языковой модели совпадает с cross-entropy.) текста. Языковую модель учат именно так: для каждой позиции в тексте берём вероятность, которую модель дала настоящему следующему токену, логарифмируем, ставим минус и усредняем.

Числа. Пример из урока 1.3.1: после «Карп» модель дала слову «прыгает» вероятность 0.2, затем слову «через» — 0.5, затем слову «врата» — 0.71. Минус логарифмы: −ln⁡0.2≈1.609-\ln 0.2 \approx 1.609, −ln⁡0.5≈0.693-\ln 0.5 \approx 0.693, −ln⁡0.71≈0.342-\ln 0.71 \approx 0.342. Сумма 2.645, среднее по трём токенам — 0.882. Самый большой вклад дал самый «неожиданный» для модели токен.

import math
probs = [0.2, 0.5, 0.71] # вероятности настоящих токенов
nll = -sum(math.log(p) for p in probs) # минус сумма логарифмов
print(round(nll, 3), round(nll / len(probs), 3)) # 2.645 0.882 — сумма и среднее на токен

перекрёстная энтропияперекрёстная энтропияEN cross-entropy между «правильным» распределением pp и предсказанием модели qq:

H(p,q)=−∑ipilog⁡qiH(p, q) = -\sum_i p_i \log q_i

В языковой модели правильный ответ один (следующий токен tt), то есть pp — это one-hot кодированиеone-hot кодирование (унитарный код)EN one-hot encodingВектор длины V из нулей с единицей в позиции номера токена. Все one-hot векторы попарно ортогональны. вектор: единица на месте tt, нули на остальных (урок 1.0.3). Тогда от суммы остаётся одно слагаемое:

H=−log⁡qt=−log⁡softmax(z)tH = -\log q_t = -\log \mathrm{softmax}(z)_t

Это ровно отрицательное лог-правдоподобие правильного токена. Пример из урока 1.0.3: логиты (2,1,0.1)(2, 1, 0.1) дают вероятности q=(0.659,0.242,0.099)q = (0.659, 0.242, 0.099); если правильный — первый токен, то H=−ln⁡0.659≈0.417H = -\ln 0.659 \approx 0.417.

В коде обучения LLM на PyTorch вы всегда видите F.cross_entropy(logits, targets). Эта функция принимает логиты, а не вероятности, и номера правильных токенов; softmax и логарифм она делает сама, устойчивым способом, который мы разберём через пару разделов.

Как читать значение loss:

Вероятность правильного токенаLoss −ln⁡qt-\ln q_t
1.0 (модель уверена и права)0
0.50.69
0.12.30
1 / 152 000 (полное незнание)11.93

Perplexity (перплексияперплексияEN perplexity) — это просто elosse^{\text{loss}} (урок 1.0.2). При loss = 2.3 perplexity ≈ 10: модель в среднем «колеблется между 10 равновероятными вариантами».

Логиты в настоящих моделях бывают большими, а в пониженной точности переполнение наступает намного раньше: в FP16 экспонента переполняется уже при z≈11.1z \approx 11.1 (урок 1.0.2). Возьмём z=(1000,1001,1002)z = (1000, 1001, 1002) и попробуем «школьный» softmax. Самая большая экспонента e1002e^{1002} не помещается во float64 (предел около 1030810^{308}, урок 1.0.2), и результат зависит от того, чем считать:

import math
math.exp(1000) # OverflowError: math range error — чистый Python падает сразу
import numpy as np # запускайте отдельно: ошибка выше остановила бы программу
z = np.array([1000.0, 1001.0, 1002.0])
e = np.exp(z) # RuntimeWarning: overflow encountered in exp
print(e) # [inf inf inf]
print(e / e.sum()) # [nan nan nan] — inf / inf = nan, «не число»

NumPy не падает, а молча выдаёт nan, и дальше nan расползается по всем вычислениям. Это хуже явной ошибки.

Спасает простое свойство: softmax не меняется, если ко всем логитам прибавить (или вычесть) одну и ту же константу mm:

ezi−m∑jezj−m=ezie−me−m∑jezj=softmax(z)i\frac{e^{z_i - m}}{\sum_j e^{z_j - m}} = \frac{e^{z_i} e^{-m}}{e^{-m} \sum_j e^{z_j}} = \mathrm{softmax}(z)_i

Читаем: ezi−m=ezi⋅e−me^{z_i - m} = e^{z_i} \cdot e^{-m} (правило степеней из урока 1.0.2), и общий множитель e−me^{-m} есть и в числителе, и в каждом слагаемом знаменателя — он сокращается. Если взять m=max⁡(z)m = \max(z), то самая большая экспонента равна e0=1e^0 = 1, а остальные меньше 1 — переполнения нет.

Числа. Для z=(1000,1001,1002)z = (1000, 1001, 1002) берём m=1002m = 1002 и получаем сдвинутые логиты (−2,−1,0)(-2, -1, 0). Их экспоненты (0.135,0.368,1)(0.135, 0.368, 1) — безобидные числа, а ответ (0.090,0.245,0.665)(0.090, 0.245, 0.665) совпадает с примером (1,2,3)(1, 2, 3): эти логиты отличаются от наших на одну и ту же константу 999.

Для cross-entropy есть ещё одна ловушка — log⁡0\log 0. Возьмём z=(1000,0)z = (1000, 0) и правильный второй токен. После сдвига его вероятность равна e−1000/(1+e−1000)e^{-1000} / (1 + e^{-1000}), а e−1000e^{-1000} во float64 округляется до 0.0. Вероятность стала нулём, и math.log(0.0) падает с ValueError: math domain error (урок 1.0.2). Поэтому логарифм softmax считают сразу, не вычисляя саму вероятность, через log-sum-exp:

−log⁡softmax(z)t=m+log⁡∑jezj−m⏟logsumexp(z)−zt-\log \mathrm{softmax}(z)_t = \underbrace{m + \log \sum_j e^{z_j - m}}_{\mathrm{logsumexp}(z)} - z_t

Читаем: logsumexp(z)\mathrm{logsumexp}(z) — «логарифм суммы экспонент» log⁡∑jezj\log \sum_j e^{z_j}, посчитанный безопасно: максимум вынесли наружу, а под логарифмом осталась сумма чисел от 0 до 1, причём хотя бы одно из них равно 1. Поэтому под логарифмом никогда не бывает нуля. Откуда формула: log⁡softmax(z)t=zt−log⁡∑jezj\log \mathrm{softmax}(z)_t = z_t - \log \sum_j e^{z_j} (логарифм частного — разность логарифмов, урок 1.0.2), а log⁡∑jezj=m+log⁡∑jezj−m\log \sum_j e^{z_j} = m + \log \sum_j e^{z_j - m} — тот же вынос eme^m за скобку.

Числа. Для z=(1000,0)z = (1000, 0): m=1000m = 1000, под логарифмом e0+e−1000≈1e^0 + e^{-1000} \approx 1, значит logsumexp(z)≈1000\mathrm{logsumexp}(z) \approx 1000. Loss для правильного второго токена: 1000−0=10001000 - 0 = 1000 — огромный, но конечный, и обучение может его уменьшать. Для первого токена: 1000−1000=01000 - 1000 = 0.

Величину zi−logsumexp(z)z_i - \mathrm{logsumexp}(z) называют log-softmax — это логарифмы вероятностей, посчитанные без самих вероятностей. Для z=(2,1,0.1)z = (2, 1, 0.1) log-softmax равен (−0.417,−1.417,−2.317)(-0.417, -1.417, -2.317), и cross-entropy для первого токена — просто минус первое число, 0.417. В PyTorch это F.log_softmax; вы встретите её в уроке 1.3.3.

Почему именно cross-entropy: градиент «p минус ответ»

Заголовок раздела «Почему именно cross-entropy: градиент «p минус ответ»»

Обучение двигает логиты по градиенту loss (урок 1.2.3). У связки «softmax + cross-entropy» он получается удивительно простым. Производная loss по логиту ziz_i равна

∂H∂zi=pi−yi\frac{\partial H}{\partial z_i} = p_i - y_i

где p=softmax(z)p = \mathrm{softmax}(z), а yy — one-hot вектор правильного ответа. Читаем: «вероятность, которую модель дала токену, минус то, что должно было быть (1 для правильного, 0 для остальных)». Вывод — несколько строк цепного правила из урока 1.2.2, он есть в разделе 3.4 d2l (ссылка в источниках).

Числа. z=(2,1,0.1)z = (2, 1, 0.1), правильный — первый токен, p=(0.659,0.242,0.099)p = (0.659, 0.242, 0.099). Градиент: (0.659−1,  0.242−0,  0.099−0)=(−0.341,0.242,0.099)(0.659 - 1, \; 0.242 - 0, \; 0.099 - 0) = (-0.341, 0.242, 0.099). Шаг градиентного спуска идёт против градиента: логит правильного токена растёт, остальные падают — тем сильнее, чем больше вероятности им досталось по ошибке. Каждая компонента лежит между −1 и 1, поэтому градиент не «взрывается», даже когда модель уверенно ошибается. Сравните с производной −1/p-1/p из урока 1.2.1: по самой вероятности толчок при p=0.01p = 0.01 равен −100, а по логиту — не больше 1 по модулю. Softmax и логарифм «гасят» друг друга, и обучение получается устойчивым. Это одна из главных причин, почему классификаторы и LLM учат именно на cross-entropy.

Проверим формулу центральной разностью из урока 1.2.1 (логиты маленькие, поэтому «школьный» softmax здесь безопасен):

import math
def softmax(z):
exps = [math.exp(v) for v in z]
s = sum(exps)
return [e / s for e in exps]
def ce(z, t):
return -math.log(softmax(z)[t]) # cross-entropy «в лоб»
z, t = [2.0, 1.0, 0.1], 0
p = softmax(z)
analytic = [p[i] - (1 if i == t else 0) for i in range(len(z))] # p − y
h = 1e-5
numeric = []
for i in range(len(z)):
zp = list(z); zp[i] += h # копия z, i-й логит сдвинут вверх
zm = list(z); zm[i] -= h # и вниз
numeric.append((ce(zp, t) - ce(zm, t)) / (2 * h)) # центральная разность
print([round(g, 3) for g in analytic]) # [-0.341, 0.242, 0.099]
print([round(g, 3) for g in numeric]) # [-0.341, 0.242, 0.099]

Реализуйте устойчивые softmax и cross_entropy на чистом Python (без NumPy). Тесты проверят и правильность, и устойчивость к огромным логитам вроде [1000.0, 1000.0].

Устойчивые softmax и cross-entropy6 тестов
import math


def softmax(logits):
    """Превращает список логитов в список вероятностей (сумма = 1).

    Пример: softmax([1.0, 2.0, 3.0]) → [0.090, 0.245, 0.665] (с округлением).
    Должна работать и для огромных чисел, например [1000.0, 1000.0] → [0.5, 0.5]:
    перед экспонентой вычтите из всех логитов максимум (урок 1.3.2, «Ловушка: переполнение»).
    """
    # TODO: m = max(logits); экспоненты от (z − m); их сумма; каждую экспоненту делим на сумму
    ...


def cross_entropy(logits, target):
    """Cross-entropy для одного примера: -log(softmax(logits)[target]).

    target — индекс правильного класса (токена), считается с нуля.
    Пример: cross_entropy([0.0, 0.0, 0.0, 0.0], 2) → ln 4 ≈ 1.386 (все 4 варианта равновероятны).
    Считайте через logsumexp: m + log(Σ exp(z − m)) − logits[target], иначе на [1000.0, 0.0]
    вероятность округлится до 0.0 и log(0) упадёт с ValueError.
    """
    # TODO: ваш код
    ...


# Проверьте себя кнопкой «Запустить»:
print(softmax([1.0, 2.0, 3.0]))                 # ≈ [0.0900, 0.2447, 0.6652]
print(cross_entropy([0.0, 0.0, 0.0, 0.0], 2))   # ≈ 1.3863
Проверь себя6 вопросов
1 Что вернёт softmax для логитов [5,5,5,5][5, 5, 5, 5]?
2 Модель дала правильному токену вероятность 0.5. Чему равен loss (натуральный логарифм)? Ответ округлите до двух знаков.
3 Логиты [0,0,0,0][0, 0, 0, 0], правильный токен — третий. Чему равна cross-entropy? Округлите до двух знаков.
4 Какие утверждения верны?Можно выбрать несколько ответов
5 Почему вместо log(softmax(z)[t]) используют z[t] - logsumexp(z)?
6 Начальный loss свежей модели со словарём 32 000 токенов примерно равен… (округлите до одного знака)
  • Softmax превращает логиты в вероятности: экспонента делает числа положительными, деление на сумму — нормирует. Логит больше на 1 — вероятность больше в ee раз. Для устойчивости вычитаем максимум: softmax от сдвига не меняется.
  • Метод максимального правдоподобия выбирает параметры, при которых данные наиболее вероятны. Для LLM это значит минимизировать отрицательное лог-правдоподобие, то есть cross-entropy −log⁡qt-\log q_t.
  • −log⁡softmax(z)t=logsumexp(z)−zt-\log \mathrm{softmax}(z)_t = \mathrm{logsumexp}(z) - z_t — так loss считают без переполнения и без log⁡0\log 0.
  • Градиент cross-entropy по логитам — p−yp - y: простой, ограниченный и устойчивый.
  • Loss свежей модели ≈ ln⁡V\ln V. Perplexity = elosse^{\text{loss}}. На масштабе DeepSeek-V3 экономят точность почти везде, кроме вероятностей.

Дальше: энтропия, KL-дивергенция и perplexity. KL понадобится для RLHF, GRPO и дистилляции.