4.4. Реализация софтмакс-регрессии с нуля

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

import torch
from d2l import torch as d2l

4.4.1. Софтмакс

Начнем с самого важного: отображения скаляров в вероятности. Для освежения памяти вспомним операцию суммирования по определённым осям тензора, как обсуждалось в Section 2.3.6 и Section 2.3.7. Для заданной матрицы X мы можем суммировать все элементы (по умолчанию) или только элементы вдоль одной оси. Переменная axis позволяет вычислить суммы по строкам и столбцам:

X = torch.tensor([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
X.sum(0, keepdims=True), X.sum(1, keepdims=True)

Вычисление софтмакса требует трёх шагов: (i) возведения каждого элемента в степень; (ii) суммирования по каждой строке для вычисления константы нормировки для каждого примера; (iii) деления каждой строки на её константу нормировки, чтобы в результате сумма элементов строки равнялась 1:

(4.4.1)\[\mathrm{softmax}(\mathbf{X})_{ij} = \frac{\exp(\mathbf{X}_{ij})}{\sum_k \exp(\mathbf{X}_{ik})}.\]

Логарифм (или просто логарифм от) знаменателя называется (логарифмической) функцией разбиения. Она была введена в [статистической физике](https://en.wikipedia.org/wiki/Partition_function_(statistical_mechanics) для суммирования по всем возможным состояниям термодинамического ансамбля. Реализация тривиальна:

def softmax(X):
    X_exp = torch.exp(X)
    partition = X_exp.sum(1, keepdims=True)
    return X_exp / partition  # The broadcasting mechanism is applied here

Для любого входа X мы преобразуем каждый элемент в неотрицательное число. Сумма элементов в каждой строке равна 1, что соответствует требованиям к вероятности. Внимание: приведённый выше код не является устойчивым к очень большим или очень малым аргументам. Хотя его достаточно для иллюстрации происходящего, не следует использовать этот код без изменений для каких-либо серьезных задач. Фреймворки глубокого обучения имеют встроенные механизмы защиты, и в дальнейшем мы будем использовать встроенный софтмакс.

X = torch.rand((2, 5))
X_prob = softmax(X)
X_prob, X_prob.sum(1)

4.4.2. Модель

Теперь у нас есть всё необходимое для реализации модели софтмакс-регрессии. Как и в примере с линейной регрессией, каждый объект будет представлен вектором фиксированной длины. Поскольку исходные данные здесь состоят из \(28 \times 28\) пиксельных изображений, мы преобразуем каждое изображение в вектор длины 784. В последующих главах мы рассмотрим свёрточные нейронные сети, которые более эффективно используют пространственную структуру.

В софтмакс-регрессии количество выходов нашей сети должно быть равно количеству классов. Поскольку наш набор данных содержит 10 классов, размерность выхода нашей сети равна 10. Следовательно, наши веса образуют \(784 \times 10\) матрицу, а смещения — \(1 \times 10\) вектор-строку. Как и в линейной регрессии, мы инициализируем веса W с помощью гауссовского шума. Смещения инициализируются нулями.

class SoftmaxRegressionScratch(d2l.Classifier):
    def __init__(self, num_inputs, num_outputs, lr, sigma=0.01):
        super().__init__()
        self.save_hyperparameters()
        self.W = torch.normal(0, sigma, size=(num_inputs, num_outputs),
                              requires_grad=True)
        self.b = torch.zeros(num_outputs, requires_grad=True)

    def parameters(self):
        return [self.W, self.b]

Код ниже определяет, как сеть отображает каждый вход в выход. Обратите внимание, что мы преобразуем каждое изображение размером \(28 \times 28\) пикселей в батче в вектор с помощью reshape перед передачей данных в нашу модель.

@d2l.add_to_class(SoftmaxRegressionScratch)
def forward(self, X):
    X = X.reshape((-1, self.W.shape[0]))
    return softmax(torch.matmul(X, self.W) + self.b)

4.4.3. Функция потерь перекрёстной энтропии

Далее мы реализуем функцию потерь перекрёстной энтропии (введённую в Section 4.1.2). Это, возможно, самая распространённая функция потерь во всём глубоком обучении. В настоящее время число задач глубокого обучения, которые легко сводятся к классификации, значительно превышает число задач, которые лучше описываются как задачи регрессии.

Вспомним, что перекрёстная энтропия вычисляет отрицательный логарифм правдоподобия вероятности, присвоенной истинной метке. Для эффективности мы избегаем циклов for в Python и используем индексацию. В частности, одномерное кодирование в \(\mathbf{y}\) позволяет выбрать соответствующие элементы в \(\hat{\mathbf{y}}\).

Чтобы увидеть это на практике, мы создадим пример данных ``y_hat`` с 2 примерами предсказанных вероятностей для 3 классов и их соответствующими метками ``y``. Верные метки — \(0\) и \(2\) соответственно (т. е. первый и третий класс). Используя ``y`` в качестве индексов вероятностей в ``y_hat``, мы можем эффективно выбрать нужные элементы.

y = torch.tensor([0, 2])
y_hat = torch.tensor([[0.1, 0.3, 0.6], [0.3, 0.2, 0.5]])
y_hat[[0, 1], y]

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

def cross_entropy(y_hat, y):
    return -torch.log(y_hat[list(range(len(y_hat))), y]).mean()

cross_entropy(y_hat, y)
@d2l.add_to_class(SoftmaxRegressionScratch)
def loss(self, y_hat, y):
    return cross_entropy(y_hat, y)

4.4.4. Обучение

Мы повторно используем метод fit, определённый в Section 3.4, для обучения модели в течение 10 эпох. Обратите внимание, что количество эпох (max_epochs), размер мини-батча (batch_size) и скорость обучения (lr) являются настраиваемыми гиперпараметрами. Это означает, что, хотя эти значения не обучаются в рамках основного цикла обучения, они всё же влияют на производительность нашей модели как в отношении качества обучения, так и обобщающей способности. На практике эти значения следует выбирать на основе валидационной выборки данных, а затем, в конечном итоге, оценивать итоговую модель на тестовой выборке. Как обсуждалось в Section 3.6.3, мы будем рассматривать тестовые данные Fashion-MNIST как валидационную выборку, поэтому будем отчитываться о валидационной потере и валидационной точности на этой выборке.

data = d2l.FashionMNIST(batch_size=256)
model = SoftmaxRegressionScratch(num_inputs=784, num_outputs=10, lr=0.1)
trainer = d2l.Trainer(max_epochs=10)
trainer.fit(model, data)

4.4.5. Прогнозирование

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

X, y = next(iter(data.val_dataloader()))
preds = model(X).argmax(axis=1)
preds.shape

Нас больше интересуют изображения, которые мы пометили неверно. Мы визуализируем их, сравнивая истинные метки (первая строка текстового вывода) с предсказаниями модели (вторая строка текстового вывода).

wrong = preds.type(y.dtype) != y
X, y, preds = X[wrong], y[wrong], preds[wrong]
labels = [a+'\n'+b for a, b in zip(
    data.text_labels(y), data.text_labels(preds))]
data.visualize([X, y], labels=labels)

4.4.6. Резюме

К этому моменту мы уже накопили некоторый опыт решения задач линейной регрессии и классификации. С их помощью мы достигли того, что можно считать передовым уровнем статистического моделирования 1960–1970-х годов. В следующем разделе мы покажем, как использовать фреймворки глубокого обучения для более эффективной реализации этой модели.

4.4.7. Упражнения

  1. В этом разделе мы напрямую реализовали функцию софтмакс, основываясь на математическом определении операции софтмакс. Как обсуждалось в Section 4.1, это может привести к численной нестабильности.

    1. Проверьте, работает ли softmax корректно, если входное значение равно \(100\).

    2. Проверьте, работает ли softmax корректно, если наибольшее из всех входных значений меньше \(-100\).

    3. Реализуйте исправление, сравнивая значение с максимальным элементом аргумента.

  2. Реализуйте cross_entropy функцию, которая соответствует определению функции потерь перекрёстной энтропии \(\sum_i y_i \log \hat{y}_i\).

    1. Попробуйте это в примере кода этого раздела.

    2. Почему, по вашему мнению, он работает медленнее?

    3. Стоит ли его использовать? В каких случаях это целесообразно?

    4. На что нужно обратить внимание? Подсказка: рассмотрите область определения логарифма.

  3. Всегда ли целесообразно возвращать наиболее вероятную метку? Например, стали бы вы поступать так при медицинской диагностике? Как бы вы решили эту проблему?

  4. Предположим, что мы хотим использовать софтмакс-регрессию для предсказания следующего слова на основе некоторых признаков. Какие проблемы могут возникнуть из-за большого словаря?

  5. Экспериментируйте с гиперпараметрами кода в этом разделе. В частности:

    1. Постройте график изменения ошибки на валидационной выборке в зависимости от скорости обучения.

    2. Меняются ли потери на валидации и обучении при изменении размера мини-батча? Насколько большим или маленьким нужно сделать его, чтобы увидеть эффект?

Обсуждения