Урок 5 - Pytorch и практика обучения нейронных сетей¶

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

О библиотеке PyTorch¶

Первое, что необходимо понять об устройстве PyTorch — идея вычислительных графов. Вычислительный граф — набор вычислений, которые называются узлами (nodes), и которые соединены в прямом порядке вычислений. Другими словами, выбранный узел зависит от узлов на входе, который в свою очередь производит вычисления для других узлов. Ниже представлен простой пример вычислительного графа:

No description has been provided for this image

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

Pytorch автоматически строит внутри себя вычислительные графы для всех операций и позволяет осуществлять дифференцирование всех компонентов графа одним методом. Но об этом мы поговорим ниже.

Для начала, познакомимся с основным объектом операций в pyTorch'е - тензором. Во многом он похож на массив из нумпая, но обладает более специфичным для обучения нейронных сетей функционалом.

In [ ]:
# импортируем пайторч и создадим двумерный массив. Синтаксис аналогичен нумпаю:
import torch
x = torch.Tensor(2, 3)

print(x)
tensor([[3.9168e-02, 1.1706e-19, 1.3563e-19],
        [1.3563e-19, 1.3563e-19, 1.2686e+31]])
In [ ]:
# мы можем создать тензор и из нумпай массива
import numpy as np
x = torch.Tensor(np.random.random((2, 3)))

print(x)
tensor([[0.5341, 0.3995, 0.7401],
        [0.1817, 0.7504, 0.4476]])
In [ ]:
# все основные операции аналогичны с нумпаем

x = torch.ones(2,3)
y = torch.ones(2,3) * 2
print(x + y)
tensor([[3., 3., 3.],
        [3., 3., 3.]])
In [ ]:
# аналогично и индексирование

y[:,1] = y[:,1] + 1
print(y)
tensor([[2., 3., 2.],
        [2., 3., 2.]])

Автодифференцирование¶

В библиотеке pytorch, как упоминалось выше, есть механизмы вычисления производной и осуществления обратного распространения ошибки через вычислительный граф. Этот механизм, называемый автоградиентом в PyTorch, легко доступен и интуитивно понятен. Класс Variable — главный компонент автоградиента в PyTorch. Variable позволяет автоматически вычислять градиент при вызове функции .backward(), т.к. содержит данные из тензора, градиент тензора и ссылку на любую функцию, для вычисления которой необходим этот объект. Значением Variable может быть тензор любой размерности - т.е. как число, так и вектор, матрица и прочие объекты более высоких размерностей.

In [ ]:
from torch.autograd import Variable
In [ ]:
# создадим дифференцируемую переменную
x = Variable(torch.ones(2, 2) * 2, requires_grad=True)
In [ ]:
# создадим новую переменную, являющуюся результатом некоторых операций на x
z = 2 * (x * x) + 5 * x

Аналитически легко посчитать, что для z градиент по x будет рассчитываться как $4x + 5$. Попробуем посчитать его от матрицы из единиц размера 2 на 2:

In [ ]:
z.backward(torch.ones(2, 2))
In [ ]:
x.grad
Out[ ]:
tensor([[13., 13.],
        [13., 13.]])

Реализуем с помощью пайторча алгоритм обратного распространения ошибки для обучения полносвязных нейронных сетей с произвольным кол-вом слоёв

In [ ]:
from tqdm import tqdm


def sigmoid(x):
    return 1 / (1 + torch.exp(-x))


def mse_loss(y_true, y_pred):
    return ((y_true - y_pred) ** 2).mean()


class NeuralNetwork:
    def __init__(self, layers_sizes, bias=True):
        self.bias = bias
        self.len = len(layers_sizes)-1
        self.weights = []
        for i in range(self.len):
            layer = [Variable(torch.rand(layers_sizes[i], layers_sizes[i+1]), requires_grad=True)]
            if bias:
                layer.append(Variable(torch.rand(1, layers_sizes[i+1]), requires_grad=True))
            self.weights.append(layer)

    def forward(self, x):
        for i in range(self.len):
            x = x @ self.weights[i][0]
            if self.bias:
                x += self.weights[i][1]
            x = sigmoid(x)
        return x

    def train(self, train_data, valid_data, criterion, epochs, lr):
        X_train, y_train = train_data
        X_valid, y_valid = valid_data

        X_train, y_train = Variable(X_train, requires_grad=True), Variable(y_train, requires_grad=True)
        X_valid, y_valid = Variable(X_valid, requires_grad=True), Variable(y_valid, requires_grad=True)
        for e in tqdm(range(epochs)):
            out = self.forward(X_train)
            loss = criterion(y_train, out)

            loss.backward()
            for i in range(self.len):
                with torch.no_grad():
                    self.weights[i][0] -= self.weights[i][0].grad.data * lr
                    self.weights[i][0].grad = None
                    if self.bias:
                        self.weights[i][1] -= self.weights[i][1].grad.data * lr
                        self.weights[i][1].grad = None

        train_loss = loss.item()
        valid_loss = criterion(y_valid, self.forward(X_valid)).item()
        print(f"Train loss: {round(train_loss, 4)} | Valid loss: {round(valid_loss, 4)}")

