Скачать ipynb
nn_simple_examples

Введение в анализ данных

PyTorch и полносвязные нейронные сети

pytorch-logo.png

1. Введение

В данном ноутбуке мы будем пользоваться фреймворком PyTorch, который предназначен для работы с нейронными сетями. Как установить pytorch можно прочитать на официальном сайте PyTorch. Для этого выберите свою OS и вам будет показана нужная команда для ввода в терминале. Больше подробностей о том, как pytorch работает будет рассказно на 3 курсе.

In [1]:
import numpy as np
from sklearn.datasets import load_boston

import matplotlib.pyplot as plt
import seaborn as sns
from IPython.display import clear_output
sns.set(palette='Set2', font_scale=1.5)

import torch
from torch import nn
import torch.nn.functional as F

print(torch.__version__)
1.10.0+cu111

1.1 Сравнение NumPy и PyTorch-синтаксиса

Интерфейс pytorch написан подобно интерфесу numpy для удобства использования. Главное различие между ними, что numpy оперрирует numpy.ndarray массивами, а pytorch — тензорами pytorch.Tensor. Напишем одни и те же операции на numpy и pytorch.

numpy

In [2]:
x = np.arange(16).reshape(4, 4)

print("Матрица X:\n{}\n".format(x))
print("Размер: {}\n".format(x.shape))
print("Добавление константы:\n{}\n".format(x + 5))
print("X*X^T:\n{}\n".format(np.dot(x, x.T)))
print("Среднее по колонкам:\n{}\n".format(x.mean(axis=-1)))
print("Кумулятивная сумма по колонкам:\n{}\n".format(np.cumsum(x, axis=0)))
Матрица X:
[[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]
 [12 13 14 15]]

Размер: (4, 4)

Добавление константы:
[[ 5  6  7  8]
 [ 9 10 11 12]
 [13 14 15 16]
 [17 18 19 20]]

X*X^T:
[[ 14  38  62  86]
 [ 38 126 214 302]
 [ 62 214 366 518]
 [ 86 302 518 734]]

Среднее по колонкам:
[ 1.5  5.5  9.5 13.5]

Кумулятивная сумма по колонкам:
[[ 0  1  2  3]
 [ 4  6  8 10]
 [12 15 18 21]
 [24 28 32 36]]

pytorch

In [3]:
x = np.arange(16).reshape(4, 4)
x = torch.tensor(x, dtype=torch.float32)  # или torch.arange(0,16).view(4,4)

print("Матрица X:\n{}".format(x))
print("Размер: {}\n".format(x.shape))
print("Добавление константы:\n{}".format(x + 5))
print("X*X^T:\n{}".format(torch.matmul(x, x.transpose(1, 0))))  # кратко: x.mm(x.t())
print("Среднее по колонкам:\n{}".format(torch.mean(x, dim=-1)))
print("Кумулятивная сумма по колонкам:\n{}".format(torch.cumsum(x, dim=0)))
Матрица X:
tensor([[ 0.,  1.,  2.,  3.],
        [ 4.,  5.,  6.,  7.],
        [ 8.,  9., 10., 11.],
        [12., 13., 14., 15.]])
Размер: torch.Size([4, 4])

Добавление константы:
tensor([[ 5.,  6.,  7.,  8.],
        [ 9., 10., 11., 12.],
        [13., 14., 15., 16.],
        [17., 18., 19., 20.]])
X*X^T:
tensor([[ 14.,  38.,  62.,  86.],
        [ 38., 126., 214., 302.],
        [ 62., 214., 366., 518.],
        [ 86., 302., 518., 734.]])
Среднее по колонкам:
tensor([ 1.5000,  5.5000,  9.5000, 13.5000])
Кумулятивная сумма по колонкам:
tensor([[ 0.,  1.,  2.,  3.],
        [ 4.,  6.,  8., 10.],
        [12., 15., 18., 21.],
        [24., 28., 32., 36.]])

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

Например, Pytorch имеет другое написание стандартных типов

  • x.astype('int64') -> x.type(torch.LongTensor)

Для более подробного ознакомления можно посмотреть на табличку перевода методов из numpy в pytorch, а также заглянуть в документацию. Также при возникновении проблем часто помогает зайти на pytorch forumns.

1.2 NumPy <-> PyTorch

Можно переводить numpy-массив в torch-тензор и наоборот. Например, чтобы сделать из numpy-массива torch-тензор, можно сделать так:

In [4]:
# зададим numpy массив
x_np = np.array([2, 5, 7, 1])

