В части 1 этой серии руководств мы подробно остановились на некоторых мельчайших аспектах и ​​основах реализации нашей собственной библиотеки глубокого обучения на JavaScript.

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

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

Цели

  • Понимание тензоров в JavaScript
  • Преобразование однозначных операций (сложение и множественное) в тензорные операции
  • Реализуйте линейные слои, функцию активации ReLU и функцию softmax.

Код этого поста можно получить здесь:



Что такое тензоры

Тензор может быть представлен как (потенциально многомерный) массив (хотя многомерный массив не обязательно является представлением тензора, как обсуждается ниже в отношении холоров). Так же, как вектор в n - мерном пространстве представлен одномерным массивом с n компонентами ………. Википедия

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

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

Например, классы Python имеют атрибут dunder (то есть магические методы) и тому подобное, упрощающее эти операции:

  • __add__, чтобы добавить два класса с одинаковым свойством идентичности
  • __sub__ вычесть
  • __mul__ умножать
  • __div___ разделить

Эти чертовы методы упрощают реализацию тензоров, подобных этому, в Python:

class Tensors():
     def __init__(value_list):
          self.data = value_list
     def __add__(self, tensor_class):
          return self.data + tensor_class.data
     def __sub__(self,tensor_class):
           return self.data - tensor_class.data

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

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

Во-первых, давайте поговорим о вводе тензора. В Python мы структурируем входные тензоры следующим образом:

array([
    [2, 3, 4],
    [5, 6, 7],
    [8, 9,10]
])

Пока я пытался реализовать тензоры в JavaScript (во время экспериментов), первой ошибкой, которую я сделал ранее, было то, что я подумал о тензоре как о вложенном списке - как о том, как они реализованы в Python.

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

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

Но после изучения работы Андрея Карпати на ConvNet.js я обнаружил, что могу думать о матрицах / тензорах по-разному.

Лучше всего было думать об этом как о сплющенной матрице. Благодаря этой идее Андрей смог реализовать сверточные слои в JavaScript.

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

Давайте сначала создадим объект Tensor и его свойства:

В объекте Tensor n обозначает количество строк, а d обозначает количество столбцов - он также представляет depth в случае 3dim. И мы устанавливаем исходящую матрицу и ее градиент на ноль, используя функцию полезности zeros.

Теперь наше основное внимание должно быть сосредоточено на свойствах get и set - они показывают, как осуществляется доступ к значению тензора. Проиллюстрируем это на простом примере:

//given a matrix 3 x 3
[[2, 3, 4],
 [5, 6, 7],
 [8, 9, 10]]

Наша цель - уменьшить указанную выше матрицу до:

[2,3,4,5,6,7,8,9,10]

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

(col * row) + col_i

Теперь посмотрим, как это работает:

//for 3 x 3 matrix we have n_col = 3, n_rows = 3
//hence the length of the matrix is 9
//to access any of the value, let say we want to access all values for row 0
//values for row 0
3 * 0 + 0 = 0
3 * 0 + 1 = 1
3 * 0 + 2 = 2   // this are the index of the value of row 0
//value for row 1
3 * 1 + 0 = 3
3 * 1 + 1 = 4
3 * 1 + 2 = 5
//value for row 3
3 * 2 + 0 = 6
3 * 2 + 1 = 7
3 * 2 + 2 = 8

Основная суть иллюстрации выше - помочь показать, как мы получаем доступ к значениям по индексу в тензорах.

Давайте поэкспериментируем с этим в коде, создав тензор 3 X 3:

