Вероятности, softmax и правдоподобие
Зачем вероятности
Хорошая модель не просто говорит «это кошка», а говорит «кошка с вероятностью 92%, собака 7%, птица 1%». Вероятности позволяют модели выражать уверенность, а нам — построить правильную функцию потерь. Нам понадобится совсем немного теории.
Распределения
Распределение вероятностей — список возможных исходов и их вероятностей. Вероятности неотрицательны и в сумме дают 1.
- Дискретное: конечное число исходов. Бросок кубика: шесть исходов по 1/6. Ответ классификатора: 10 цифр со своими вероятностями.
- Непрерывное: исход — любое число (рост, ошибка измерения). Описывается плотностью, вероятность попасть в интервал — площадь под ней.
Математическое ожидание (среднее) — взвешенное вероятностями среднее исходов: \mathbb{E}[X] = \sum_i p_i x_i. Дисперсия — средний квадрат отклонения от среднего: \text{Var}[X] = \mathbb{E}[(X - \mathbb{E}X)^2], а стандартное отклонение \sigma = \sqrt{\text{Var}} — типичный разброс.
import numpy as np
import vis
rng = np.random.default_rng(0)
rolls = rng.integers(1, 7, size=10_000)
print("среднее бросков:", rolls.mean(), "(теория 3.5)")
print("дисперсия:", rolls.var().round(3), "(теория 35/12 ≈ 2.917)")
faces, counts = np.unique(rolls, return_counts=True)
vis.bar([str(k) for k in faces], counts / len(rolls), title="Частоты исходов кубика", fmt=".3f")
Нормальное распределение
Самое важное непрерывное распределение — нормальное (гауссово) \mathcal{N}(\mu, \sigma^2), «колокол» со средним \mu и разбросом \sigma:
Сумма многих мелких независимых случайностей почти всегда распределена нормально (центральная предельная теорема), поэтому так ведут себя шумы и ошибки измерений. В нейросетях нормальное распределение используют для начальной инициализации весов.
import numpy as np
import vis
x = np.linspace(-5, 5, 300)
pdf = lambda x, mu, s: np.exp(-(x - mu) ** 2 / (2 * s ** 2)) / (s * np.sqrt(2 * np.pi))
vis.line(x, {"μ = 0, σ = 1": pdf(x, 0, 1), "μ = 0, σ = 2": pdf(x, 0, 2), "μ = 2, σ = 0.5": pdf(x, 2, 0.5)},
title="Нормальное распределение", xlabel="x")
samples = np.random.default_rng(0).normal(0, 1, 100_000)
print(f"доля в пределах 1σ: {(np.abs(samples) < 1).mean():.3f}, 2σ: {(np.abs(samples) < 2).mean():.3f}, 3σ: {(np.abs(samples) < 3).mean():.4f}")
Softmax: от оценок к вероятностям
Последний слой классификатора выдаёт C произвольных чисел — логитов z_1, \dots, z_C (по одному на класс). Они могут быть отрицательными и не суммируются в 1. Чтобы получить распределение, применяют softmax:
- Экспонента делает все числа положительными и сохраняет порядок: больший логит → большая вероятность.
- Деление на сумму нормирует: вероятности в сумме дают 1.
Двигайте логиты и температуру:
- Разница логитов важнее их величины: прибавьте ко всем одно число — вероятности не изменятся.
- Температура T: \text{softmax}(z / T). При T \to 0 распределение становится «острым» (почти весь вес у максимума), при больших T — равномерным. Языковые модели используют температуру, чтобы регулировать «креативность» при генерации текста.
Устойчивое вычисление
e^{1000} не помещается во float — наивная формула сломается. Но мы знаем, что сдвиг всех логитов не меняет ответ. Вычтем максимум: тогда наибольшая экспонента равна e^0 = 1, и переполнения нет.
import numpy as np
def softmax_naive(z):
e = np.exp(z)
return e / e.sum()
def softmax(z):
e = np.exp(z - z.max())
return e / e.sum()
z = np.array([2.0, 1.0, 0.1])
print(softmax(z).round(3), softmax(z).sum())
big = np.array([1000.0, 1001, 1002])
print("наивно:", softmax_naive(big)) # nan — переполнение
print("устойчиво:", softmax(big).round(3))
Сигмоида — это softmax для двух классов
Для двух классов с логитами (z, 0): \text{softmax} = \frac{e^z}{e^z + 1} = \frac{1}{1 + e^{-z}} = \sigma(z). Поэтому в бинарной классификации модель выдаёт одно число, а вероятность получают сигмоидой.
Правдоподобие
Как оценить, хороша ли модель, которая выдаёт вероятности? Посмотрим, какую вероятность она дала тому, что произошло на самом деле. Если на картинке «3», а модель дала классу «3» вероятность 0.9 — хорошо; если 0.01 — плохо.
Правдоподобие (likelihood) модели на наборе данных — вероятность, которую модель приписывает всем правильным ответам сразу. Объекты независимы, поэтому вероятности перемножаются:
Метод максимального правдоподобия: подобрать параметры \theta так, чтобы правдоподобие было максимальным — модель должна считать реальные данные как можно более вероятными.
Произведение тысяч вероятностей исчезает в нуль, поэтому берут логарифм (максимум от этого не сдвигается), а чтобы получилась задача минимизации, — со знаком минус. Получается отрицательное лог-правдоподобие:
Классификация → кросс-энтропия
Если модель выдаёт вероятности через softmax, то -\ln p_\theta(y_i \mid x_i) — минус логарифм вероятности правильного класса. Среднее по объектам — это кросс-энтропия, главная функция потерь для классификации:
import numpy as np
import vis
for p in (0.99, 0.9, 0.5, 0.1, 0.01):
print(f"вероятность правильного класса {p:<5} → потеря {-np.log(p):.3f}")
p = np.linspace(0.005, 1, 200)
vis.line(p, -np.log(p), title="Кросс-энтропия одного объекта", xlabel="вероятность правильного класса", ylabel="−ln p")
Уверенная правильная модель почти ничего не платит, неуверенная (0.5) платит 0.69, а уверенно ошибающаяся — очень много. Если модель угадывает случайно среди C классов, потеря равна \ln C (для 10 цифр это 2.30) — полезный ориентир: обучение должно начинаться примерно с этого значения.
Регрессия → MSE
Для регрессии предположим, что правильный ответ — это предсказание модели плюс нормальный шум: y = \hat y + \varepsilon, \varepsilon \sim \mathcal{N}(0, \sigma^2). Подставим плотность нормального распределения в отрицательное лог-правдоподобие:
Минимизировать это — то же самое, что минимизировать средний квадрат ошибки. MSE — не произвольный выбор, а максимальное правдоподобие при гауссовом шуме.
Итог
- Распределение: вероятности исходов, сумма 1. Среднее и дисперсия описывают центр и разброс. Нормальное распределение — «колокол» \mathcal{N}(\mu, \sigma^2).
- Softmax превращает логиты в вероятности: e^{z_k} / \sum_j e^{z_j}. Для устойчивости вычитают максимум; температура регулирует «остроту».
- Максимальное правдоподобие: модель должна приписывать реальным данным наибольшую вероятность. Удобно минимизировать отрицательный логарифм.
- Классификация → кросс-энтропия -\ln p_{\text{правильный}}; регрессия с гауссовым шумом → MSE.
Практика решено 0 из 7
Чему равно математическое ожидание (среднее) броска честного шестигранного кубика?
Логиты модели — [2, 2, 2]. Что выдаст softmax?
Что произойдёт с softmax, если ко всем логитам прибавить 100?
Модель дала правильному классу вероятность 0.25. Чему равна кросс-энтропия на этом объекте (натуральный логарифм)? Округлите до двух знаков.
Почему для классификации минимизируют −log вероятности правильного класса, а не просто максимизируют саму вероятность?
Устойчивый softmax. Напишите softmax(z) для матрицы логитов z формы (N, C): softmax
по каждой строке. Вычтите из каждой строки её максимум перед экспонентой — тогда не будет
переполнения даже при логитах порядка 1000.
Какие тесты проверяют код (2)
p = softmax(np.array([[1.0, 2, 3], [0, 0, 0]])) assert np.shape(p) == (2, 3) assert np.allclose(p.sum(axis=1), 1), "Каждая строка должна суммироваться в 1" assert np.allclose(p[1], 1 / 3) assert np.allclose(p[0], np.exp([1, 2, 3]) / np.exp([1, 2, 3]).sum()) p = softmax(np.array([[1000.0, 1001, 1002]])) assert np.all(np.isfinite(p)), "Переполнение: вычтите максимум строки" assert np.allclose(p, softmax(np.array([[0.0, 1, 2]])))
import numpy as np
def softmax(z):
e = np.exp(z - z.max(axis=1, keepdims=True))
return e / e.sum(axis=1, keepdims=True)
Кросс-энтропия. Напишите cross_entropy(logits, targets): logits — (N, C), targets —
вектор правильных классов длины N. Верните среднее по объектам -\ln p_{\text{правильный}}
(обычный float). Используйте устойчивый log-softmax:
\ln \text{softmax}(z)_k = z_k - m - \ln\sum_j e^{z_j - m}, где m = \max_j z_j.
Какие тесты проверяют код (3)
assert abs(cross_entropy(np.zeros((4, 4)), np.array([0, 1, 2, 3])) - np.log(4)) < 1e-12, "При равных логитах потеря = ln C" z = np.array([[2.0, 1, 0.1], [0.5, 2.5, 0.3]]) p = np.exp(z) / np.exp(z).sum(1, keepdims=True) expected = -(np.log(p[0, 0]) + np.log(p[1, 2])) / 2 assert abs(cross_entropy(z, np.array([0, 2])) - expected) < 1e-12 v = cross_entropy(np.array([[1000.0, 0]]), np.array([1])) assert np.isfinite(v) and abs(v - 1000) < 1e-9, "Нужна устойчивая формула через log-softmax"
import numpy as np
def cross_entropy(logits, targets):
z = logits - logits.max(axis=1, keepdims=True)
log_probs = z - np.log(np.exp(z).sum(axis=1, keepdims=True))
return float(-log_probs[np.arange(len(targets)), targets].mean())
Войдите, чтобы отмечать пройденные уроки.