Глубокое обучение: Автоматическое дифференцирование. Теория и реализация. С нуля, на Python

Всем привет. Меня зовут Алмаз Хуснутдинов. В этой статье я сделал разбор алгоритма автоматического дифференцирования для глубокого обучения. Идею для реализации я взял из книги «Грокаем глубокое обучение». Я разобрал как вычисляются производные для основных операций и показал, как сделать простую реализацию.

Содержание: граф вычислений, операции и производные по ним, прямой и обратный проход по графу ручное вычисление, реализация прямого и обратного прохода по графу, пример использования.

Введение

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

Автоматическое дифференцирование — алгоритм, который вычисляет значения частных производных переменных независимо от того, какие преобразования производятся над переменными. Этот алгоритм основан на идее обратного распространения ошибки, цепном правиле дифференцирования и аналитических формулах вычисления производных по различным операциям преобразования.

Любая функция состоит из арифметических или элементарных математических функций (степенная, логарифмическая, тригонометрическая). Ниже под f_i следует понимать арифметические функции (сложение, умножение), элементарные или составные (например сигмоида).

Пусть есть некоторая сложная функция F (нейросеть), которая состоит из множества более простых функций f_i. Каждая функция f_i принимает на вход какие-то значения (аргументы, операнды) и производит преобразования этих значений по определенной формуле, которая заранее известна (задается заранее). Каждое входное значение для любой функции f_i является результатом преобразования каких-то других функций f_i или исходным входным значением X для всей функции F.

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

Те параметры, которые мы изменяем во время оптимизации, называются изменяемыми. Каждое входное значение для функции f_i представляет собой совокупность результатов работы других функций f_i, которые могут быть умножены на свой коэффициент, называемый весом. Веса мы делаем изменяемым, но их также можно сделать не изменяемыми.

На вход функции F (нейросети) подаются входные значения. Происходит последовательное вычисление значений всех промежуточных функций f_i. В результате получается значение функции F — ответ нейросети, которое передается в функцию ошибки, также называемую функцией потерь. Вычисляется значение функции ошибки.

Задача состоит в том, чтобы вычислить значения частных производных для всех значений (переменных, параметров) функции F относительно функции ошибки E, которые участвовали в получении ее значения. Но нам не нужно изменять все параметры, мы можем выбирать лишь те, которые нам нужно изменять.

Граф вычислений

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

Граф вычислений, рассматриваемый в статье

Граф вычислений, рассматриваемый в статье

Операция — осуществление какого-либо преобразования над определенными переменными (операндами). Например, операция суммы или умножения.

Оператор — функция, которая осуществляет операцию над данными переменными (операндами). При реализации операторы задаются в виде методов класса.

Операнд — контейнер с переменной, над которой осуществляется операция. Обычно операция производится над одним или двумя операндами. При реализации в качестве операндов будут рассматриваться экземпляры класса-контейнера.

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

Узел — вершина графа, которая представляет собой операнд, полученный в результате произведенной операции (функции f_i). Каждый узел графа хранит информацию о том, какая операция была произведена при создании данного узла. То есть в результате какого преобразования был получен операнд.

Граф вычислений — в нем представлены связи между всеми узлами, которые участвовали в получении значения функции ошибки E.

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

Аналитические значения производных для различных операций

Рассмотрим несколько основных операций. Здесь рассмотрены только основные математические операции. Любые другие операции, такие как функции ошибки или активации, являются композициями основных математических операций.

Операция сложения:

Скрытый текст

В результате этой операции получается новая переменная-операнд, которую мы назвали z. Переменная a является операндом 1, а переменная b является операндом 2. Операция суммы применяется к операнду 1, а не к операнду 2, то есть значение b прибавляется к значению a, а не наоборот.

Для вычисления производной для операндов a и b понадобится знать значение производной операнда z:

z = a + b, z'_a = z'_z \cdot (1 + 0) = z'_z, z'_b = z'_z \cdot (0 + 1) = z'_z

Операция вычитания:

Скрытый текст

В результате получается переменная-операнд z. a — операнд 1, b — операнд 2. Для вычисления производной для каждого из операндов a и b понадобится знать значение производной z:

z = a - b, z'_a = z'_z (1 - 0) = z'_z, z'_b = z'_z (0 - 1) = -z'_z

Операция умножения:

z = a \cdot b, z'_a = z'_z \cdot b, z'_b = z'_z \cdot a

Операция отрицания. Эта операция меняет знак числа. Плюс на минус, а минус на плюс:

z = -a, z'_a = z'_z \cdot (-1) = -z'_z

Операция суммы:

Скрытый текст

Эта операция отлична от операции сложения. Здесь происходит последовательное суммирование всех элементов на входе. Затем создается новый операнд с полученным значением. Для каждого элемента вектора a производная относительно z будет одинаковой, поэтому нужно просто скопировать (expand — расширить, развернуть) производную по z n раз:

a = (a_1,...,a_n), z = \sum_{i = 1}^{n} a_i, z'_a = z'_{z}.expand(n) = ((z'_z)_1, ... , (z'_z)_n)

Операция транспонирования матрицы (вектора). Это просто транспонирование операнда:

Z = A^T, Z'_A = (Z'_Z)^T

Операция произведения матрицы и вектора (матрицы и матрицы):

Скрытый текст

z и x — векторы, W — матрица. Рассмотрим матрицу с двумя строками и двумя столбцами и вектор x с двумя элементами:

W = \begin{pmatrix} w_{11} & w_{12}\\ w_{21} & w_{22} \end{pmatrix}, x = \begin{pmatrix} x_1 \\ x_2 \end{pmatrix}z = W \cdot x = \begin{pmatrix} w_{11} & w_{12} \\ w_{21} & w_{22} \end{pmatrix} \cdot \begin{pmatrix} x_1 \\ x_2 \end{pmatrix} = \begin{pmatrix} w_{11} \cdot x_1 + w_{12} \cdot x_2 \\ w_{21} \cdot x_1 + w_{22} \cdot x_2 \end{pmatrix} = \begin{pmatrix} z_1 \\ z_2 \end{pmatrix}

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

В случае с матрицами, операндами будут сами матрицы и мы работаем с матричными операциями, а не с операциями над числами. Поэтому нам нужно находить производную для вектора x и матрицы W через матричные операции:

z'_W = z'_z \cdot (W \cdot x)'_W = z'_z \cdot x^T

z'_x = (W \cdot x)'_x \cdot z'_z = W^T \cdot z'_z

Z = A \cdot B, Z'_A = Z'_Z \cdot B^T, Z'_B = A^T \cdot Z'_Z

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

Операция expand (расширение, развертывание):

Скрытый текст

Не знаю как перевести на русский, может быть «экспанирование». Эта операция копирует значение исходного операнда несколько раз. Можно считать эту операцию противоположной для операции суммы. Так как для суммы происходит превращение массива чисел в одно число. А эта операция копирует значение исходного операнда несколько раз, до необходимого размера массива. В результате получается новый операнд, значение которого представляет собой массив из нескольких значений данного операнда:

a = 3, z = a.expand(4) = (3, 3, 3, 3), z'_a = \sum_{i = 1}^{4} (z'_z)_i

То есть z — вектор чисел (вектор производных), нужно сложить все элементы этого вектора. По сути, это сложение — это правило дифференцирования, которое я называю правилом суммы, которое применяется при вычислении частной производной переменной, которая участвует в нескольких преобразованиях.

Сигмоида:

f(z) = \frac{1}{1 + e^{-z}}, f'_z = z'_z \cdot f(z) \cdot (1 - f(z))

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

Обратите внимание, здесь в качестве производной, полученной на предыдущем шаге, выступает производная относительно самой себя, то есть z’_z. Во всех выражениях в качестве z’_z подразумевается E’_z, z’_z является частным случаем, если рассматривать взятие производной только для операнда z, а не для операнда E. На практике это значение передается с предыдущего шага обратного распространения ошибки.

Граф вычислений, прямой проход

