Нейронна мережа з нуля на чистому JS
Без TensorFlow. Без бібліотек. Побудуйте повнозв'язну нейронну мережу з перших принципів: прямий прохід без матриць, активації sigmoid та ReLU, функцію втрат на основі середньоквадратичної помилки, зворотне поширення помилки за допомогою ланцюгового правила та стохастичний градієнтний спуск. Перевірте її на XOR та апроксимації функції синуса.
- Масиви та функції в JavaScript
- Похідні та ланцюгове правило з математичного аналізу
- Базове розуміння того, що таке нейронна мережа (шари, нейрони)
Структура даних шару та мережі
Кожен шар зберігає ваги, зсуви та кешовані активації, потрібні для зворотного поширення. Ми використовуємо звичайні масиви (без матриць — так простіше стежити):
function randomWeight() { return (Math.random() - 0.5) * 0.5; }
class Layer {
// inputSize: нейрони на вході; outputSize: нейрони в цьому шарі
constructor(inputSize, outputSize, activation = 'sigmoid') {
this.w = Array.from({length: outputSize}, () =>
Array.from({length: inputSize}, randomWeight)
); // w[j][i] = вага від входу i до виходу j
this.b = Array(outputSize).fill(0); // зсуви (biases)
this.activation = activation;
// Кеш для зворотного поширення (заповнюється під час прямого проходу)
this.input = null; // вхід до активації
this.z = null; // значення до активації (w·x + b)
this.a = null; // вихід після активації
}
}
class NeuralNetwork {
constructor(topology, activations) {
// topology = [2, 4, 4, 1] → 2 входи, два прихованих шари по 4, 1 вихід
this.layers = [];
for (let i = 1; i < topology.length; i++) {
this.layers.push(new Layer(topology[i-1], topology[i], activations?.[i-1] ?? 'sigmoid'));
}
}
}
Функції активації та їхні похідні
const activations = {
sigmoid: {
fn: x => 1 / (1 + Math.exp(-x)),
der: x => { const s = 1/(1+Math.exp(-x)); return s*(1-s); },
},
relu: {
fn: x => Math.max(0, x),
der: x => x > 0 ? 1 : 0,
},
tanh: {
fn: x => Math.tanh(x),
der: x => 1 - Math.tanh(x)**2,
},
linear: {
fn: x => x,
der: _ => 1,
},
};
Прямий прохід
Поширюємо вхід через кожен шар. Для кожного шару обчислюємо
z = W·x + b, а потім a = activation(z):
NeuralNetwork.prototype.forward = function(input) {
let x = input;
for (const layer of this.layers) {
layer.input = x;
const act = activations[layer.activation];
// z[j] = sum_i(w[j][i] * x[i]) + b[j]
layer.z = layer.w.map((row, j) =>
row.reduce((sum, wij, i) => sum + wij * x[i], layer.b[j])
);
// a[j] = activation(z[j])
layer.a = layer.z.map(act.fn);
x = layer.a;
}
return x; // кінцевий вихід
};
Функція втрат
// Середньоквадратична помилка: L = (1/N) * Σ(y_pred - y_true)²
function mseLoss(pred, target) {
return pred.reduce((sum, p, i) => sum + (p - target[i])**2, 0) / pred.length;
}
// dL/d(pred[i]) = 2 * (pred[i] - target[i]) / N
function mseLossGrad(pred, target) {
const n = pred.length;
return pred.map((p, i) => 2 * (p - target[i]) / n);
}
Зворотне поширення помилки
Ланцюгове правило поширює градієнти назад від виходу до входу. Для кожного шару (у зворотному порядку):
NeuralNetwork.prototype.backward = function(lossGrad) {
const grads = []; // зберігаємо градієнти ваг/зсувів для кожного шару
let delta = lossGrad; // градієнт, що надходить з попереднього шару
for (let l = this.layers.length - 1; l >= 0; l--) {
const layer = this.layers[l];
const act = activations[layer.activation];
// dL/dz[j] = delta[j] * activation'(z[j])
const dz = layer.z.map((zj, j) => delta[j] * act.der(zj));
// dL/dw[j][i] = dz[j] * input[i]
const dw = layer.w.map((row, j) => row.map((_, i) => dz[j] * layer.input[i]));
// dL/db[j] = dz[j]
const db = dz.slice();
// dL/d(input[i]) = sum_j(dz[j] * w[j][i]) — поширюємо до попереднього шару
delta = layer.input.map((_, i) =>
layer.w.reduce((sum, row, j) => sum + dz[j] * row[i], 0)
);
grads.unshift({ dw, db }); // додаємо на початок (рухаємось назад)
}
return grads;
};
Оновлення ваг методом SGD
NeuralNetwork.prototype.update = function(grads, lr = 0.01) {
for (let l = 0; l < this.layers.length; l++) {
const layer = this.layers[l];
const { dw, db } = grads[l];
// w[j][i] -= lr * dL/dw[j][i]
for (let j = 0; j < layer.w.length; j++) {
for (let i = 0; i < layer.w[j].length; i++) {
layer.w[j][i] -= lr * dw[j][i];
}
layer.b[j] -= lr * db[j];
}
}
};
// Один повний крок навчання
function trainStep(net, inputs, targets, lr) {
let totalLoss = 0;
for (let k = 0; k < inputs.length; k++) {
const pred = net.forward(inputs[k]);
totalLoss += mseLoss(pred, targets[k]);
const grad = mseLossGrad(pred, targets[k]);
const grads = net.backward(grad);
net.update(grads, lr);
}
return totalLoss / inputs.length;
}
Навчання на XOR та візуалізація
// Набір даних XOR
const XOR_X = [[0,0],[0,1],[1,0],[1,1]];
const XOR_Y = [[0],[1],[1],[0]];
// 2 входи → 4 прихованих (sigmoid) → 1 вихід (sigmoid)
const net = new NeuralNetwork([2, 4, 4, 1], ['sigmoid','sigmoid','sigmoid']);
// Цикл навчання
const canvas = document.getElementById('loss-chart');
const ctx = canvas.getContext('2d');
const lossHistory = [];
let epoch = 0;
function trainEpochs(steps = 50) {
for (let i = 0; i < steps; i++) {
const loss = trainStep(net, XOR_X, XOR_Y, 0.3);
lossHistory.push(loss);
epoch++;
}
// Простий графік втрат
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#22c55e';
ctx.lineWidth = 2;
ctx.beginPath();
lossHistory.forEach((l, i) => {
const x = i / lossHistory.length * canvas.width;
const y = (1 - Math.min(l, 1)) * canvas.height;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
if (epoch < 3000) requestAnimationFrame(() => trainEpochs(50));
}
trainEpochs();
// Перевірка після навчання
console.log('Результати XOR після навчання:');
for (let k = 0; k < XOR_X.length; k++) {
const pred = net.forward(XOR_X[k])[0].toFixed(3);
console.log(`[${XOR_X[k]}] → ${pred} (очікувано ${XOR_Y[k][0]})`);
}
// Очікуваний вивід: значення близькі до 0, 1, 1, 0
XOR — це класичний тест на «лінійну нероздільність»: один шар не здатен його розв'язати, а 2 прихованих шари — можуть. Мережа 2-4-1 зазвичай розв'язує XOR приблизно за 2000 епох при LR=0.3. Якщо вона не збігається, повторно ініціалізуйте ваги випадково (локальні мінімуми для XOR трапляються часто).