# 1-й способ
x_torch = torch.tensor(x_np) 
print(type(x_torch), x_torch)

# 2-й способ
x_torch = torch.from_numpy(x_np)
print(type(x_torch), x_torch)
<class 'torch.Tensor'> tensor([2, 5, 7, 1])
<class 'torch.Tensor'> tensor([2, 5, 7, 1])

Аналогично и с переводом обратно: функция x.numpy() переведет torch-тензор x в numpy-массив, причем типы переведутся соответственно табличке.

In [5]:
x_np = x_torch.numpy()
print(type(x_np), x_np)
<class 'numpy.ndarray'> [2 5 7 1]

1.3 Еще один пример

Давайте нарисуем по сетке данную кривую на графике, используя pytorch:

$$x(t) = 2 \cos t + \sin 2t \cos 60t,$$$$y(t) = \sin 2t + \sin 60t.$$
In [6]:
t = torch.linspace(-10, 10, steps=10000)
x = 2 * torch.cos(t) + torch.sin(2 * t) * torch.cos(60 * t)
y = torch.sin(2 * t) + torch.sin(60 * t)

plt.plot(x, y)
plt.xlabel('x')
plt.ylabel('y')
plt.show()

Заметим, что matplotlib справляется с отображением pytorch-тензоров, и дополнительных преобразований делать не нужно.

2. Простой пример обучения нейронной сети

2.1 Цикл обучения модели

Пусть у нас есть вход $x \in \mathbb{R}^d$. Мы построили нейронную сеть $model$, состоящую из обучаемых параметров $w$ и $b$. На выходе она возвращает некоторый ответ $\widehat{y} = model(x)$. Для обучения такой сети мы задаем функцию, которую будем минимизировать. Тогда процесс обучения задается так:

  • Прямой проход / Forward pass
    Считаем $\widehat{y}$ и также запоминаем значения выходов всех слоев;
  • Вычисление оптимизируемой функции
    Вычисляем оптимизируемую функцию на текущем наборе объектов;
  • Обратный проход / Backward pass
    Считаем градиенты по всем обучаемым параметрам и запоминаем их;
  • Шаг оптимизации
    Делаем шаг градиентного спуска, обновляя все обучаемые веса.

2.2 Линейная регрессия

Сделаем одномерную линейную регрессию на датасете boston.

Скачиваем данные.

In [7]:
boston = load_boston()
/usr/local/lib/python3.7/dist-packages/sklearn/utils/deprecation.py:87: FutureWarning: Function load_boston is deprecated; `load_boston` is deprecated in 1.0 and will be removed in 1.2.

    The Boston housing prices dataset has an ethical problem. You can refer to
    the documentation of this function for further details.

    The scikit-learn maintainers therefore strongly discourage the use of this
    dataset unless the purpose of the code is to study and educate about
    ethical issues in data science and machine learning.

    In this special case, you can fetch the dataset from the original
    source::

        import pandas as pd
        import numpy as np


        data_url = "http://lib.stat.cmu.edu/datasets/boston"
        raw_df = pd.read_csv(data_url, sep="\s+", skiprows=22, header=None)
        data = np.hstack([raw_df.values[::2, :], raw_df.values[1::2, :2]])
        target = raw_df.values[1::2, 2]

    Alternative datasets include the California housing dataset (i.e.
    :func:`~sklearn.datasets.fetch_california_housing`) and the Ames housing
    dataset. You can load the datasets as follows::

        from sklearn.datasets import fetch_california_housing
        housing = fetch_california_housing()

    for the California housing dataset and::

        from sklearn.datasets import fetch_openml
        housing = fetch_openml(name="house_prices", as_frame=True)

    for the Ames housing dataset.
    
  warnings.warn(msg, category=FutureWarning)

Будем рассматривать зависимость таргета от последнего признака в данных.

In [8]:
plt.figure(figsize=(10,7))
plt.scatter(boston.data[:, -1], boston.target, alpha=0.7)
plt.xlabel('Признак')
plt.ylabel('Таргет');

В данном случае ответ модели задается следующим образом: $$\widehat{y}(x) = wx + b.$$

Объявляем обучаемые параметры, в данном случае у нас всего 2 скалярных параметра $w, b$. Также задаем вход $x$ и таргеты $y$ в виде torch-тензоров.

In [9]:
# создаем два тензора размера 1 с заполнением нулями, 
# для которых будут вычисляться градиенты
w = torch.zeros(1, requires_grad=True)
b = torch.zeros(1, requires_grad=True)