Граф вычислений для нейросети с входным, скрытым и выходным слоями

Граф вычислений для нейросети с входным, скрытым и выходным слоями

Инициализация:

Скрытый текст

Пусть значения весов таковы: w_1 = 0.1, w_2= 0.2, w_3 = 0.3, w_4 = 0.4, w_5 = 0.5, w_6 = 0.6. На вход подаем значения x_1 = 0.8, x_2 = 0.9. Целевое выходное значение t=1.

На рисунке изображен граф вычислений для простой нейросети, состоящей из трех слоев. На вход подается вектор (x_1, x_2). Создается операнд со значением (x_1, x_2), и он становится первым узлом этого графа.

Далее производится операция умножения матрицы весов W_1 на входной вектор x, в результате чего получается новый операнд Z_1.

Прямое распространение:

Скрытый текст

Вычисление ошибки:

Скрытый текст

Граф вычислений, обратный проход

После того, как был получен конечный узел (в данном случае конечным является узел E), запускается обратный проход по графу (обычно это запускается через метод backward).

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

E, Y_1, Y_2:

Скрытый текст

E'_E= (1) — вектор из единицы, в качестве индекса можно также записать E'_{MSE}.

Далее происходит вызов аналогичного метода для узлов, которые участвовали в создании узла E, то есть для узлов Y_1 и Y_2. При создании узла E была произведена операция умножения, то есть операция MSE, которая представлена в виде умножения Y_1 и Y_2.

E'_{Y1} = E'_E \cdot (Y_1 \cdot Y_2)'_{Y1} = E'_E \cdot Y_2 = E'_E \cdot ((y) - (t)) = (1) \cdot ((0.66) - (1)) = (1) \cdot (-0.34) = (-0.34)

Здесь мы применили производную для операции произведения, которая описана выше, для операндов Y_1 и Y_2, в результате получился вектор (-0.34). Для операнда Y_2 получается то же самое, только для вычисления производной используется не Y_2, а Y_1. В этом примере эти операнды являются разными объектами, так как мы применяем операцию умножения, которая действует для двух операндов, а не операцию возведения в степень, которая действует для одного операнда.

Y:

Скрытый текст

Z_2:

Скрытый текст

В создании узла Y_1 участвовали узел Z_2 и операция сигмоиды. Далее нужно рассчитать производную для узла Z_2. Для этого понадобится знать аналитическое значение производной для операции сигмоиды.

(E'_{Z2})_{Y1} = (E'_Y)_{Y1} \cdot (Y)'_{Z2} = (E'_Y)_{Y1} \cdot (Y \cdot((1) - Y)) = (-0.34) \cdot (0.66 \cdot (1 - 0.66)) = (-0.076)

(E'_{Z2})_{Y2} = (E'_Y)_{Y2} \cdot (Y)'_{Z2} = (E'_Y)_{Y2} \cdot (Y \cdot((1) - Y)) = (-0.34) \cdot (0.66 \cdot (1 - 0.66)) = (-0.076)

Потом они складываются по правилу суммы:

