Как я говорил во вступлении к первой части, я frontend-разработчик, и мой родной язык — JavaScript, реализовывать нашу нейросеть в рамках данной статьи мы будем именно на нем. Для начала несколько слов о структуре. За исключением различных вычисляемых свойств и методов, объект нейросети будет содержать в себе массив слоев layers, каждый слой будет содержать массив нейронов neurons, а каждый нейрон, в свою очередь, будет содержать массив “входов” — связей с нейронами предыдущего слоя inputs. Также, так как у нас есть вещи общие для всей сети, такие как активационная функция, ее производная и learning rate, а получать доступ до них нужно из каждого нейрона, условимся, что в нейроне будет ссылка _layer на слой, которому он принадлежит, а у слоя будет _network — ссылка на саму сеть.
Давайте пойдем от частного к общему и сначала опишем класс входа для нейрона.
class Input {
constructor(neuron, weight) {
this.neuron = neuron;
this.weight = weight;
}
}
Тут все довольно просто. У каждого входа есть числовой вес и ссылка на нейрон предыдущего слоя. Идем далее. Опишем класс самого нейрона.
class Neuron {
constructor(layer, previousLayer) {
this._layer = layer;
this.inputs = previousLayer
? previousLayer.neurons.map((neuron) => new Input(neuron, Math.random() - 0.5))
: [0];
}
get $isFirstLayerNeuron() {
return !(this.inputs[0] instanceof Input)
}
get inputSum() {
return this.inputs.reduce((sum, input) => {
return sum + input.neuron.value * input.weight;
}, 0);
}
get value() {
return this.$isFirstLayerNeuron
? this.inputs[0]
: this._layer._network.activationFunction(this.inputSum);
}
set input(val) {
if (!this.$isFirstLayerNeuron) {
return;
}
this.inputs[0] = val;
}
set error(error) {
if (this.$isFirstLayerNeuron) {
return;
}
const wDelta = error * this._layer._network.derivativeFunction(this.inputSum);
this.inputs.forEach((input) => {
input.weight -= input.neuron.value * wDelta * this._layer._network.learningRate;
input.neuron.error = input.weight * wDelta;
});
}
}
Давайте разберемся что тут происходит. В конструктор нейрона мы можем передать два параметра: слой, на котором этот нейрон находится и, если это не входной слой нейросети, ссылку на предыдущий слой.
В конструкторе, для каждого нейрона предыдущего слоя мы создадим вход, который свяжет нейроны и будет иметь случайный вес, и запишем все входы в массив inputs. Если же это входной слой сети, то массив inputs будет состоять из единственного числового значения, того, которое мы передадим на вход.
$isFirstLayerNeuron — вычисляемый флаг, который будет говорить нам о том, что это нейрон входного слоя. Некоторые операции производятся во всех слоях, кроме входного, так что он пригодится нам в дальнейшем.
inputSum — readonly свойство, которое предоставляет нам сумму всех входных сигналов (значений, на выходе нейронов предыдущего слоя) с примененными к ним весами.
value — выходное значение нейрона. Для всех слоев, кроме входного, это значение активационной функции от inputSum.
Также мы объявим два сеттера:
input — нужен для того, чтобы устанавливать входные значения для нейронов первого слоя.
И самое интересное — сеттер error. В нем мы рассчитываем дельту весов, и для каждого входа нейрона пересчитываем их значения, а также устанавливаем error для нейрона предыдущего слоя. Таким образом, установив ошибку на выходе сети, мы инициируем пересчет весов всех нейронных связей.
Теперь давайте посмотрим на класс слоя нейросети. Тут все намного проще.
class Layer {
constructor(neuronsCount, previousLayer, network) {
this._network = network;
this.neurons = [];
for (let i = 0; i < neuronsCount; i++) {
this.neurons.push(new Neuron(this, previousLayer));
}
}
get $isFirstLayer() {
return this.neurons[0].$isFirstLayerNeuron;
}
set input(val) {
if (!this.$isFirstLayer) {
return;
}
if (!Array.isArray(val)) {
return;
}
if (val.length !== this.neurons.length) {
return;
}
val.forEach((v, i) => this.neurons[i].input = v);
}
}
В конструктор передается число — количество нейронов, в соответствии с ним мы заполним массив neurons слоя, ссылка на предыдущий слой, чтобы передать ее в конструктор нейрона, и ссылка на саму сеть.
Также у нас есть $isFirstLayer — вычисляемый флаг, который говорит нам о том, входной ли это слой, и сеттер input, который после проверок на то, что мы вызываем его на входном слое и на вход получаем массив нужной длины, устанавливает входные данные. Он нужен для того, чтобы заполнить значениями входной слой.
И, наконец, опишем класс самой нейросети
class Network {
static sigmoid(x) {
return 1 / (1 + Math.exp(-x));
}
static sigmoidDerivative(x) {
return Network.sigmoid(x) * (1 - Network.sigmoid(x));
}
constructor(inputSize, outputSize, hiddenLayersCount = 1, learningRate = 0.5) {
this.activationFunction = Network.sigmoid;
this.derivativeFunction = Network.sigmoidDerivative;
this.learningRate = learningRate;
this.layers = [new Layer(inputSize, null, this)];
for (let i = 0; i < hiddenLayersCount; i++) {
const layerSize = Math.min(inputSize * 2 - 1, Math.ceil((inputSize * 2 / 3) + outputSize));
this.layers.push(new Layer(layerSize, this.layers[this.layers.length - 1], this));
}
this.layers.push(new Layer(outputSize, this.layers[this.layers.length - 1], this));
}
set input(val) {
this.layers[0].input = val;
}
get prediction() {
return this.layers[this.layers.length - 1].neurons.map((neuron) => neuron.value);
}
trainOnce(dataSet) {
if (!Array.isArray(dataSet)) {
return;
}
dataSet.forEach((dataCase) => {
const [input, expected] = dataCase;
this.input = input;
this.prediction.forEach((r, i) => {
this.layers[this.layers.length - 1].neurons[i].error = r - expected[i];
});
});
}
train(dataSet, epochs = 100000) {
return new Promise(resolve => {
for (let i = 0; i < epochs; i++) {
this.trainOnce(dataSet);
}
resolve();
});
}
}
В конструктор мы передаем размер входного и выходного слоев, количество скрытых слоев и learning rate.
Сеттер input - просто сахар, по сути вызывает одноименный сеттер входного слоя.
Вычисляемое свойство prediction - то, ради чего все это делалось. Выдает нам предсказание нейросети в виде массива значений нейронов выходного слоя на основе установленных входных данных.
Метод trainOnce производит единичную итерацию обучения нейросети на основе dataset - массива обучающих данных, каждый элемент которого в свою очередь является массивом, где первым элементом идет массив входных данных, а вторым - ожидаемый для них результат. Извиняюсь, если слишком усложнил описание, на примере вы увидите что это довольно просто. В нем мы устанавливаем входные данные, получаем предсказание сети, вычисляем ошибку и сообщаем ее, инициируя пересчет весов нейронных связей.
Метод train - производит необходимое количество итераций обучения сети, так называемых эпох. Оформлен в виде промиса просто для красивого синтаксиса. Для того, чтобы иметь возможность знать когда обучение завершено с помощью .then, хоть весь наш код и так будет выполняться в main thread.
Теперь у нас есть все, чтобы проверить нашу нейросеть в деле. Давайте сделаем это на классическом примере - попытаемся обучить сеть выполнять операцию XOR.
Для начала создадим инстанс сети с двумя входными нейронами и одним выходным.
const network = new Network(2, 1);
Зададим набор данных для обучения:
const data = [
[[0, 0], [0]],
[[0, 1], [1]],
[[1, 0], [1]],
[[1, 1], [0]],
];
Запустим процесс обучения, а после проверим результат на тестовых данных.
network.train(data).then(() => {
const testData = [
[0, 0],
[0, 1],
[1, 0],
[1, 1],
];
testData.forEach((input, index) => {
network.input = input;
console.log(`${input[0]} XOR ${input[1]} = ${network.prediction}`)
});
});
Что же, наша сеть дает верный результат с весьма неплохой точностью для бинарной операции. В следующей части мы запустим ее прямо в браузере и найдем ей практическое применение.