Как я говорил ранее, в качестве основного инструмента будет использоваться нейросетевой фреймворк PyTorch. Люди, далёкие от анализа данных, думают, что нейронные сети очень сложны для понимания и требуют нескольких лет изучения. В действительности, чаще всего они представляют из себя композицию простых линейных моделей с нелинейными функциями активации. Сложности и хитрости, конечно, есть — главным образом они лежат в плоскости выбора оптимальных архитектур этих композиций и на этапе тонкой настройки параметров, однако в своей основе нейронные сети очень просты.
Соответственно, и фреймворки для построения нейронных сетей, типа PyTorch и TensorFlow, также не представляют из себя чёрных ящиков. По сути, они выполняют три главные функции:
Итак, давайте реализуем одну из самых простых и изученных моделей — линейную регрессию. Именно с неё для многих начинается погружение в мир машинного обучения. Напомню, что простая линейная регрессия моделирует зависимость между зависимой и независимой переменной при помощи уравнения , где свободный коэффициент задаёт положение прямой по оси , а угловой коэффициент , тангенс угла наклона. В матричной форме эта формула будет выглядеть как .
В качестве простого примера смоделируем линейную зависимость оценок вина от его цены, построив регрессионную модель с одним предиктором, используя набор данных из предыдущей статьи.
import pandas as pd import torch import numpy as np import math import statsmodels.api as sm import matplotlib.pyplot as plt import seaborn as sns from sklearn.metrics import r2_score wine_url = "https://nagornyy.me/datasets/wine_reviews.csv.zip" df_wine = pd.read_csv(wine_url) # удаляем вина без рейтинга или без цены df_wine.dropna(subset=["points", "price"], inplace=True) df_wine.shape
df_wine.head(2)
country | description | designation | points | price | province | region_1 | region_2 | variety | winery | |
---|---|---|---|---|---|---|---|---|---|---|
0 | US | With a delicate, silky mouthfeel and bright ac... | NaN | 86 | 23.0 | California | Central Coast | Central Coast | Pinot Noir | MacMurray Ranch |
1 | Italy | D'Alceo is a drop dead gorgeous wine that ooze... | D'Alceo | 96 | 275.0 | Tuscany | Toscana | NaN | Red Blend | Castello dei Rampolla |
Цена, как это обычно бывает, распределена не нормально, и логарифмирование частично решает эту проблему — после неё начинает угадываться линейный тренд.
y = df_wine.points.values X = np.log(df_wine.price.values)
В качестве бейзлайна давайте воспользуемся хорошо известной библиотекой statsmodels, где реализовано множество видов регрессионных моделей и много чего ещё. Пока не будем использовать никаких нейронных сетей, а решим всё по аналитически, при помощи метода наименьших квадратов, оценки которого являются наилучшими при соблюдении ряда условий, но сложность вычисления которого растёт значительно быстрее, чем увеличивается количество параметров.
model = sm.OLS(y, sm.add_constant(X)).fit()
Посмотрим, что получилось, наложив график предскзанных значений на график предсказанных и посчитав коэффициент детерминации .
predicted = model.predict(sm.add_constant(X)) r2 = r2_score(y, predicted) plt.figure(figsize=(10, 7), dpi=200) plt.title(f"$R^2$={r2:.5f}") sns.scatterplot(y, X) sns.scatterplot(predicted, X);
Выглядит неплохо — получилось, что баллы вина на 37% определяются его ценой. Причём, напомню, математически доказано, что эти оценки — самые лучшие из всех возможных.
Попробуем сделать то же самое, но в PyTorch с использованием только лишь функций перемножения матриц и автоматического дифференцирования.
Для начала переформатируем данные из вектора в матрицу с одним столбцом и сконвертируем их в то, что в мире нейросетевых фреймфорков называется тезнорами, а по сути это те же многомерные матрицы и вектора, которые теперь могут вычисляться на видеокарте.
y_tensor = torch.from_numpy(y.reshape(-1, 1)).float() X_tensor = torch.from_numpy(X.reshape(-1, 1)).float()
Затем определим функцию простой линейной регрессии. В коде она выглядит точно так же, как и на формуле. Оператор @
, если кто не знает, в Python обозначает матричное умножение.
def lin_reg_model(X, w, b): return X @ w.t() + b
Далее запишем функцию потерь — пусть это будет функция среднекватратичной ошибки , где — это истинные значения зависимой переменной, а — предсказанные. Получается, что штраф увеличивается пропорционально квадрату ошибки.
def mse(true, predicted): return ((true - predicted) ** 2).sum() / true.numel()
А в общем-то на этом почти всё. Далее надо всего лишь определить переменные, в которых будут хранится параметры модели. В нашем случае их всего два — угловой коэффициент weight
и свободный коэффициент bias
. Пока можно заполнить эти матрицы случайными числами, мы всё равно подберём их позже. Главное не забыть установить requires_grad_
на True
, после чего PyTorch начнёт записывать историю всех вычислений, в которых принимал участие этот параметр, чтобы потом взять производную по нему от итоговой сложной функции.
# по мотивами Kaiming initialization weight = torch.randn(y_tensor.shape[1], X_tensor.shape[1]) / math.sqrt(2 / y_tensor.shape[1]) bias = torch.zeros(y_tensor.shape[1]) # начинаем записывать историю вычислений weight.requires_grad_(True) bias.requires_grad_(True);
Далее вычисляем значение функции при данных параметрах (forward pass) и берём частную производную этой же функции, обёрнутую в функцию потерь по каждому из параметров (backward pass) по цепному правилу (chain rule), получая таким образом градиент — вектор, координаты которого составляют частные производные и который указывает на направление наискорейшего возрастания функции.
Знание градиента необходимо, чтобы выполнить градиентный спуск. Производная функции по параметру, как известно, показывает как сильно изменяется эта функция при изменении аргумента. Поскольку мы берём производную от функции потерь, она будет показывать, как изменение параметра влияет на ошибку предсказания, а так как мы хотим уменьшить эту ошибку, следует стремиться к тому, чтобы изменение параметра не приводило к увеличению функции. Самый простой способ сделать это — вычесть из каждого параметра производную функции по нему. Таким образом мы спускаемся по направлению антиградиента, то есть в сторону скорейшего уменьшения функции. Такой алгоритм поиска минимума фукнции называется градиентным спуском, а в целом всё это называется методом обратного распространения ошибки. Именно он лежит в основе практически всего глубокого обучения, поэтому следует представлять [2], как он работает.
learning_rate = 0.01 epochs = 10000 for epoch in range(epochs): # forward pass — вычисляем функцию потерь predictions = lin_reg_model(X_tensor, weight, bias) loss = mse(predictions, y_tensor) # backward paass — вычисляем градиент loss.backward() # вычитаем производные из параметров # записывать историю вычислений уже не нужно (no_grad) with torch.no_grad(): weight -= weight.grad * learning_rate bias -= bias.grad * learning_rate # обнуляем производные weight.grad.zero_() bias.grad.zero_() # выводим значение фукнции потерь каждые 2000 повторов if (epoch + 1) % (epochs / 10) == 0: print('epoch {}, loss {}'.format(epoch, loss.item()))
predicted = lin_reg_model(X_tensor, weight, bias) r2 = r2_score(y_tensor.squeeze().numpy(), predicted.squeeze().detach().numpy()) plt.figure(figsize=(10, 7), dpi=200) plt.title(f"$R^2$={r2:.5f}") sns.scatterplot(y_tensor.squeeze(1).numpy(), X_tensor.squeeze(1).numpy()) sns.scatterplot(predicted.squeeze(1).detach().numpy(), X_tensor.squeeze(1).numpy());
Готово! Параметры подобраны, и мы получили всё тот же равный 0.37, т.е. почти столько же, сколько при помощи метода наименьших квадратов. Конечно, модель, чьи веса подобраны градиентным спуском, немного менее точна, поскольку это численный, приближённый метод, однако он подходит для вычисления функций с миллионами параметров (у нас, напомню, их две штуки).
Чтобы построить множественную регрессию, т.е. модель с более чем одной независимой переменной, в этом коде не нужно ничего менять — достаточно лишь передать функции lin_reg_model
матрицы и не с одним, а с несколькими столбцами. Можете сделать это самостоятельно.
Стоит, однако, заметить, что в этом решении не используется множество алгоритмических хитростей, связанных с адаптивными алгоритмами градиентного спуска, помогающими найти минимум функции быстрее и надёжнее, разбиением данных на батчи для уменьшения поребления памяти и уменьшения переобучения, а также игнорируются встроенные в PyTorch модули, позволяющие лучше структурировать код, без чего сложно будет построить сложные композиции слоёв. Более правильно было бы реализовать нашу модель иначе.
from torch import nn, optim class LinearRegression(nn.Module): def __init__(self, inputSize, outputSize): super(LinearRegression, self).__init__() self.linear = nn.Linear(inputSize, outputSize) def forward(self, X): predictions = self.linear(X) return predictions model = LinearRegression(1, 1) criterion = nn.MSELoss() optimizer = optim.SGD(model.parameters(), lr=learning_rate) for epoch in range(epochs): optimizer.zero_grad() predictions = model(X_tensor) loss = criterion(predictions, y_tensor) # get gradients loss.backward() # update parameters optimizer.step() if (epoch + 1) % (epochs / 10) == 0: print('epoch {}, loss {}'.format(epoch, loss.item()))
Сейчас особой разницы нет, но в будущем лучше писать именно в таком стиле, т.к. он позволяет легко менять методы оптимизации, предоставляет возможность легко строить композиции слоёв и много чего ещё. Попробуем этот стиль на новой моделе — на этот раз классификаторе.
Комментарии