Урок 3 - метод обратного распространения ошибки¶

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

Линейная алгебра с numpy¶

Вспомним самые базовые операции с матрицами и их реализацию в numpy:

In [ ]:
import numpy as np

a = np.array([[1, 2, 3],
              [4, 1, 2]])

b = np.array([[2, 3],
              [5, 6],
              [1, 2]])
In [ ]:
# перемножение матриц

print(a.dot(b))
[[15 21]
 [15 22]]
In [ ]:
# транспонирование матриц

print(a.T)
[[1 4]
 [2 1]
 [3 2]]
In [ ]:
# поэлементное умножение матриц

print(a.T * b)
[[ 2 12]
 [10  6]
 [ 3  4]]

Вспомним, как работает дифференцирование матриц¶

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

Рассмотрим несколько важных операций и способы их дифференцирования:

Умножение матриц: Если у нас есть две матрицы A размером m x n и B размера n x p, их произведение будет матрицей C = A * B размера m x p. Для вычисления производной по отношению к элементам матрицы C можно использовать правило дифференцирования сложной функции (правило цепочки) и правило произведения матриц:
$\frac{\Delta C}{\Delta A_{ij}} = \sum_{p}^{k=1}\frac{\Delta C_{ik}}{\Delta A_{ik}} = \sum_{p}^{k=1} B_{jk}$

Сложение матриц: Пусть у нас есть две матрицы A и B одинакового размера. Их сумма C = A + B также будет матрицей того же размера. Производная суммы по отношению к элементам равна единице:
$\frac{\Delta C}{\Delta A_{ij}} = \frac{\Delta C_{ij}}{\Delta A_{ij}} = 1$

Транспонирование: производная транспонированной матрицы A равно транспонированной матрице производных A.

Матричные функции: Дифференцирование функций от матриц (например, сигмоиды, гиперболический тангенс, softmax и др.) выполняется поэлементно, а затем может быть распространено с помощью правила цепочки.

Вспомним правило цепочки для вычисления производных сложных функций:
Если мы имеем функцию y = f(u) и u = g(x), т.е. y = f(u) - сложная функция, то $\frac{\Delta y}{\Delta x} = \frac{\Delta y}{\Delta u} \frac{\Delta u}{\Delta x}$, иначе говоря, $f'=f'(g(x))g'(x)$

Таким образом, если мы имеем функцию $f(f(X W_{1}) W_{2})$, где X и Wn - матрицы размерности, допустимой для вычисления функции, и функцию ошибки для этой функции $L(W_{1}, W_{2}) = y - f(f(X W_{1}) W_{2})$, где X и y - константы, то частные производные по L будут иметь следующий вид: $W_{2}=o_{2}^T ((y - o_{2})*f'(o_{1}W_{2}))$, $W_{1}=X^T(W_{2}'o_{1}^T * f'(o_{1}))$ где $o_{1}=f(X W_{1})$, а $o_{2}=f(f(X W_{1}) W_{2})$.

Подобный алгоритм вычисления производных мы можем обобщить на любую функцию вида $f(f..(f(XW_{1})W_{2})..W_{n})$, и для $W_{i}'=W_{i-1}^T(o_{i}'W_{i}^T * f'(o_{i}))$.

Видно, что для вычисления производной по $W_{i}$, нам небходимо знать производную i-ого выхода, для которого нужно знать производную либо $W_{i+1}$, либо производную по выходной функции ошибки, а также помнить значения всех выходов. Соответственно, алгоритм вычисления производной всех параметров должен помнить каждый i-ый выход, а также вычислять производную "с конца". Поэтому подобный алгоритм вычисления производных называется backward-propagation, или же метод "обратного распространения ошибки". На его основе как раз и реализовано обучение нейронных сетей.


Реализуем класс для обучения простой полносвязаной сети¶

Имеющихся у нас на данный момент теоретических знаний достаточно для реализации своей первой нейронной сети, состоящей из нескольких слоёв и нелинейной функции активации. Подобные нейронные сети ещё называются "полносвязными", потому что каждый вес i-ого слоя всегда влияет на каждый выход i-ого слоя.
В качестве функции активации мы будем использовать сигмоиду, имеющую вид $a(x) = \frac{1}{1+e^{-x}}$.
В качестве решаемой задачи у мы воспользуемся классическим примером, использующимся при изучении нейронных сетей - моделирование логической функции XOR. Дело в том, что XOR - нелинейная функция, значит её нельзя описать с помощью нейронной сети, имеющий только один слой (или же модели линейной регрессии).

In [ ]:
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
y = np.array([[0], [1], [1], [0]])
In [ ]:
# реализуем функцию активации и производную для неё

def sigmoid(x):
    # ваш код здесь
    return 1 / (1 + np.exp(-x))


def sigmoid_derivative(x):
    # ваш код здесь
    return x *(1 - x)

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

In [ ]:
def MSE(y_target, y_pred):
    return np.mean((y_target - y_pred)**2)

def MSE_derivative(y_target, y_pred):
    return -2 * ((y_target - y_pred)**2).mean()
In [ ]:
class NeuralNetwork:
    def __init__(self, input_size, hidden_size, output_size):
        # класс для полносвязной нейронной сети с одним скрытым слоем
        self.weights_input_hidden = np.random.rand(input_size, hidden_size)
        self.weights_hidden_output = np.random.rand(hidden_size, output_size)

    def forward(self, input_data):
        # Метод для получения предсказаний модели
        # ваш код здесь. Мы специально сохраняем значения выходов на каждом слое, чтобы использовать их при backward'е
        self.hidden_output = None
        self.output = None
        return self.output

    def backward(self, input_data, target, learning_rate):
        # Метод для изменения весов методом распространеного распространения ошибки

        # Вычисление ошибки на выходном слое
        output_error =  self.output - target # в качестве функции ошибки будет использовать просто разницу между предиктом и таргетом.
        output_delta = output_error * sigmoid_derivative(self.output)

        # Вычисление ошибки на скрытом слое
        hidden_error = np.dot(output_delta, self.weights_hidden_output.T)
        hidden_delta = hidden_error * sigmoid_derivative(self.hidden_output)

        # Обновление весов
        # ваш код здесь
        #...

    def train(self, input_data, target, learning_rate, epochs):
        for epoch in range(epochs):
            # ваш код здесь
            # ...
In [ ]:
neural_network = NeuralNetwork(2, 4, 1)
neural_network.train(X, y, learning_rate=0.1, epochs=10000)
In [ ]:
print("Final output after training:")
print(neural_network.forward(X))
Final output after training:
[[0.09794406]
 [0.91189227]
 [0.90882783]
 [0.08706259]]

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

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

In [ ]:
preds = (neural_network.forward(X) > 0.5).astype(int)
In [ ]:
preds
Out[ ]:
array([[0],
       [1],
       [1],
       [0]])

Можно заметить, что предсказания модели и целевые значения совпадают.

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

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

In [ ]: