Всем привет. Меня зовут Алмаз Хуснутдинов. В этой статье я рассказываю про алгоритм обратного распространения ошибки, который используется для обучения нейросетей.
Содержание: архитектура простой нейросети и инициализация переменных, прямое распространение ручной расчет, вывод производных, вывод алгоритма, обратное распространение ручной расчет, реализация простой архитектуры нейросети и задача «логическое или», реализация класса для многослойной нейросети и изображения MNIST.
Архитектура нейронной сети
В этом примере будет показана нейросеть для задачи «логическое или». На вход нейросети подается пара чисел — вектор признаков (0, 1), а на выходе должна получится единица. Первый выходной нейрон обозначает класс для «1», а второй — класс для «0».
- вектор входного слоя,
- входные значения скрытого слоя,
- вектор выходных значений скрытого слоя,
- входные значения выходного слоя,
- вектор выходного слоя,
- вектор целевых значений (метки классов).
Матрица весов между входным и скрытым слоями:
Матрица весов между скрытым и выходным слоями:
Функция активации сигмоида:
Функция ошибки MSE:
i — номер выходного нейрона, 2 — число выходных нейронов. Обычно еще все делится на общее число выходных признаков (выходных нейронов), но в этом примере я сделал без этого.
Прямое распространение
Все вычисления проводились с точностью 6 знаков после запятой, после проведения всех вычислений (в том числе и в обратном распространении) я оставил только 2 знака после запятой, я ничего не округлял.
Для задачи «логическое или» возможны 2 варианта значения функции: 1 или 0. Поэтому на выходе будет 2 нейрона. В случае для чисел 0 и 1 значение функции «логическое или» будет равно 1, поэтому на выходе должна получиться единица
Значение функции ошибки:
Оптимизация нейросети
Чтобы уменьшить значение функции ошибки (оптимизировать), можно использовать метод градиентного спуска (этот метод не единственный способ оптимизации). Этот метод заключается в том, что нужно вычислить значение частной производной для каждого параметра относительно минимизируемой функции (функции ошибки), а потом вычесть это значение, домножив на шаг градиентного спуска - .
Нужно вычитать значение производной для того, чтобы если это значение возрастает (в точке (, )), можно было сдвинуть значение параметра влево (так как значение производной будет положительным), и если значение убывает (в точке (, ), то сдвинуть значение параметра вправо (так как значение производной будет отрицательным, и при наложении двух минусов получится плюс). Таким образом значение функции ошибки будет уменьшено, так как значение параметров будет уменьшено.
Формула для изменения параметра:
Производная сигмоиды по z
В итоге производная сигмоиды по аргументу:
Частные производные по w5 и w1
Частная производная функции ошибки для :
Производные для , по :
= = =
= = =
В итоге производная для :
Производная для :
Производные для , по :
= = = = = = = =
В итоге производная по : = +
Частные производные по z1, z2, z3, z4
Для выходного слоя:
= =
Для скрытого слоя:
=
= = = = = =
Вынесем частные производные , , , из формул для и и обобщим полученные формулы в виде алгоритма обратного распространения ошибки.
E'
=
Алгоритм обратного распространения ошибки.
Функция ошибки: , n — число нейронов на выходном слое.
Для выходных нейронов:
i — номер выходного нейрона, — i-й элемент выходного слоя.
Для скрытых нейронов:
m — число нейронов в следующем слое, — связь между нейроном i слоя h и нейроном j слоя h+1, — нейрон в текущем слое, — нейрон в следующем слое. На последнем скрытом слое в качестве будет .
Для весов:
На первом слое весов в качестве будет ( = ).
Обратное распространение
=
=
Обновление весов
— скорость обучения (шаг градиентного спуска), обычно подбирается между 0 и 1, например, 0.68.
w
Прямое распространение после изменения весов
Значение функции ошибки после изменения весов:
= = =
Значение ошибки уменьшилось и значение выходных нейронов стали другими: значение первого нейрона изменилось в сторону 1, а значение второго нейрона изменилось в сторону 0. Осталось повторить этот алгоритм много раз на разных входных данных и соответствующим им меткам, и нейросеть будет постепенно обобщать или запоминать обучающие данные до определенной степени.
В файле binary_or.py показан код с теми же значениями весов и входных данных. Там происходит обучение этой нейросети для задачи «логическое или».
Реализация
Получаем набор данных из функции binary_or, берем только первый пример, то есть вектор (0, 1), как в ручном расчете. Также можно закомментировать обрезку списков inputs, targets, labels, чтобы работать с полным набором данных.
inputs, targets, labels = binary_or()
inputs = inputs[:1]
targets = targets[:1]
labels = labels[:1]
w1 = [[0.2, 0.3], [0.4, 0.5]] # weights for h1 and h2
w2 = [[0.9, 0.8], [0.7, 0.6]] # weights for o1 and o2
lr = 1 # learning rate
test(inputs, targets, w1, w2)
for epoch in range(1):
for i, inp in enumerate(inputs):
hidden_vector, output_vector = forward(inp, w1, w2)
print(hidden_vector, output_vector)
out_der = output_derivative(output_vector, targets[i])
hid_der = hidden_derivative(hidden_vector, w2, out_der)
update_weights_matrix(inp, w1, hid_der, lr, bias=False)
update_weights_matrix(hidden_vector, w2, out_der, lr, bias=False)
print()
for i, inp in enumerate(inputs):
hidden_vector, output_vector = forward(inp, w1, w2)
print(hidden_vector, output_vector)
test(inputs, targets, w1, w2)
output:
output: [0.73, 0.68],error: 0.54
[0.574442516811659, 0.6224593312018546]
[0.7339908254341614, 0.6847278790385614]
[0.5676489083985418, 0.6172386733724463]
[0.7462017913227571, 0.6359233499954247]
output: [0.75, 0.64],error: 0.469
Здесь выводится то же самое, что приведено в ручном рассчете. Далее рассмотрим вспомогательные функции.
Функция для набора данных. Здесь 3 примера для значения «0», чтобы было равномерно, так как для значения «1» всего 3 примера.
def binary_or():
x = [[0, 1], [0, 0], [0, 0], [0, 0], [1, 0], [1, 1]] # input data
t = [[1, 0], [0, 1], [0, 1], [0, 1], [1, 0], [1, 0]] # target data
labels = [1, 0, 0, 0, 1, 1]
return x, t, labels
Функция test просто делает прямое распространение и выводит функцию ошибки.
def test(inputs, target_vector, w1, w2):
for i, inp in enumerate(inputs):
_, output = forward(inp, w1, w2)
print(f"output: {[round(o, 2) for o in output]},"
f"error: {round(mse(output, target_vector[i]), 3)}")
Функция mse. Суммируем квадраты разниц выходов нейросети и целевых значений.
def mse(output_vector, target_vector):
res = 0
for i in range(len(output_vector)):
res += (output_vector[i] - target_vector[i]) ** 2
return res
Функция forward. Матричное произведение матрицы и входного вектора inp, получается вектор z, применяем функцию сигмоиды и получается h. Потом матричное произведение , сигмоида и получается вектор o.
def forward(inp, w1, w2): # forward propagation
z = matrix_by_vector(w1, inp)
hidden_vector = vector_activation(z)
output_vector = matrix_by_vector(w2, hidden_vector)
output_vector = vector_activation(output_vector)
return hidden_vector, output_vector
Функция output_derivative. В ней рассчитываются производные выходного слоя по формуле алгоритма (которая показана выше).
def output_derivative(output_vector, target_vertor):
out_der = [0 for _ in range(len(output_vector))]
for i in range(len(output_vector)):
out_der[i] = (output_vector[i] - target_vertor[i]) * sigmoid_der(output_vector[i]) * 2
return out_der
Функция hidden_derivative. В ней рассчитываются производные скрытого слоя на основе производных с предыдущего слоя, тоже по формуле.
def hidden_derivative(current_layer, weights_matrix, next_layer_der):
current_layer_der = [0 for _ in range(len(current_layer))]
for i in range(len(current_layer)):
next_layer_der_sum = 0
for j in range(len(next_layer_der)):
next_layer_der_sum += weights_matrix[j][i] * next_layer_der[j]
current_layer_der[i] = sigmoid_der(current_layer[i]) * next_layer_der_sum
return current_layer_der
Обновление весов на основе производных скрытых слоев или выходного слоя. Каждый вес умножается на входной признак, на который он умножался при прямом распространении, и также умножается на значение производной с предыдущего шага обратного распространения.
def update_weights_matrix(input_vector, weights_matrix, out_der, lr, bias=True):
for j in range(len(out_der)): # number of output neuron
for i in range(len(input_vector)): # number of input neuron
weights_matrix[j][i] -= lr * input_vector[i] * out_der[j]
if bias: weights_matrix[j][-1] -= lr * out_der[j]
Остальные функции не буду расписывать, ссылка на папку с кодом в конце статьи.
Класс для многослойной нейросети
Этот класс нужен для создания нейросети с несколькими скрытыми слоями. В нем можно задавать число нейронов на каждом слое. На вход нужно передать список, в котором будет число нейронов для каждого слоя. Для каждого слоя создается матрица весов, для создания матрицы нужно передать число нейронов на текущем слое и на следующем. Также нужно хранить значения линейных преобразований слоев при прямом распространении и активированные значения слоев.
class Network:
def __init__(self, nn_structure): # [inp_size, hid1_size, hid2_size, ... , out_size]
self.weights = []
for layer_n in range(len(nn_structure) - 1):
w = init_weights_matrix(
inp_size=nn_structure[layer_n],
out_size=nn_structure[layer_n + 1]
)
self.weights.append(w)
self.layers_z = [[] for _ in range(len(self.weights))]
self.layers = [[] for _ in range(len(self.weights))]
В методе forward происходит прямое распространение входного вектора признаков x. Сначала вычисляется значение первого скрытого слоя, затем происходит последовательное вычисление значений всех остальных слоев. Все значения сохраняются, потом они будут использоваться для вычисления производных и обновления весов.
def _forward(self, x):
self.layers_z[0] = matrix_by_vector(self.weights[0], x)
self.layers[0] = vector_activation(self.layers_z[0])
for i in range(1, len(self.weights)):
self.layers_z[i] = matrix_by_vector(self.weights[i], self.layers[i - 1])
self.layers[i] = vector_activation(self.layers_z[i])
В методе train происходит прямое и обратное распространение и обновление весов. На вход передается входной вектор, целевой вектор и величина шага градиентного спуска. Вызывается метод forward, в котором происходит вычисление ответа нейросети. Далее вычисляется градиент для последнего слоя в функции output_derivative и итеративно от последнего к первому вычисляются градиенты для всех скрытых слоев. Затем изменяются веса для входной матрицы на основе входного вектора и для скрытых матриц на основе значений скрытых слоев и соответствующих производных для каждого слоя.
def train(self, x, t, lr=0.5): # lr - learning rate
self._forward(x)
layers_der = [[] for _ in range(len(self.layers))] # derivatives
layers_der[-1] = output_derivative(self.layers[-1], t)
for i in range(1, len(self.layers)):
idx = -(i + 1)
layers_der[idx] = hidden_derivative(self.layers[idx], self.weights[idx + 1], layers_der[idx + 1])
update_weights_matrix(x, self.weights[0], layers_der[0], lr)
for i in range(1, len(self.weights)):
update_weights_matrix(self.layers[i - 1], self.weights[i], layers_der[i], lr)
Метод predict вычисляет выход нейросети и просто возвращает выходной вектор признаков. Каждый выходной признак показывает степень соответствия входного вектора признаков к определенному классу (метке).
def predict(self, x):
self._forward(x)
return [round(self.layers[-1][i], 2) for i in range(len(self.layers[-1]))]
Далее пример того, как использовать этот класс. Создается экземпляр класса, количество слоев — 3, во входном слое 784 нейрона, в скрытом — 100 и в выходном — 10. На вход подаются пиксели изображений MNIST — черно-белые (одноканальные) изображения разрешения 28 на 28 пикселей (28*28=784). На выходе получается вектор из степеней принадлежности изображения к одному из 10 классов (цифры от 0 до 9) по шкале от 0 до 1. Ответом нейросети будет та цифра, у которой на выходе получилось максимальное значение.
model = Network(nn_structure=[784, 100, 10])
x, t, labels = mnist_100(samples_n=3)
epochs_n = 20
print("\nlabels =", labels)
test(model, x, t)
print()
for epoch in range(1, epochs_n + 1):
for i, inp in enumerate(x):
model.train(inp, t[i], lr=0.1)
print(f"epoch: {epoch}, error: {round(batch_mse(model, x, t), 4)}")
test(model, x, t)
print()
print("\nlabels =", labels)
test(model, x, t)
output:
labels = [5, 0, 4]
output: [0.42, 0.58, 0.34, 0.57, 0.74, 0.55, 0.6, 0.32, 0.44, 0.54], error: 2.6487
output: [0.41, 0.55, 0.34, 0.57, 0.78, 0.56, 0.61, 0.34, 0.43, 0.54], error: 2.9893
output: [0.44, 0.58, 0.32, 0.55, 0.76, 0.55, 0.6, 0.34, 0.46, 0.53], error: 2.2664
…
labels = [5, 0, 4]
output: [0.27, 0.04, 0.04, 0.04, 0.13, 0.63, 0.04, 0.04, 0.04, 0.04], error: 0.2373
output: [0.71, 0.04, 0.04, 0.04, 0.16, 0.18, 0.05, 0.04, 0.04, 0.04], error: 0.1556
output: [0.17, 0.04, 0.04, 0.04, 0.83, 0.17, 0.04, 0.04, 0.04, 0.04], error: 0.0992
Перед обучением нейросеть выводит хаотичные значения, которые распределены около значения 0.5, это из-за того, что среднее значение сигмоиды равно 0.5, а веса инициализируются от -1 до 1, в результате везде получается входное значение z для функции сигмоиды около нуля.
В этом примере нейросеть обучается на трех цифрах: 5, 0, 4. Для каждого изображения выведен ответ модели в виде списка из 10 чисел. Для метки «5» - первый список, для метки «4» - третий. Ответ модели, что на первом изображении цифра «5», составляет 0.55, а после обучения 0.63. Для метки «4» - до обучения 0.76, а после обучения 0.83. После обучения для каждой из трех цифр значение числа в выходном векторе должно увеличиться, а для остальных уменьшиться. Общая ошибка тоже уменьшается.
output:
epoch: 1, error: 0.9574
...
epoch: 20, error: 0.1641
Функция mnist_100. Изображения набора данных хранятся в папке digits, это первые 100 изображений набора данных MNIST. Они загружаются через matplotlib.pyplot. Каждое изображение загружается, а потом у него изменяется форма массива с 28˟28˟3 на 1˟784, и значение каждого пикселя (которое от 1 до 255) делится на 255, чтобы все значения были в диапазоне от 0 до 1. Потом загружаются метки для каждого изображения.
def mnist_100(samples_n=5):
import matplotlib.pyplot as plt
# max samples == 100
x = [] # input data
for i in range(samples_n): # loading images
img = plt.imread(f"digits/images/{i}.jpg")
img = img[:, :, 0].reshape(784) / 255 # change shape from 28x28x3 to 1x784
x.append(list(img))
t = [] # target data
labels = []
with open("digits/labels.txt", 'r') as file: # loading labels
for i in range(samples_n):
t.append([0 for _ in range(10)])
digit_label = int(file.readline())
labels.append(digit_label)
t[i][digit_label] = 1
return x, t, labels
Заключение
Я показал, как выводятся частные производные для каждой переменной и обобщил их вывод в виде алгоритма обратного распространения ошибки. Показал ручной расчет для простой нейросети и сделал для нее реализацию. Для того, чтобы понять все лучше, я показал как можно реализовать класс, чтобы описанный алгоритм можно было легко использовать для любой архитектуры нейросети прямого распространения.
Таким образом выглядит вывод и реализация нейросети с нуля. Это лишь учебный пример, чтобы разобраться в том, как все работает на низком уровне. На практике такой метод не используется — используются фреймворки для глубокого обучения. Приведенный в статье метод изучения нейросети может подойти для того, чтобы развивать мышление для исследования каких-то новых идей и алгоритмов с нуля, а также для того, чтобы начать развиваться в области создания ИИ.
Если есть вопросы, то спрашивайте. Также подписывайтесь на мой ТГК, чтобы не пропустить мои материалы на разные темы по машинному обучению и идеи о создании цифрового интеллекта.
Ссылка на папку с кодом из статьи на ГитХабе.
MaxAkaAltmer
Я давно занимаюсь нейросетями, но вот прямо с первого абзаца - абстрагируясь от этого знания, понимаю лишь одно, что читаю очередную статью очередного математика, в которой ничего не понятно, потому что математики наверное не умеют писать статьи.
Что за Т, почему он и вектор и показатель, где на картинке Z.... а дальше просто можно не читать.