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

Если вы что-то пропустили, обязательно разобраться в этой статье. Так что вперед и сначала прочтите: https://medium.com/@cookiengineer/machine-learning-for-dummies-part-1-dbaca076ec07

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

0. Вернуться к нейронным сетям

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

Мы также знаем, что сами нейроны всегда имеют value и weights, представляющие их связи с нейронами предыдущего слоя. Когда мы говорим о предыдущем и следующем, предыдущий означает слой, расположенный левее на нашей диаграмме архитектуры.

Так, например, layers[1] в нашей вышеупомянутой сети имеет 4 neurons с 2 weights each (потому что layers[0] является входным слоем и имеет 2 нейрона).

// A neuron inside a NN where the hidden layers
// (and input layer) have each 3 neurons
let neuron = {
    value:   _random(),        // value for computation
    weights: [ 0.5, 0.5, 0.5 ] // weights for connections
};

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

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

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

1. Реализация нейронных сетей.

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

const Brain = function() {
    // TODO: Proper initialization
    this.layers = [];
    // input layer
    // this.layers[0]
    // output layer
    // this.layers[this.layers.length - 1]
    // each layer has multiple neurons
    // this.layers[l][n] is a Neuron
};

Многие спрашивают, насколько большой должна быть нейронная сеть, чтобы она заработала. Как специалист по ИНС, у меня есть что-то вроде «практического правила» для размера, аргументируя это тем, что каждый входной нейрон должен иметь возможность достигать выходного нейрона по диагонали, так что в худшем случае ситуация XOR с «самого верха» вход »к« самому нижнему выходу »может быть успешно вычислен.

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

Важно помнить, что в минимальной ситуации 1 input и 1 output нам все еще нужно как минимум 3 layers для нашей сети, имеющей один входной слой, один скрытый слой и один выходной слой.

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

Другие люди придерживаются другого «твердого» мнения по этому поводу, им нравятся математические доказательства, и «с этим не поспоришь» и бла.

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

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

  • input_size, который представляет длину нашего входного (сенсорного) массива
  • output_size, который представляет длину нашего выходного (контрольного) массива
  • layers_size, который представляет количество слоев, которые нам нужны
  • hidden_size, который представляет количество нейронов на скрытый слой

В коде приведенное выше «практическое правило» выглядит следующим образом:

let input_size  = 2; // or, whatever
let output_size = 2; // or, whatever
let layers_size = 3;
let hidden_size = 1;
if (input_size > output_size) {
    hidden_size = input_size;
    layers_size = Math.max(input_size - output_size, 3);
} else {
    hidden_size = output_size;
    layers_size = Math.max(output_size - input_size, 3);
}

2. Пример реализации мозга

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

Типичная реализация также оборачивает Math.random() во что-то, что мы можем ограничить от -1.0 до 1.0, поскольку Math.random() сам в JS (или других языках) генерирует только значения от 0.0 до 1.0.

const _random = function() {
    return (Math.random() * 2) - 1);
};

Значения и веса нейронов также могут быть отрицательными! Поэтому нам нужно помнить об этом при отладке нашего кода.

Также из первой статьи мы знаем, что нам нужно реализовать функцию активации. На данный момент достаточно сигмоида, и мы реализуем его так:

const _sigmoid = function(value) {
    return (1 / (1 + Math.exp((-1 * value) / 1)));
};

Продолжая нашу реализацию Brain, нам нужно правильно ее инициализировать. Как и раньше, мы используем те же структуры данных и массив layers[] для представления содержимого нашей нейронной сети.

Когда мы генерируем нашу нейронную сеть, мы также должны различать три разных случая:

  1. Нейроны входного слоя не имеют весов, так как они не связаны в предыдущем (левом) направлении.
  2. Нейрон каждого скрытого слоя имеет массив weights[] того же размера, что и количество нейронов из предыдущего (слева) слоя.
  3. Выходной слой идентичен по своему поведению скрытым слоям (каждый нейрон также имеет массив weights[]), но количество нейронов равно размеру массива outputs, поэтому он может отличаться по размеру от скрытого слоя и / или входной слой.