# Данные оборачиваем в тензоры, по которым не требуем вычисления градиента
x = torch.FloatTensor(boston.data[:, -1] / 10)
y = torch.FloatTensor(boston.target)

# по-другому:
# x = torch.tensor(boston.data[:, -1] / 10, dtype=torch.float32)
# y = torch.tensor(boston.target, dtype=torch.float32)
In [10]:
print(x.shape)
print(y.shape)
torch.Size([506])
torch.Size([506])

Задалим оптимизируемую функцию — MSE, и сделаем обратный проход loss.backward():

$$ MSE(\widehat{y}, y) = \frac{1}{n} \sum_{i=1}^n \left(\widehat{y}_i - y_i\right)^2. $$
In [11]:
def optim_func(y_pred, y_true):
    return torch.mean((y_pred - y_true) ** 2)
In [12]:
# Прямой проход
y_pred = w * x + b

# Подсчет лосса
loss = optim_func(y_pred, y)

# Вычисление градиентов 
# с помощью обратного прохода по сети 
# и сохранение их в памяти сети
loss.backward()

Здесь loss — значение функции MSE, вычисленное на этой итерации.

In [13]:
loss
Out[13]:
tensor(592.1469, grad_fn=<MeanBackward0>)

К градиентам для параметров, которые требуют градиента (requires_grad=True), теперь можно обратиться следующим образом:

In [14]:
print("dL/dw =", w.grad)
print("dL/b =", b.grad)
dL/dw = tensor([-47.3514])
dL/b = tensor([-45.0656])

Если мы посчитаем градиент $M$ раз, то есть $M$ раз вызовем loss.backward(), то градиент будет накапливаться (суммироваться) в параметрах, требующих градиента. Иногда это бывает удобно.

Убедимся на примере, что именно так все и работает.

In [15]:
y_pred = w * x + b
loss =  optim_func(y_pred, y)
loss.backward()

print("dL/dw =", w.grad)
print("dL/b =", b.grad)
dL/dw = tensor([-94.7029])
dL/b = tensor([-90.1312])

Видим, что значения градиентов стали в 2 раза больше, за счет того, что мы сложили одни и те же градиенты 2 раза.

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

In [16]:
w.grad.zero_()
b.grad.zero_()
w.grad, b.grad
Out[16]:
(tensor([0.]), tensor([0.]))

Напишем код, обучающий нашу модель.

In [17]:
def show_progress(x, y, y_pred, loss):
    '''
    Визуализация процесса обучения.
    x, y -- объекты и таргеты обучающей выборки;
    y_pred -- предсказания модели;
    loss -- текущее значение ошибки модели.
    '''

    # Избавимся от градиентов перед отрисовкой графика
    y_pred = y_pred.detach()

    # Превратим тензор размерности 0 в число, для краисивого отображения
    loss = loss.item()

    # Стираем предыдущий вывод в тот момент, когда появится следующий
    clear_output(wait=True)

    # Строим новый график
    plt.figure(figsize=(10, 7))
    plt.scatter(x, y, alpha=0.75)
    plt.scatter(x, y_pred, color='orange', linewidth=5)
    plt.xlabel('Признак')
    plt.ylabel('Таргет')
    plt.show()

    print(f"MSE = {loss:.3f}")
In [18]:
# Инициализация параметров
w = torch.zeros(1, requires_grad=True)
b = torch.zeros(1, requires_grad=True)

# Количество итераций
num_iter = 1000

# Скорость обучения для параметров
lr_w = 0.01
lr_b = 0.05

for i in range(num_iter):

    # Forward pass: предсказание модели
    y_pred = w * x + b

    # Подсчет оптимизируемой функции (MSE)
    loss = optim_func(y_pred, y)

    # Обратный проход: подсчет градиентов
    loss.backward()

    # Оптимизация: обновение параметров
    w.data -= lr_w * w.grad.data
    b.data -= lr_b * b.grad.data

    # Зануление градиентов
    w.grad.zero_()
    b.grad.zero_()


    # График + вывод MSE через каждые 5 итераций
    if (i + 1) % 5 == 0:
        show_progress(x, y, y_pred, loss)
        
        if loss.item() < 39:
            print("Готово!")
            break
MSE = 38.978
Готово!

2.3 Улучшение модели

Попробуем усложнить модель. Сделаем еще один слой.

In [19]:
# Инициализация параметров
w0 = torch.ones(1, requires_grad=True)
b0 = torch.ones(1, requires_grad=True)
w1 = torch.ones(1, requires_grad=True)
b1 = torch.ones(1, requires_grad=True)