Используем набор данных с предсказанием прогрессии диабета с прошлого занятия.

In [ ]:
from sklearn.datasets import load_diabetes
from sklearn.preprocessing import MinMaxScaler
from sklearn.model_selection import train_test_split

data = load_diabetes(as_frame=True, scaled=False)
X, y = data["data"], data["target"].values.reshape((-1, 1))
scaler_X = MinMaxScaler().fit(X)
scaler_y = MinMaxScaler().fit(y)
X = scaler_X.transform(X)
y = scaler_y.transform(y)

X_train, X_valid, y_train, y_valid = train_test_split(X, y, test_size=0.2, random_state=42)
In [ ]:
X_train = torch.Tensor(X_train)
y_train = torch.Tensor(y_train)

X_valid = torch.Tensor(X_valid)
y_valid = torch.Tensor(y_valid)
In [ ]:
model = NeuralNetwork([10, 8, 1])
In [ ]:
model.train((X_train, y_train), (X_valid, y_valid), mse_loss, 10000, 0.1)
100%|██████████| 10000/10000 [00:24<00:00, 401.19it/s]
Train loss: 0.0296 | Valid loss: 0.0271

Оценим ошибку модели по метрике MAPE

In [ ]:
preds = model.forward(X_valid)
mape = (abs(preds - y_valid) / y_valid).mean().item()
print("Ошибка модели по метрике MAPE:", round(mape, 3))
Ошибка модели по метрике MAPE: 0.632

Модель работает и справляется с решением задачи.


Более специализированные инструменты¶

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

Используя весь функционал торча, реализуем обучение нейронной сети со всеми изученными на предыдущих занятиях техниками.

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

В памяти компьютера изображение хранится как матрицы пикселей для каждого цветового канала. В современных нейронных сетях для компьютерного зрения (например, свёрточные, хотя они уже устаревают, или визуальные трансформеры), используются более сложные архитектуры нейронных сетей со специализированными слоями, которые учитывают позиционирование пикселей относительно друг друга по ширине и высоте. Но т.к. мы используем обычные полносвязные нейронные сети с линейными слоями, мы будем просто "сглаживать" матрицу в вектор из подряд идущих строк.
Простым нейронным сетям с линейными слоями довольно тяжело справляться с задачами компьютерного зрения, т.к. человек, анализируя изображения, смотрит не на всё изображение по одному пикселю, а сам группирует их в геометрические примитивы и запомнившееся ему фигуры. В полносвязных нейронных сетях, наоборот, каждый пиксель влияет на значение выходов первого слоя (а значит и всех следующих). Поэтому мы облегчим задачу нашей модели - входные изображения цифр будут серые и иметь разрешение 8x8, т.е. кол-во входных параметров для нейросети будет 64.

In [ ]:
# также воспользуемся готовыми датасетами из библиотеки sklearn

from sklearn.datasets import load_digits

digits = load_digits()
print(digits.data.shape)
(1797, 64)
In [ ]:
# выведем для примера изображение

import matplotlib.pyplot as plt

plt.imshow(digits.images[700])
Out[ ]:
<matplotlib.image.AxesImage at 0x79678dbda440>
No description has been provided for this image

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

In [ ]:
from torch.utils.data import Dataset, DataLoader

class DigitsDataset(Dataset):
    def __init__(self, images, targets):
        self.X = images
        self.y = targets

    def __getitem__(self, idx):
        X, y = self.X[idx], self.y[idx]
        X = X.flatten() # сгладим картинку в один вектор
        X /= 16 # приведем значение пикселей в диапазон от 0 до 1
        return torch.FloatTensor(X), torch.tensor(y).long()

    def __len__(self):
        return len(self.X)
In [ ]:
X, y = digits.data, digits.target
In [ ]:
X_train, X_valid, y_train, y_valid = train_test_split(X, y, test_size=0.2, random_state=42)
train_dataset, valid_dataset = DigitsDataset(X_train, y_train), DigitsDataset(X_valid, y_valid)
In [ ]:
# создадим dataloader - объект специального класса, позволяющий удобно осуществлять батчирование данных и итерироваться по датасету

train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True)
valid_loader = DataLoader(valid_dataset, batch_size=16, shuffle=True)

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

In [ ]:
from IPython.display import clear_output
import time