Пример реализации может выглядеть так:

const Brain = function() {
    this.layers = [];
};
Brain.prototype.initialize = function(inputs, outputs) {
    // TODO: Task for reader - Insert Rule of Thumb from above
    let input_size  = 3;
    let output_size = 2;
    let layers_size = 3;
    let hidden_size = input_size > output_size ? input_size : output_size;
    this.layers = new Array(layers_size).fill(0).map((layer, l) => {
        let prev = hidden_size;
        let size = hidden_size;
        // input layer
        if (l === 0) {
            prev = 0;
            size = input_size;
        // first hidden layer
        } else if (l === 1) {
            prev = input_size;
        // output layer
        } else if (l === layers_size - 1) {
            size = output_size;
        }
        // neuron has value and weights (for each previous neuron)
        return new Array(size).fill(0).map(_ => ({
            value:   _random(),
            weights: new Array(prev).fill(0).map(val => _random())
        }));
    });
};
Brain.prototype.compute = function(inputs) {
    // TODO: This will come up next
    return null;
};

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

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

Использовать нашу Brain реализацию довольно просто:

// API Usage
let brain = new Brain();
let data  = { inputs: [1,0], outputs: [1] };
// inititalize by reference dataset
brain.initialize(data.inputs, data.outputs);
// compute inputs and return outputs
let outputs = brain.compute(data.inputs);
console.log('computed:', outputs);
console.log('expected:', data.outputs);

Обратите внимание, что использование уже готовится к обучению с подкреплением, поэтому мы как бы реализуем метод Brain.prototype.learn(inputs, outputs) позже, когда правильно поймем обратное распространение.

Полный код вышеупомянутой реализации Brain доступен в репозитории github.

3. Внедрение расчетов с прямой связью

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

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

Поскольку Feed Forward NN * очень * легко реализовать, мы разберемся с ними, прежде чем сможем понять, как работает обратное распространение (и подкрепление в целом).

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

  1. Входной слой - это особый случай. Без весов, мы напрямую обновляем значения каждого нейрона.
  2. Для каждого скрытого слоя и нейрона выходного слоя мы отображаем и сохраняем сумму значений в переменной values и суммируем их. После этого мы активируем нейрон с помощью метода _sigmoid(value), где value - это сумма значений всех связанных нейронов.

Взяв наш пример из вышеупомянутого, нам нужно переопределить его метод Brain.prototype.compute(inputs) с помощью этого кода:

Brain.prototype.compute = function(inputs) {
    let layers = this.layers;
    // set input values
    layers[0].forEach((neuron, n) => neuron.value = inputs[n]);
    // feed forward for hidden layers + output layer
    layers.slice(1).forEach((layer, l) => {
        let prev_layer = layers[layers.indexOf(layer) - 1];
        layer.forEach(neuron => {
            // neuron.weights[p] represents connection
            let values   = prev_layer.map((prev, p) => prev.value * neuron.weights[p])
            let value    = values.reduce((a, b) => a + b, 0);
            neuron.value = _sigmoid(value);
        });
    });
    // return output values
    return layers[layers.length - 1].map(neuron => neuron.value);
};

Как мы могли вспомнить из предыдущей статьи, метод _sigmoid(value) очень важен. Он активирует каждый нейрон на основе суммы значений предыдущих нейронов.

В настоящее время neuron.value в основном существует для простых вычислений, поскольку мы не используем его повторно, за исключением создания массива outputs, который возвращается нашим методом compute(inputs). НО нам это понадобится позже для обратного распространения ошибки, чтобы мы могли учиться на входных значениях.

weight каждого нейрона представляет связь с предыдущим слоем. В приведенном выше примере кода это означает, что neuron.weights[p] эквивалентен layers[l — 1][p], поскольку это нейрон предыдущего слоя с тем же индексом.

Таким образом, каждый массив neuron.weights идентичен по размеру количеству нейронов в предыдущем слое (слой слева).

TL;DR

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

Я создал репозиторий github со всем собранным содержимым этой серии статей.

Что дальше

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

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

Есть вопросы или комментарии? Ответьте ниже.