# Функция активации
def act_func(x):
    return x * (x >= 0)

# Количество итераций
num_iter = 1000

# Скорость обучения для параметров
lr_w = 0.01
lr_b = 0.05

for i in range(num_iter):

    # Forward pass: предсказание модели
    y_pred =  w1 * act_func(w0 * x + b0) + b1

    # Подсчет оптимизируемой функции (MSE)
    loss = optim_func(y_pred, y)

    # Bakcward pass: подсчет градиентов
    loss.backward()

    # Оптимизация: обновление параметров
    w0.data -= lr_w * w0.grad.data
    b0.data -= lr_b * b0.grad.data
    w1.data -= lr_w * w1.grad.data
    b1.data -= lr_b * b1.grad.data

    # Зануление градиентов
    w0.grad.zero_()
    b0.grad.zero_()
    w1.grad.zero_()
    b1.grad.zero_()

    # График + вывод MSE через каждые 5 итераций
    if (i + 1) % 5 == 0:
        show_progress(x, y, y_pred, loss)
        
        if loss.item() < 33:
            print("Готово!")
            break
MSE = 32.994
Готово!

Полученная модель лучше описывает данные.

3. Готовые модули из PyTorch

На практике нейронные сети так не пишут, пользуются готовыми модулями. Напишем такую же нейросеть, но теперь с помощью pytorch. Для этого будем пользоваться torch.nn.

In [20]:
model = nn.Sequential(  # собираем модули в последовательность
    nn.Linear(in_features=1, out_features=1),  # кол-во признаков во входном слое 1, в выходном тоже 1
    nn.ReLU(),  # та же ф-ция активации, что и раньше, только из pytorch
    nn.Linear(in_features=1, out_features=1)  # кол-во признаков во входном слое 1, в выходном тоже 1
)

model
Out[20]:
Sequential(
  (0): Linear(in_features=1, out_features=1, bias=True)
  (1): ReLU()
  (2): Linear(in_features=1, out_features=1, bias=True)
)

Для того, чтобы работать с данной моделью, нам понадобится поменять размерность x и y.

In [21]:
x_new = x.reshape(-1, 1)
y_new = y.reshape(-1, 1)

Применим модель к нашим данным и посмотрим на результаты для первых 10 элементов.

In [22]:
model(x_new)[:10]
Out[22]:
tensor([[-0.6562],
        [-0.6562],
        [-0.6562],
        [-0.6562],
        [-0.6562],
        [-0.6562],
        [-0.6562],
        [-0.6581],
        [-0.6648],
        [-0.6568]], grad_fn=<SliceBackward0>)

Посмотрим на параметры модели с помощью функции named_parameters, которая кроме параметров, выдает также их названия.

In [23]:
for name, param in model.named_parameters():
    print(name)
    print(param.data)
0.weight
tensor([[0.4398]])
0.bias
tensor([-0.7145])
2.weight
tensor([[-0.0142]])
2.bias
tensor([-0.6562])

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

In [24]:
for p in model.parameters():
    p.data = torch.FloatTensor([[1]])
    print(p.data)
tensor([[1.]])
tensor([[1.]])
tensor([[1.]])
tensor([[1.]])

Ранее мы оптимизацию производили самостоятельно. Теперь же сделаем это с помощью оптимизатора SGD из pytorch. Установим скорость обучения на уровне 0.01 для всех параметров сразу. Также заменим нашу написанную MSE функцию на соответствуюшую из pytorch.

In [25]:
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
optim_func = nn.MSELoss()

Обучим полученную модель на наших данных. Теперь обновления значений параметров происходят с помощью вызова optimzer.step(), а зануление градиентов — optimizer.zero_grad().

In [26]:
# Количество итераций
num_iter = 10000

for i in range(num_iter):

    # Forward pass: предсказание модели
    y_pred =  model(x_new)

    # Подсчет оптимизируемой функции (MSE)
    loss = optim_func(y_pred, y_new)

    # Bakcward pass: подсчет градиентов
    loss.backward()

    # Оптимизация: обновление параметров
    optimizer.step()

    # Зануление градиентов
    optimizer.zero_grad()

    # График + вывод MSE через каждые 5 итераций
    if (i + 1) % 5 == 0:
        show_progress(x, y, y_pred, loss)
        
        if loss.item() < 35:
            print("Готово!")
            break
MSE = 34.986
Готово!

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