Задача классификации и её функция потерь
Что такое классификация
Классификация — предсказание класса из конечного набора: спам / не спам, цифра 0–9, порода собаки, следующее слово (класс — слово из словаря!). Модель выдаёт не просто ответ, а распределение вероятностей по классам, и мы учим её давать правильному классу высокую вероятность.
Бинарная классификация
Два класса (0 и 1). Сеть выдаёт одно число — логит z, сигмоида превращает его в вероятность класса 1:
Функция потерь — бинарная кросс-энтропия (BCE), минус логарифм вероятности правильного класса:
Её градиент по логиту поразительно прост — «предсказание минус ответ»:
Многоклассовая классификация
C классов. Сеть выдаёт C логитов, softmax превращает их в распределение, потеря — кросс-энтропия:
Двигайте логиты и посмотрите, как меняются вероятности и потеря (правильный класс — «кошка»):
- Потеря необученной модели (все логиты одинаковые) — \ln C: для 10 классов 2.30. Если обучение стартует с сильно другого значения, проверьте инициализацию выходного слоя.
- Потеря 0 недостижима: для неё нужна вероятность ровно 1, то есть бесконечный логит. Поэтому без регуляризации логиты могут расти бесконечно, а модель — становиться излишне самоуверенной.
Почему не MSE и не точность
Точность (доля правильных ответов) — то, что нас интересует в итоге, но её нельзя оптимизировать градиентным спуском: она меняется скачками, и её градиент почти везде равен нулю.
MSE между вероятностями и one-hot формально работает, но хуже: вместе с сигмоидой/softmax градиент MSE содержит множитель p(1 - p), который почти обнуляется, когда модель уверенно ошибается (p \approx 0 для правильного класса). Именно тогда, когда учиться нужнее всего, градиент исчезает. У кросс-энтропии в той же ситуации градиент максимален.
import numpy as np
import vis
z = np.linspace(-6, 6, 300) # логит правильного класса (бинарный случай, y = 1)
p = 1 / (1 + np.exp(-z))
vis.line(z, {"кросс-энтропия: |p − 1|": np.abs(p - 1), "MSE: |2(p − 1)·p(1 − p)|": np.abs(2 * (p - 1) * p * (1 - p))},
title="Модуль градиента по логиту, когда правильный ответ 1", xlabel="логит z (слева — уверенная ошибка)")
Слева (модель уверенно ошибается) градиент кросс-энтропии близок к 1, а у MSE — к нулю.
Устойчивые вычисления
Считать \ln(\text{softmax}(z)) «в лоб» опасно: при больших логитах экспонента переполняется, а при маленьких вероятностях логарифм от нуля даёт -\infty. Поэтому:
- softmax считают, вычитая максимум логита;
- log-softmax — сразу по формуле z_k - \max z - \ln\sum_j e^{z_j - \max z};
- BCE — через логит: \max(z, 0) - zy + \ln(1 + e^{-|z|}).
Поэтому функции потерь в библиотеках (nt.cross_entropy, nt.binary_cross_entropy, а в PyTorch — F.cross_entropy, BCEWithLogitsLoss) принимают логиты, а не вероятности. Частая ошибка — поставить softmax в модель и ещё раз применить его внутри функции потерь.
import numpy as np
import nanotorch as nt
logits = nt.tensor([[2.0, 1.0, 0.1], [0.2, 3.0, -1.0]], requires_grad=True)
targets = np.array([0, 1])
loss = nt.cross_entropy(logits, targets)
loss.backward()
print("потеря:", round(loss.item(), 4))
print("вероятности:\n", logits.softmax().data.round(3))
print("градиент = (p − onehot) / N:\n", logits.grad.round(3))
Пример: три спирали
import numpy as np
import nanotorch as nt
import vis
X, y = nt.make_spirals(450, classes=3, noise=0.15, seed=0)
X_train, X_test, y_train, y_test = nt.train_test_split(X, y, test_size=0.3, seed=0)
nt.manual_seed(0)
model = nt.Sequential(nt.Linear(2, 64), nt.ReLU(), nt.Linear(64, 64), nt.ReLU(), nt.Linear(64, 3))
hist = nt.fit(model, X_train, y_train, nt.cross_entropy, nt.Adam(model.parameters(), lr=0.01),
epochs=100, batch_size=32, X_val=X_test, y_val=y_test, metric=nt.accuracy, verbose=False)
print(f"точность: train {hist['metric'][-1]:.1%}, test {hist['val_metric'][-1]:.1%}")
vis.line({"train": hist["loss"], "test": hist["val_loss"]}, title="Кросс-энтропия", xlabel="эпоха")
with nt.no_grad():
vis.boundary(lambda P: model(nt.Tensor(P)).data.argmax(1), X, y, title="Предсказанный класс")
probs = model(nt.Tensor(X_test)).softmax().data
conf = probs.max(axis=1)
print(f"средняя уверенность на тесте: {conf.mean():.2f}; на ошибках: {conf[probs.argmax(1) != y_test].mean():.2f}")
Обычно модель менее уверена на объектах, где ошибается, — уверенность можно использовать, например, чтобы отправлять сомнительные случаи человеку на проверку. Но нейросети склонны к излишней самоуверенности, и их вероятности не всегда можно понимать буквально; для этого существуют методы калибровки.
Несколько меток (multi-label)
Если у объекта может быть несколько классов одновременно (на фото и кошка, и собака), softmax не подходит: он заставляет вероятности конкурировать. Вместо этого каждый класс — отдельная бинарная задача: C логитов, сигмоида у каждого и BCE, усреднённая по классам.
Итог
- Бинарная: 1 логит → сигмоида → BCE; многоклассовая: C логитов → softmax → кросс-энтропия; multi-label: C сигмоид → BCE.
- Градиент по логитам — «вероятность минус ответ», не затухает при уверенных ошибках (в отличие от MSE).
- Точность нельзя оптимизировать градиентом; кросс-энтропия — её гладкая замена.
- Функции потерь принимают логиты и считают всё устойчиво; потеря необученной модели ≈ \ln C.
Практика решено 0 из 6
Какой выход и функцию потерь выбрать?
| 1 логит + сигмоида + BCE | C логитов + softmax + CE | C логитов + сигмоида у каждого + BCE | |
|---|---|---|---|
| Письмо — спам или нет | |||
| Какая цифра (0–9) на картинке | |||
| Какие объекты есть на фото: кошка, собака, машина (может быть несколько сразу) | |||
| Тональность отзыва: негативная, нейтральная, позитивная | |||
| Болен ли пациент |
Почему точность (accuracy) нельзя использовать как функцию потерь для градиентного спуска?
Для 4 классов модель выдала логиты [0, 0, 0, 0]. Чему равна кросс-энтропия (натуральный логарифм, два знака)?
Логит бинарного классификатора равен 0. Какую вероятность класса 1 выдаёт модель?
Устойчивая бинарная кросс-энтропия по логитам. Наивная формула -[y\ln\sigma(z) + (1-y)\ln(1-\sigma(z))] ломается при больших |z|: \sigma(z) округляется до 0 или 1, и логарифм даёт -\infty. Эквивалентная устойчивая форма:
Напишите bce_with_logits(z, y) (массивы логитов и меток 0/1) — среднее по объектам, float.
Используйте np.log1p для \ln(1 + \cdot).
Какие тесты проверяют код (2)
z, y = np.array([0.5, -1.2, 2.0]), np.array([1.0, 0.0, 0.0])
p = 1 / (1 + np.exp(-z))
naive = -(y * np.log(p) + (1 - y) * np.log(1 - p)).mean()
assert abs(bce_with_logits(z, y) - naive) < 1e-12, "На обычных логитах результат должен совпадать с наивной формулой"
v = bce_with_logits(np.array([100.0, -100.0]), np.array([0.0, 1.0]))
assert np.isfinite(v) and abs(v - 100) < 1e-9, f"При логитах ±100 и неверных метках потеря должна быть 100, получено {v}"
assert bce_with_logits(np.array([50.0]), np.array([1.0])) < 1e-20
import numpy as np
def bce_with_logits(z, y):
return float((np.maximum(z, 0) - z * y + np.log1p(np.exp(-np.abs(z)))).mean())
Предсказание и уверенность. Напишите predict(logits) для многоклассовой модели: по матрице
логитов (N, C) верните пару (classes, confidence) — номер класса с максимальной вероятностью и
саму эту вероятность (после softmax) для каждого объекта. Без циклов, softmax — устойчивый.
Какие тесты проверяют код (2)
cls, conf = predict(np.array([[2.0, 1, 0.1], [0, 0, 0], [-1, 5, 5]])) assert list(cls[:2]) == [0, 0] and cls[2] in (1, 2) assert np.allclose(conf, [np.exp(2) / (np.exp(2) + np.e + np.exp(0.1)), 1 / 3, 0.5 / (1 + 0.5 * np.exp(-6))], atol=1e-6) cls, conf = predict(np.array([[1000.0, 0]])) assert cls[0] == 0 and np.isfinite(conf[0]) and abs(conf[0] - 1) < 1e-12
import numpy as np
def predict(logits):
z = logits - logits.max(axis=1, keepdims=True)
p = np.exp(z) / np.exp(z).sum(axis=1, keepdims=True)
return p.argmax(axis=1), p.max(axis=1)
Войдите, чтобы отмечать пройденные уроки.