array([[2,3,4],
       [5,6,7],
       [8,9,10])

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

[2,3,4,5,6,7,8,9,10]

Примечание: я мог бы абстрагироваться от этого от пользователя кода и позволить им вводить нормальный формат массива NumPy, а затем сгладить входной массив внутренне, но в случае этого руководства я решил оставить это для более четкого понимания.

В коде это выглядит так:

Из приведенного выше кода мы смогли получить значение в строке 1 и столбце 2.

Давайте также попробуем изменить значение в строке 1 столбце 2:

Этот метод действительно поможет нам выполнять простые скалярные произведения.

Поскольку мы создали объект Tensor для двумерного массива, давайте преобразуем нашу прежнюю операцию Add в двумерную:

В этом новом классе Add я не использовал gradv для хранения входящего градиента, но теперь он представлен как dout.

Для операции сложения вместо использования метода get в классе Tensor мы обращаемся к тензору, как к обычному массиву JavaScript: this.items.out[i] = x.out[i] + y.out[i];

Мы сделали это, поскольку два входа должны иметь одинаковую длину:

Операция с точкой выполняется в приведенном выше коде, как показано здесь:

dot += this.x.get(i,k) * this.y.get(k,j);

В операции с точкой, если у нас есть две матрицы A (2X3) и B (3X4), результат операции с точкой будет 2X4.

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

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

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

this.x.dout[this.x.d*i+k] += this.y.out[this.y.d*k+j] * b;

В приведенном выше фрагменте кода значение производной умножается на входящий градиент b.

Давайте попробуем простой пример: операция с точкой между двумя матрицами формы 2x3 и 3X2.

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

matrix A (2X3): [[6,5,4],
                 [3,2,1]]
matrix B (3X2): [[1,2],
                 [3,4],
                 [5,6]]

Если мы найдем скалярное произведение между двумя матрицами, мы должны ожидать выходную матрицу формы (2X2).

Точечный продукт между двумя матрицами будет выглядеть так:

A * B
[(6*1+5*3+4*5), (6*2+5*4+4*6)] = [41,56]
[(3*1+2*3+1*5), (3*2+2*4+1*6)] = [14,20]

Следовательно, матрица вывода из вышеуказанной операции:

[[41,56],
 [14,20]]

Итак, давайте попробуем это с помощью класса matmul:

Используя приведенный выше код, мы получаем тот же результат, который был рассчитан ранее:

[41,54,14,20]

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

Если вы помните…

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

Для скалярного произведения все еще остается тот же факт. Но поскольку мы имеем дело с тензором, производная функции f (x, y) = xy по входу имеет вид:

df/dy = transpose of x and df/dx= transpose of y

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

Следовательно, производная df/dy = gradient_in_flow * transpose of x

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

Градиент будет иметь ту же форму, что и вычисленный результат операции matmul, то есть (2X2).

Теперь, чтобы вычислить обратное распространение для тензорной переменной a:

gradient_inflow = [[1,1],
                   [1,1]]
df/da = gradient_inflow * b_transpose

Чтобы получить b транспонирование, мы инвертируем ось матрицы:

b = [[1,2],
     [3,4],
     [5,6]]
b_transpose = [[1,3,5],
               [2,4,6]]

Следовательно, df / da задается как:

[[1,1],    *   [[1,3,5],
 [1,1]]        [2,4,6]]
//just like we did the dot product before
[(1*1+1*2), (1*3+1*4), (1*5+1*6)] = [3,7,11]
[(1*1+1*2), (1*3+1*4), (1*5+1*6)] = [3,7,11]

Градиент df / da как форма (2x3), которая также имеет ту же форму, что и тензор A. Градиент тензора должен иметь ту же форму, что и сам тензор.

Теперь давайте посмотрим на код для реализации этого:

Поскольку мы смогли создать объекты Add и matmul, теперь у нас есть полный доступ для создания линейного слоя.

Линейный слой

Линейный слой состоит из простых математических операций; сложение и матмул.

Функция для линейного слоя задается как F(X) = x*W + b

Давайте продолжим и реализуем линейный слой:

Объект слоя Linear принимает входное измерение и выходное измерение, которые используются для инициализации веса и смещения.

this.W = new Tensor(in_dim,out_dim,true).randn(0,0.88); // initialize the weight        
this.b = new Tensor(out_dim,1,true).randn(0,0.88); // initialoze bia

Вес и смещение инициализируются случайной величиной, сгенерированной из распределения Гаусса со средним значением 0 и стандартным отклонением 0,88. Это обучаемые параметры

Прямой проход для линейного слоя включает использование объектов matmul и Add.

this.mult = new Matmul(x,this.W)            
this.items = new add(this.mult,this.b);

А обратное распространение выполняется с помощью функции backward:

backward: function(){                    
      this.items.grad(this.dout);                                this.items.backward(); //backprop accross the chain        
},

Из приведенного выше кода видно, что нам не нужно явно определять обратное распространение. Вместо этого для волшебства используется this.items.backward().

В частности, this.items.backward() используется для вызова операций, связанных с оператором. Например, прямая операция выглядит так matmuladd, но когда вызывается this.items.backward(), операция выглядит так addmatmul.

То есть мы вычисляем производную для операции add, а затем передаем градиент, сгенерированный операцией add, на ее вход.

Входными данными для операции add являются matmul и bias, а переданный градиент используется для вычисления производной для matmul по отношению к его входным x и весу w.

Надеюсь, теперь вы видите цепочку:

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

Благодаря этому мы можем увидеть приток градиента и убедиться, что основная операция выполнена правильно:

Если вы попытаетесь исследовать градиент a.dout, linear.W.dout, linear.b.out, вы увидите, что их значения, полученные с помощью приведенного выше кода, верны.

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

forward : function(x){                
   this.mult = new Matmul(x,this.W)            
   this.items = new add(this.mult,this.b);
   this.n = this.mult.n;           
   this.d = this.mult.d;           
   this.out = this.items.out;            
   this.dout = this.items.dout;                            
   return this;       
 },

Метод forward возвращает this - в JavaScript this - очень важное ключевое слово.

this относится к владельцу метода. С помощью этого ключевого слова переменная forwardpass может сохранять свойство линейного слоя. Кроме того, благодаря этому ключевому слову мы можем запустить обратное распространение с помощью forwardPass, используя forwardPass.backward(), и мы по-прежнему будем видеть отраженные изменения.

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

linear_layer1 = new Linear(2,3,true)
linear_layer2 = new Linear(3,2,true)
linear1_output = linear_layer1.forward(x)
linear2_output = linear_layer2.forward(linear1_output)
linea2_output.backward()

Функция активации ReLu

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

ReLU - это функция активации, которая помогает нам предотвратить насыщение градиента.

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

Функция активации ReLU представлена ​​как f (x) = max (0, x)

На изображении выше показано, как найти производную ReLU по входу.

Но для нашего дизайна помните, что всегда будет градиентный приток. следовательно, для if x> 0, dL/dx = d(relu)/dx * dL/d(relu), если мы предположим, что градиент исходит из функции L.

Следовательно, для if x> 0, dL/dx = 1 * dL/d(relu).

Таким образом, реализация ReLU выглядит так:

Попробуем реализовать это с помощью кода:

let a = new autograd.Tensor(1,5,true)
a.setFrom([1,2,3,-4,-5])
let relu = new autograd.ReLU()
let relu_pass = relu.forward(a)
console.log(relu_pass.out);
//output
[1,2,3,0,0]

Обратите внимание, что все операции (Linear, ReLu) используют одну и ту же структуру. Теперь сделаем то же самое для функции активации softmax.

Софтмакс

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

Реализация:

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

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

Мы можем создать двухслойную нейронную сеть. И, как вы можете видеть из фрагмента кода, вывод softmax в сумме дает единицу. Чтобы вычислить обратное распространение, нам просто нужно ввести индекс правильного класса в backward метод в функции softmax.

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

softmax.backward(1)
console.log(linear1.dout,linear2.dout)

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

Заключение

Это все для этой части нашей серии руководств. Некоторые ключевые моменты, о которых следует помнить:

  • this ключевое слово помогает нам включить свойство цепочки, которое является ключевым для создания вычислительного графа.
  • Чтобы реализовать весь процесс для другого языка программирования, который, естественно, не поддерживает числовые операции, вам необходимо понять, как представить тензоры самым простым способом.
  • Каждая операция имеет одинаковую базовую структуру.
  • require_grad помогает управлять потоком градиента в узел.

В следующей части мы реализуем последовательный слой, оптимизатор и функцию потерь (кросс-энтропию). Следите за обновлениями!

Чтобы получить код для всей реализации, проверьте здесь:



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

Являясь независимой редакцией, Heartbeat спонсируется и публикуется Comet, платформой MLOps, которая позволяет специалистам по данным и группам машинного обучения отслеживать, сравнивать, объяснять и оптимизировать свои эксперименты. Мы платим участникам и не продаем рекламу.

Если вы хотите внести свой вклад, отправляйтесь на наш призыв к участникам. Вы также можете подписаться на наши еженедельные информационные бюллетени (Deep Learning Weekly и Comet Newsletter), присоединиться к нам в » «Slack и подписаться на Comet в Twitter и LinkedIn для получения ресурсов, событий и гораздо больше, что поможет вам быстрее создавать лучшие модели машинного обучения.