def train(model, train_loader, valid_loader, criterion, optimizer, epochs):
    total_train_loss, total_train_acc = [], []
    total_valid_loss, total_valid_acc = [], []
    for ep in tqdm(range(epochs)):
        model.train()
        train_acc, valid_acc = [], []
        train_loss, valid_loss = [], []

        for imgs, labels in train_loader:
            optimizer.zero_grad()

            imgs = imgs
            labels = labels

            y_pred = model(imgs).float()

            loss = criterion(y_pred, labels)
            loss.backward()

            train_loss.append(loss.item())
            train_acc.append((y_pred.argmax(1) == labels).sum().item() / len(imgs))

            optimizer.step()

        model.eval()

        with torch.no_grad():
            for imgs, labels in valid_loader:
                imgs = imgs
                labels = labels

                y_pred = model(imgs)
                loss = criterion(y_pred, labels).item()

                valid_loss.append(loss)
                valid_acc.append((y_pred.argmax(1) == labels).sum().item() / len(imgs))

        if (ep-1) % 20 == 0:
            print(f"Epoch {ep} | Train acc: {round(np.mean(train_acc), 3)} | Valid acc: {round(np.mean(valid_acc), 3)}")

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

In [ ]:
from torch import nn

model = nn.Sequential(
    nn.Linear(64, 128),
    nn.ReLU(),
    nn.Linear(128, 256),
    nn.ReLU(),
    nn.Linear(256, 64),
    nn.ReLU(),
    nn.Linear(64, 10),
    nn.Sigmoid()
)

model = model
In [ ]:
from torch.optim import SGD

optimizer = SGD(model.parameters(), lr=5e-4, momentum=0.95)
criterion = nn.CrossEntropyLoss()
In [ ]:
train(model, train_loader, valid_loader, criterion, optimizer, 200)
  1%|          | 2/200 [00:00<01:37,  2.03it/s]
Epoch 1 | Train acc: 0.094 | Valid acc: 0.073
 11%|█         | 22/200 [00:06<00:38,  4.66it/s]
Epoch 21 | Train acc: 0.257 | Valid acc: 0.198
 21%|██        | 42/200 [00:11<00:34,  4.55it/s]
Epoch 41 | Train acc: 0.295 | Valid acc: 0.261
 31%|███       | 62/200 [00:16<00:46,  2.97it/s]
Epoch 61 | Train acc: 0.558 | Valid acc: 0.505
 41%|████      | 82/200 [00:21<00:28,  4.10it/s]
Epoch 81 | Train acc: 0.794 | Valid acc: 0.821
 51%|█████     | 102/200 [00:25<00:23,  4.17it/s]
Epoch 101 | Train acc: 0.781 | Valid acc: 0.815
 61%|██████    | 122/200 [00:31<00:18,  4.14it/s]
Epoch 121 | Train acc: 0.77 | Valid acc: 0.75
 71%|███████   | 142/200 [00:36<00:13,  4.19it/s]
Epoch 141 | Train acc: 0.74 | Valid acc: 0.734
 81%|████████  | 162/200 [00:41<00:11,  3.29it/s]
Epoch 161 | Train acc: 0.743 | Valid acc: 0.739
 91%|█████████ | 182/200 [00:46<00:04,  4.36it/s]
Epoch 181 | Train acc: 0.755 | Valid acc: 0.753
100%|██████████| 200/200 [00:50<00:00,  3.92it/s]

Мы получили достаточно неплохую точность для нашей задачи. Но в рамках данной задачи возможно подобрать гипперпараметры и архитектуру модели, при которых, используя даже полносвязные модели, можно получить точность выше 90%. Попробуйте сами провести эксперимента для поиска наулучших гипперпараметров и архитектуры для решаемой задачи, а также добавить дропаут и другие методы регуляризации, попробовать альтернативные оптимизаторы и функции ошибки. Фреймворк пайторч открывает огромные возможности для экспериментов с нейронными сетями, а дальше всё в ваших руках!


Итоги марафона¶

Итак, в рамках изученного марафона мы изучили основы математики, необходимой для понимания градиентной оптимизации, на основе которой строится современное обучение нейронных сетей. Мы познакомились с самыми частоиспользующимися библиотеками в области прикладного анализа данных, а также фреймворком пайторч - основой современного глубокого машинного обучения. Но это только начало вашего пути в дальнейшем познании мира машинного обучения! После прохождения марафона советую заново, но более подробно, изучить весь пройденный материал и особенно углубиться в математические доказательства некоторых важных элементов, например, дифференцирования матриц. Хорошая математическая база будет очень полезна при изучении дальнейших более сложных тем в машинном обучении.
Список полезных материалов для дальнейшего самостоятельного изучения:

  • Deep learning school от МФТИ (бесплатный, но нужно записываться на поток);
  • Открытый курс по машинному обучению от ODS group (курс не про глубокое машинное обучение и нейронные сети, а про анализ данных и классические модели машинного обучения, но всё равно очень полезен и важен);
  • https://practicum.yandex.ru/blog/svertochnye-neyronnye-seti/
In [ ]:
 
In [ ]:
 
In [ ]: