Линейная регрессия с нуля с использованием NumPy

Линейная регрессия с использованием NumPy

 

Мотивация

 

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

В этой статье мы развернем рукава и построим линейную регрессию с нуля, используя библиотеку NumPy. Вместо использования абстрактных реализаций, таких как те, которые предоставляет Scikit-Learn, мы начнем с основ.

 

Набор данных

 

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

Метод make_regression, предоставляемый библиотекой Scikit-Learn, генерирует случайные наборы данных для линейной регрессии с добавлением гауссовского шума для добавления случайности.

X, y = datasets.make_regression(
        n_samples=500, n_features=1, noise=15, random_state=4)

 

Мы генерируем 500 случайных значений с одним признаком. Следовательно, X имеет форму (500, 1), и каждое из 500 независимых значений X имеет соответствующее значение y. Таким образом, у также имеет форму (500, ).

Визуализация набора данных выглядит следующим образом:

   

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

 

Интуиция

 

Общее уравнение для линейной прямой выглядит так:

y = m*X + b

X является числовым, одномерным. Здесь m и b представляют собой градиент и y-пересечение (или смещение). Они являются неизвестными, и различные значения этих параметров могут генерировать разные линии. В машинном обучении X зависит от данных, а также значения y. У нас есть контроль только над параметрами m и b, которые действуют как параметры нашей модели. Мы стремимся найти оптимальные значения этих двух параметров, которые генерируют линию, минимизирующую разницу между предсказанными и фактическими значениями y.

Это распространяется и на случай, когда X является многомерным. В этом случае количество значений m будет равно количеству измерений в наших данных. Например, если у наших данных есть три разных признака, у нас будет три значения m, называемые весами.

Теперь уравнение примет следующий вид:

y = w1*X1 + w2*X2 + w3*X3 + b

Это может быть расширено до любого количества признаков.

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

Сначала давайте инициализируем линейную регрессию, а подробнее рассмотрим процесс оптимизации позже.

 

Инициализация класса линейной регрессии

 

import numpy as np


class LinearRegression:
    def __init__(self, lr: int = 0.01, n_iters: int = 1000) -> None:
        self.lr = lr
        self.n_iters = n_iters
        self.weights = None
        self.bias = None

 

Мы используем гиперпараметры скорости обучения и количества итераций, которые будут объяснены позже. Веса и смещения устанавливаются как None, потому что количество весовых параметров зависит от входных признаков в данных. У нас пока нет доступа к данным, поэтому мы инициализируем их как None.

 

Метод Fit

 

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

def fit(self, X, y):
        num_samples, num_features = X.shape     # Форма X [N, f]
        self.weights = np.random.rand(num_features)  # Форма W [f, 1]
        self.bias = 0

 

Независимая функция X будет представлена массивом NumPy формы (num_samples, num_features). В нашем случае форма X равна (500, 1). Каждая строка в наших данных будет иметь соответствующее значение целевой переменной, поэтому y также имеет форму (500,) или (num_samples).

Мы извлекаем это и случайным образом инициализируем веса, исходя из количества входных признаков. Так что теперь наши веса также являются массивом NumPy размером (num_features, ). Смещение является единственным значением, инициализированным нулем.

 

Прогнозирование значений Y

 

Мы используем уравнение линии, обсуждаемое выше, для расчета прогнозируемых значений Y. Однако вместо итеративного подхода к суммированию всех значений мы можем использовать векторизованный подход для более быстрого вычисления. Учитывая, что веса и значения X являются массивами NumPy, мы можем использовать матричное умножение для получения прогнозов.

X имеет форму (num_samples, num_features), а веса имеют форму (num_features, ). Мы хотим, чтобы прогнозы имели форму (num_samples, ) и соответствовали исходным значениям Y. Поэтому мы можем умножить X на веса, то есть (num_samples, num_features) x (num_features, ), чтобы получить прогнозы формы (num_samples, ).

Значение смещения добавляется в конце каждого прогноза. Это можно реализовать просто в одной строке.

# Форма y_pred должна быть N, 1
y_pred = np.dot(X, self.weights) + self.bias

 

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

Как получить оптимальные значения? Градиентный спуск.

 

Функция потерь и градиентный спуск

 

Теперь, когда у нас есть прогнозируемые и целевые значения Y, мы можем найти разницу между обоими значениями. Среднеквадратичная ошибка (MSE) используется для сравнения вещественных чисел. Уравнение выглядит следующим образом:

   

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

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

   

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

   

Мы берем градиент по отношению к каждому значению веса и затем перемещаем их в противоположную сторону от градиента. Это приближает потерю к минимуму. Как показано на изображении, градиент положителен, поэтому мы уменьшаем вес. Это приближает J(W) или потерю к минимальному значению. Следовательно, уравнения оптимизации выглядят следующим образом:

   

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

 

Реализация

 

Если мы упростим уравнение производной с помощью базового алгебраического преобразования, его очень просто реализовать.

   

Для вычисления производной мы используем две строки кода:

# X -> [ N, f ]
# y_pred -> [ N ]
# dw -> [ f ]
dw = (1 / num_samples) * np.dot(X.T, y_pred - y)
db = (1 / num_samples) * np.sum(y_pred - y)

 

dw имеет форму (num_features, ), поэтому у нас есть отдельное значение производной для каждого веса. Мы их оптимизируем отдельно. db имеет единственное значение.

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

self.weights = self.weights - self.lr * dw
self.bias = self.bias - self.lr * db

 

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

Полный цикл выглядит следующим образом:

for i in range(self.n_iters):

            # y_pred должен иметь форму N, 1
            y_pred = np.dot(X, self.weights) + self.bias

            # X -> [N,f]
            # y_pred -> [N]
            # dw -> [f]
            dw = (1 / num_samples) * np.dot(X.T, y_pred - y)
            db = (1 / num_samples) * np.sum(y_pred - y)

            self.weights = self.weights - self.lr * dw
            self.bias = self.bias - self.lr * db

 

Предсказание

 

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

def predict(self, X):
        return np.dot(X, self.weights) + self.bias

 

Результаты

 

С случайно инициализированными весами и смещением наши прогнозы были следующими:

  Изображение автора   Вес и смещение были инициализированы очень близко к 0, поэтому мы получаем горизонтальную линию. После обучения модели в течение 1000 итераций мы получаем следующее:

  Изображение автора  

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

 

Заключение

 

Вы сейчас реализовали линейную регрессию с нуля. Полный код также доступен на GitHub.

import numpy as np


class LinearRegression:
    def __init__(self, lr: int = 0.01, n_iters: int = 1000) -> None:
        self.lr = lr
        self.n_iters = n_iters
        self.weights = None
        self.bias = None

    def fit(self, X, y):
        num_samples, num_features = X.shape     # X shape [N, f]
        self.weights = np.random.rand(num_features)  # W shape [f, 1]
        self.bias = 0

        for i in range(self.n_iters):

            # y_pred должен иметь форму N, 1
            y_pred = np.dot(X, self.weights) + self.bias

            # X -> [N,f]
            # y_pred -> [N]
            # dw -> [f]
            dw = (1 / num_samples) * np.dot(X.T, y_pred - y)
            db = (1 / num_samples) * np.sum(y_pred - y)

            self.weights = self.weights - self.lr * dw
            self.bias = self.bias - self.lr * db

        return self

    def predict(self, X):
        return np.dot(X, self.weights) + self.bias

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