Физтех.Статистика
Скачать ipynb
Введение в анализ данных¶
PyTorch и полносвязные нейронные сети¶
1. Введение¶
В данном ноутбуке мы будем пользоваться фреймворком PyTorch, который предназначен для работы с нейронными сетями. Как установить torch
можно прочитать на официальном сайте PyTorch. Для этого выберите свою OS, и вам будет показана нужная команда для ввода в терминале. Больше подробностей о том, как работает torch
, будет рассказано на 3 курсе.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from IPython.display import clear_output
sns.set(palette='Set2')
import torch
from torch import nn
print(torch.__version__)
2.1.0+cu121
1.1 Сравнение NumPy и PyTorch-синтаксиса¶
Интерфейс torch
написан подобно интерфейсу numpy
для удобства использования. Главное различие между ними в том, что numpy
оперирует numpy.ndarray
массивами, а torch
— тензорами torch.Tensor
. Тензор в torch
, как и массив в numpy
представляет собой многомерную матрицу с элементами одного типа данных. Напишем одни и те же операции на numpy
и torch
.
numpy
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
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
в torch
, а также заглянуть в документацию. Также при возникновении проблем часто помогает зайти на pytorch forumns.
1.2 NumPy <-> PyTorch¶
Можно переводить numpy
-массив в torch
-тензор и наоборот.
Например, чтобы сделать из numpy
-массива torch
-тензор, можно поступить следующим образом
# зададим 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
-массив, причем типы переведутся соответственно табличке.
x_np = x_torch.numpy()
print(type(x_np), x_np)
<class 'numpy.ndarray'> [2 5 7 1]
1.3 Еще один пример¶
Нарисуем по сетке данную кривую на графике, используя torch
:
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 Цикл обучения модели¶
Пусть задана нейронная сеть $f(x)$, параметризуемая обучаемыми параметрами $\theta$. Для входных данных $x$ модель возвращает $\widehat{y}=f(x)$. Для обучения модели необходимо задать оптимизируемую функцию (функцию ошибки, лосс) $L(y, \widehat{y})$, которую следует минимизировать.
Процесс обучения задается следующим образом.
- Прямой проход / Forward pass:
Считаем $\widehat{y}=f(x)$ для входных данных $x$. - Вычисление оптимизируемой функции:
Вычисляем оптимизируемую функцию $L(y, \widehat{y})$. - Обратный проход / Backward pass:
Считаем градиенты по всем обучаемым параметрам $\frac{\partial L}{\partial \theta}$. - Шаг оптимизации:
Делаем шаг градиентного спуска, обновляя все обучаемые параметры.
2.2 Линейная регрессия¶
В лекциях показано, что линейную регрессию можно представить как частный случай нейрона с тождественной функцией активации.
Сделаем одномерную линейную регрессию на датасете boston. Этот датасет представляет собой набор данных конца 70-х годов прошлого века для предсказания цены недвижимости в Бостоне.
Скачиваем данные.
def load_boston():
# ссылка для скачивания данных
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]
return data, target
data, target = load_boston()
Будем рассматривать зависимость таргета, т.е. медианной стоимости домов в тысячах долларов, от последнего признака, т.е. процента населения людей с низким уровнем дохода.
plt.figure(figsize=(10, 7))
plt.scatter(data[:, -1], target, alpha=0.7)
plt.xlabel('% населения с низким уровнем дохода')
plt.title('Медианная стоимость домов в тыс. $');
В данном случае предсказание модели задается следующим образом: $$\widehat{y}(x) = wx + b,$$ где $w, b \in \mathbb{R}$ — обучаемые параметры модели. Это обычная линейная модель, и с ней мы уже работали ранее.
Объявляем обучаемые параметры. Также задаем признак $X$ и таргет $Y$ в виде torch
-тензоров.
# создаем два тензора размера 1 с заполнением нулями,
# для которых будут вычисляться градиенты
w = torch.zeros(1, requires_grad=True)
b = torch.zeros(1, requires_grad=True)
# Данные оборачиваем в тензоры, по которым не требуем вычисления градиента
x = torch.FloatTensor(data[:, -1] / 10)
y = torch.FloatTensor(target)
# по-другому:
# x = torch.tensor(boston.data[:, -1] / 10, dtype=torch.float32)
# y = torch.tensor(boston.target, dtype=torch.float32)
print(x.shape)
print(y.shape)
torch.Size([506]) torch.Size([506])
Зададим оптимизируемую функцию / функцию ошибки / лосс — MSE:
$$ \mathrm{MSE}(\widehat{y}, y) = \frac{1}{n} \sum_{i=1}^n \left(\widehat{y}_i - y_i\right)^2. $$def optim_func(y_pred, y_true):
return torch.mean((y_pred - y_true) ** 2)
После того, как мы посчитаем результат применения этой функции к нашим данным, нам необходимо посчитать градиенты по всем обучаемым параметрам, чтобы затем сделать шаг градиентного спуска. В этом нам поможет функция backward
. Вызвав backward
для результата подсчета функции ошибки loss
, мы сделаем обратный проход по всему графу вычислений и посчитаем градиенты лосса по всем обучаемым параметрам. Подробнее о том, как это работает, будет рассказано на 3 курсе.
# Прямой проход
y_pred = w * x + b
# Вычисление лосса
loss = optim_func(y_pred, y)
# Вычисление градиентов
# с помощью обратного прохода по сети
# и сохранение их в памяти сети
loss.backward()
Здесь loss
— значение функции MSE, вычисленное на этой итерации.
loss
tensor(592.1469, grad_fn=<MeanBackward0>)
К градиентам для обучаемых параметров, для которых requires_grad=True
, теперь можно обратиться следующим образом:
print("dL/dw =", w.grad)
print("dL/b =", b.grad)
dL/dw = tensor([-47.3514]) dL/b = tensor([-45.0656])
Если мы посчитаем градиент $M$ раз, то есть $M$ раз вызовем loss.backward()
, то градиент будет накапливаться (суммироваться) в параметрах, требующих градиента. Иногда это бывает удобно.
Убедимся на примере, что именно так все и работает.
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_
для градиентов.
w.grad.zero_()
b.grad.zero_()
w.grad, b.grad
(tensor([0.]), tensor([0.]))
Соберем в единый пайплайн весь рассмотренный выше процесс для совершения нескольких итераций обучения. Также напишем функцию визуализации процесса обучения.
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.title('Медианная стоимость домов в тыс. $')
plt.show()
print(f"MSE = {loss:.3f}")
# Инициализация параметров
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 Улучшение модели¶
Попробуем усложнить модель, добавив еще один слой. Тем самым модель примет следующий вид
$$\widehat{y}(x) = w_2u(x) + b_2,$$$$u(x) = \sigma(w_1x + b_1),$$$$\sigma(x) = \text{ReLU}(x) = \begin{equation*}\begin{cases}x, \; x \ge 0, \\ 0, \; \text{иначе,} \end{cases} \end{equation*}$$$w_1, b_1 \in \mathbb{R}$ — обучаемые параметры первого слоя, $w_2, b_2 \in \mathbb{R}$ — обучаемые параметры второго слоя, $\sigma(x)$ — функция активации, в данном случае ReLU
. Можно заметить, что эта функция не удовлетворяет условиям теоремы Цыбенко, тем не менее на практике она часто применяется для нейронных сетей.
# Инициализация параметров
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¶
На практике нейронные сети так не пишут, а пользуются готовыми модулями. Напишем такую же нейросеть, но теперь с помощью torch
. Для этого будем пользоваться torch.nn
.
Воспользуемся следующими модулями:
nn.Sequential
— модуль для соединения модулей последовательно, друг за другом;nn.Linear
— модуль линейного слоя (без функции активации);nn.ReLU
— модуль функции активации ReLU.
# собираем модули в последовательность
model = nn.Sequential(
# кол-во признаков во входном слое 1, в выходном тоже 1
nn.Linear(in_features=1, out_features=1),
# та же ф-ция активации, что и раньше, только из pytorch
nn.ReLU(),
# кол-во признаков во входном слое 1, в выходном тоже 1
nn.Linear(in_features=1, out_features=1)
)
model
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
.
x_new = x.reshape(-1, 1)
y_new = y.reshape(-1, 1)
Применим модель к нашим данным и посмотрим на результаты для первых 10 элементов.
model(x_new)[:10]
tensor([[0.7237], [0.7951], [0.7074], [0.6887], [0.7297], [0.7276], [0.8515], [0.9668], [1.1518], [0.9316]], grad_fn=<SliceBackward0>)
Посмотрим на обучаемые параметры модели с помощью функции named_parameters
, которая, кроме параметров, выдает также их названия. Имена 0.weight
и 0.bias
соответствуют весу $w_1$ и сдвигу $b_1$ первого слоя, аналогично, 2.weight
и 2.bias
соответствуют весу $w_2$ и сдвигу $b_2$ второго слоя.
for name, param in model.named_parameters():
print(name)
print(param.data)
0.weight tensor([[0.2941]]) 0.bias tensor([0.2293]) 2.weight tensor([[0.5835]]) 2.bias tensor([0.5044])
Заметим, что в названии параметров есть индексы $0$ и $2$, но нет индекса $1$, т.к. $0$-й и $2$-й модули в модели представлены линейными слоями, а $1$-й модуль — функцией активации, у которой нет обучаемых параметров.
Инициализируем параметры так же, как мы делали для подобной модели ранее. На этот раз воспользуемся функцией parameters
, она возвращает только параметры.
for p in model.parameters():
p.data = torch.FloatTensor([[1]])
print(p.data)
tensor([[1.]]) tensor([[1.]]) tensor([[1.]]) tensor([[1.]])
Ранее мы производили оптимизацию самостоятельно. Теперь же сделаем это с помощью оптимизатора SGD
из torch
, который реализует стохастический градиентный спуск. Он принимает на вход параметры модели, их мы можем получить, вызвав метод parameters
у модели, и скорость обучения lr
, которую мы обозначали ранее как $\eta$. У оптимизатора есть возможность задать некоторые другие аргументы, но их мы рассмотрим уже на 3 курсе.
Установим скорость обучения на уровне $0.01$ для всех параметров сразу. Также заменим нашу написанную MSE
функцию на соответствующую из torch
.
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
optim_func = nn.MSELoss()
Обучим полученную модель на наших данных. Теперь обновления значений параметров происходят с помощью вызова optimzer.step()
, а зануление градиентов — с помощью optimizer.zero_grad()
.
# Количество итераций
num_iter = 10000
for i in range(num_iter):
# Forward pass: предсказание модели по данным x_new
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 Готово!
Полученная модель довольно хорошо приближает данные, однако дольше сходится к оптимуму за счет меньшей скорости обучения для параметров сдвига.
Далее мы посмотрим примеры применения нейронных сетей на практике.