E'_{Z2} = (E'_{Z2})_{Y1} + (E'_{Z2})_{Y2} = (-0.076) + (-0.076) = (-0.15)

Но здесь есть один нюанс. На самом деле, далее, при вычислении всех остальных производных в графе, будет использоваться не значение E'_{Z2}, а значения (E'_{Z2})_{Y1} и (E'_{Z2})_{Y2}. То есть обратный проход будет происходить два раза, сначала по операнду Y_1, а потом по операнду Y_2. И затем, значения производных по каждому операнду будут складываться по правилу суммы. Здесь мы сразу сложим для того, чтобы не делать все вычисления два раза, а сделаем только один раз. Но в алгоритме это будет происходить два раза. В разделе с реализацией я покажу, где находится этот момент.

W_2, h:

Скрытый текст

Z_1, W_1:

Скрытый текст

Далее идет Z_1, это операнд участвовал в создании h.

E'_{Z1} = E'_h \cdot h'_{Z1} = E'_h \cdot (h \cdot (1 - h)) == \begin{pmatrix} -0.075 & -0.09 \end{pmatrix} \cdot  \begin{pmatrix} 0.59 \\ 0.63 \end{pmatrix} \cdot \left(1 - \begin{pmatrix} 0.59 \\ 0.63 \end{pmatrix} \right) = \begin{pmatrix} -0.018 \\ -0.021 \end{pmatrix}

В создании Z1 участвовала матрица весов W1 и вектор признаков x. Находим производные для них по формуле вычисления производной матрицы.

E'_{W1} = E'_{Z1} \cdot x^T = \begin{pmatrix} -0.018 \\ -0.021 \end{pmatrix} \cdot \begin{pmatrix} 0.8 & 0.9 \end{pmatrix} = \begin{pmatrix} -0.014 & -0.016 \\ -0.017 & -0.019 \end{pmatrix}

Осталось только изменить веса на основе их производных.

Реализация, прямой проход

Идея простая, но реализовать не так-то просто. Начнем с создания графа вычислений и прямого прохода. Граф как таковой мы создавать не будет, а будем лишь учитывать узлы, которые участвовали в создании нового узла. Для создания нового узла необходимо знать родительские узлы и название операции, которая над ними производилась. Узлы — это структуры данных, которые обладают определенным функционалом. Поэтому для этого нужно создать класс, который обычно называют Tensor.

В статье рассмотрим не все операции. Остальные операции реализовываются аналогично (код для остальных есть).

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

Создаем класс:

Скрытый текст

class Tensor:
    def __init__(self, data, creators=None, operation_name=""):
        self.data = data
        self.creators = creators
        self.operation_name = operation_name
        self.grad = None

Передаем данные (матрица чисел), название операции и тензоры, которые участвовали в создании нового тензора (ссылки на экземпляры тензоров).

Метод для операции сложения:

Скрытый текст

При совершении любой операции создается новый операнд, мы его создаем и возвращаем.

def __add__(self, other):
    return Tensor(
        data=add_matrixes(self.data, other.data),
        creators=[self, other],
        operation_name="__add__"
    )

Матрицы записывается как двумерный список — списоки чисел внутри списка. Создаем новый тензор и передаем в него новую матрицу — результат операции сложения и тензоры, которые участвовали в операции, и название операции. self и other — это тензоры-создатели, в параметр creators передается список из двух ссылок на объекты-тензоры.

Метод для операции суммы:

Скрытый текст

def sum(self, dim=0, copies_num=None):
    if dim == 0:
        copies_num = len(self.data)  # суммируем элементы колонки для каждой колонки

    elif dim == 1:
        copies_num = len(self.data[0])  # суммируем элементы строки для каждой строки

    return Tensor(
        data=sum_matrix(self.data, dim),
        creators=[self],
        operation_name="sum_" + str(dim) + '_' + str(copies_num)
    )

Здесь создается новый тензор, в параметр data передается сумма всех чисел операнда-создателя по определенной оси массива. Создателем является только один операнд — сам тензор self. Указываем в названии операции количество элементов в оси исходной матрицы, если ось массива, по которой производится суммация, равна 0, то len (self.data), если 1, то len (self.data[0]). Потом это число будет использовано для операции развертывания. Также нужно указать ось (измерение) массива, по которой будет производиться сумма.

Метод для матричного произведения:

Скрытый текст

Здесь выполняется операция произведения матриц операндов self и other.

def dot(self, other):
    return Tensor(
        data=matrix_by_matrix(self.data, other.data),
        creators=[self, other],
        operation_name="dot"
    )

Метод для операции развертывания (расширения):

Скрытый текст

Здесь происходит создание матрицы из одинаковых векторов-столбцов или векторов-строк — это зависит от того, по какой оси происходит копирование. В функцию для создания матрицы передается вектор, который копируется copies_num раз, также указывается ось массива, по которой нужно выполнить копирование.

def expand(self, dim, copies_num, item=None):
    if dim == 0:
        item = self.data[0]

    if dim == 1:
        item = slice_column(self.data, idx=0)

    return Tensor(
        data=expand_item(item, dim, copies_num),
        creators=[self],
        operation_name="expand_" + str(dim)
    )

Остальные операции реализовываются аналогично. Операции с массивами описывать не буду — это просто операции по элементам матриц.

Реализация, обратный проход

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

Для хранения значения производной нужно поле grad в классе Tensor, это будет экземпляр класса Tensor, значения производных тоже будут представлены в виде матрицы.

Метод для запуска обратного прохода по графу вычислений:

Скрытый текст

Этим методом обладает каждый узел (операнд, тензор), каждый узел будет запускать этот метод, и будут вычисляться производные для всех операндов, которые участвовали в создании узла, из которого был вызван этот метод. При самом первом вызове этого метода будет создаваться матрица из единиц — производная операнда относительно самого себя.

def backward(self, grad=None):
    if grad is None:
        grad = Tensor(data=ones(shape=self.shape()))

    if self.grad is None:
        self.grad = Tensor(grad.data)

    else:
        self.grad += grad

Здесь будет выполняться правило дифференцирования «правило суммы». Если у узла был вызван этот метод в первый раз, то в поле grad будет присвоен новый тензор со значением производной от какого-то операнда с предыдущего шага. Если этот метод вызывается не первый раз, то будет просто прибавлено значение производной от еще какого-то операнда с предыдущего шага. Если же узел, у которого вызывается этот метод является конечным в графе вычислений, то в качестве градиента создается матрица из единиц с размером как у этого узла.

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

Сложение:

Скрытый текст

Формула для сложения: z = a + b, z'_a = z'_z, z'_b = z'_z

if self.operation_name == "__add__":
    self.creators[0].backward(Tensor(self.grad.data))  # new Tensor
    self.creators[1].backward(Tensor(self.grad.data))  # new Tensor

В этой формуле операнд a — self, b — other. Для операндов self и other, ссылки на которые хранятся в self.creators[0] и self.creators[1] мы передаем значение производной, которое представляет собой просто текущее значение производной self.grad.data для текущего узла self. Создаем новый тензор и передаем в него значение производной.

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

Умножение:

Скрытый текст

Операция умножения: z = a \cdot b, z'_a = z'_z \cdot b, z'_b = z'_z \cdot a

if self.operation_name == "__mul__":
    self_grad = grad * self.creators[1]  # new Tensor
    self.creators[0].backward(self_grad)
    other_grad = grad * self.creators[0]  # new Tensor
    self.creators[1].backward(other_grad)

Когда мы вычисляли вручную, то я сказал, мы сразу сложим производные для E’_{Z2}, здесь она будет передаваться несколько раз и складываться. В этом месте будет происходить два вызова метода и все производные будут вычислять 2 раза по веткам графа для Y_1 и Y_2, а затем складываться self.grad += grad.

Сумма:

Скрытый текст

Формула для операции суммы:

a = (a_1,...,a_n), z = \sum_{i = 1}^{n} a_i, z'_a = z'_{z}.expand(n) = ((z'_z)_1, ... , (z'_z)_n)
elif self.operation_name.startswith("sum"):
    dim, copies_num = self.operation_name.split('_')[1:]
    dim, copies_num = int(dim), int(copies_num)
    new_grad = grad.expand(dim, copies_num)  # new Tensor
    self.creators[0].backward(new_grad)

Поскольку все элементы вектора были превращены в одно число, то нам нужно развернуть это число обратно — сделать вектор из копий значений производной. Создаем новый тензор со значением производной, развертываем — делаем указанное количество копий, указанное в строке self.operation_name[3:] и указываем ось массива, и вызываем обратное распространение у тензора-создателя self, передаем в качестве градиента только что созданный градиент.

Операция «сигмоида»:

Скрытый текст

Формула для производной сигмоиды: f'_z = z'_z \cdot f(z) \cdot (1 - f(z))

Обратное распространение через операцию сигмоиды:

elif self.operation_name == "sigmoid":
    ones_tensor = Tensor(data=ones(shape=self.shape()))
    new_grad = grad * self * (ones_tensor - self)  # new Tensor
    self.creators[0].backward(new_grad)

При вычислении градиента на текущем шаге для любого узла участвует значение градиента grad, который передается со всех предыдущих шагов для данной ветки вычислений (ветвь графа) через его передачу в метод backward, при этом никак не затрагивая накопленный градиент self.grad. Накапливаемый градиент self.grad никак не должен участвовать в вычислении градиентов, он лишь накапливает значение градиента для своего узла. В книге, из которой я взял эту идею реализации, расчет градиентов происходит через накапливаемый градиент self.grad, из-за этого возникают трудности с тем, что один и тот же градиент участвует в обратном распространении несколько раз. И для решения этой проблемы, в книге, автор создает дополнительный функционал в виде системы идентификаторов для каждого узла.

Пример использования

Инициализация и прямое распространение:

Скрытый текст

x0 = Tensor(data=[[0.8, 0.9]])
x = x0.transpose()

w1 = Tensor(data=[[0.1, 0.3], [0.2, 0.4]])

w2 = Tensor(data=[[0.5, 0.6]])

t = Tensor(data=[[1]])

z1 = w1.dot(x)
print("z1 =", z1)

h = z1.sigmoid()
print("h =", h)

z2 = w2.dot(h)
print("z2 =", z2)

y = z2.sigmoid()
print("y =", y)

y1 = (y - t)
print("y1 =", y1)
y2 = (y - t)
print("y2 =", y2)
error = (y1 * y2).sum() #/ y.shape()[0]
print("error =", error)

print("=========================")

error.backward()
print("x0.grad =", x0.grad)
print("x.grad =", x.grad)
print("w1.grad =", w1.grad)
print("z1.grad =", z1.grad)
print("h.grad =", h.grad)
print("w2.grad =", w2.grad)
print("z2.grad =", z2.grad)
print("y.grad =", y.grad)
print("t.grad =", t.grad)
print("y1.grad =", y1.grad)
print("y2.grad =", y2.grad)
print("error.grad =", error.grad)

Вывод полученных производных:

Скрытый текст

output:
z1 = [[0.35000000000000003], [0.52]]
h = [[0.5866175789173301], [0.6271477663131956]]
z2 = [[0.6695974492465824]]
y = [[0.661413015495447]]
y1 = [[-0.33858698450455305]]
y2 = [[-0.33858698450455305]]
error = [[0.11464114607588645]]
=========================
x0.grad = [[-0.006094049283464489, -0.014026838608202242]]
x.grad = [[-0.006094049283464489], [-0.014026838608202242]]
w1.grad = [[-0.014709920330186114, -0.016548660371459377],
[-0.017021236968764897, -0.01914889158986051]]
z1.grad = [[-0.01838740041273264], [-0.02127654621095612]]
h.grad = [[-0.07582514612590609], [-0.0909901753510873]]
w2.grad = [[-0.08896072728286361, -0.09510714204646732]]
z2.grad = [[-0.15165029225181217]]
y.grad = [[-0.6771739690091061]]
t.grad = [[0.6771739690091061]]
y1.grad = [[-0.33858698450455305]]
y2.grad = [[-0.33858698450455305]]
error.grad = [[1]]

Производим все вычисления в том же порядке, как и в ручном расчете. Все производные рассчитаны так же, значит алгоритм работает.

Заключение

Также я сделал пример обратного распространения на torch с теми же результатами, файл с кодом в той же папке (только там в терминал выйдет много предупреждений).

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

У этой реализации есть существенные недостатки, она медленная и у нее не оптимизирован расход памяти, тут есть утечка памяти. Я не стал полностью переписывать пример реализации из книги «Грокаем глубокое обучение», а сделал лишь упрощенную версию, если интересна более совершенная версия, то смотрите там.

Подписывайтесь на мой канал в телеграме, чтобы не пропустить мои новые статьи на различные темы по машинному обучению и созданию цифрового интеллекта.

Ссылка на папку с кодом из статьи на ГитХабе.

© Habrahabr.ru