Створення нейронної мережі з нуля на JavaScript
Сучасні фреймворки глибокого навчання ховають тисячі рядків за єдиним
викликом model.fit(). Цей посібник прибирає все зайве. Ми
будуємо повнозв'язну нейронну мережу на чистому JavaScript — множення
матриць, функції активації, прямий прохід, функцію втрат за
середньоквадратичною похибкою, зворотне поширення та SGD — без жодних
зовнішніх залежностей і з повним вихідним кодом, який можна запустити в
будь-якому браузері.
1. Нейрони та шари
Окремий штучний нейрон обчислює зважену суму своїх входів плюс зсув, потім застосовує нелінійну функцію активації:
Шар — це стовпець нейронів, що паралельно обробляють той самий вхідний вектор. Усе його обчислення — це один матрично-векторний добуток:
Мережа зчіплює ці шари: вихід одного стає входом наступного. Оскільки кожен шар застосовує лінійне перетворення, за яким іде нелінійність, така композиція є універсальним апроксиматором функцій (теорема про універсальну апроксимацію, Цибенко, 1989).
2. Операції з матрицями
Для швидкості ми подаємо матриці як плоскі Float64Array. Потрібні ключові операції:
// Легка бібліотека матриць — рядково-мажорна, плоский масив
function mat(rows, cols, data) {
return { rows, cols, d: data ? new Float64Array(data) : new Float64Array(rows * cols) };
}
function matMul(A, B) {
// C = A (m×k) · B (k×n)
const C = mat(A.rows, B.cols);
for (let i = 0; i < A.rows; i++)
for (let j = 0; j < B.cols; j++) {
let s = 0;
for (let k = 0; k < A.cols; k++) s += A.d[i * A.cols + k] * B.d[k * B.cols + j];
C.d[i * B.cols + j] = s;
}
return C;
}
function matT(A) {
const B = mat(A.cols, A.rows);
for (let i = 0; i < A.rows; i++)
for (let j = 0; j < A.cols; j++)
B.d[j * A.rows + i] = A.d[i * A.cols + j];
return B;
}
function matAdd(A, b) { // додає (broadcast) вектор-стовпець b до кожного стовпця A
const C = mat(A.rows, A.cols);
for (let i = 0; i < A.rows; i++)
for (let j = 0; j < A.cols; j++)
C.d[i * A.cols + j] = A.d[i * A.cols + j] + b.d[i];
return C;
}
3. Функції активації
Sigmoid
σ(z) = 1/(1+e⁻ᶻ) — стискає до (0,1), класична, але схильна до зникнення градієнтів. σ'(z) = σ(z)(1−σ(z)).
Tanh
tanh(z) — вихід, центрований навколо нуля, у (−1,1), зазвичай кращий за сигмоїду для прихованих шарів. tanh'(z) = 1−tanh²(z).
ReLU
max(0,z) — без насичення для додатних значень, розріджена активація, проста похідна: 0 або 1. Вибір за замовчуванням для глибоких мереж.
Softmax
eᶻᵢ/Σeᶻⱼ — перетворює вектор логітів на розподіл імовірностей, використовується у вихідному шарі для багатокласової класифікації.
const activations = {
sigmoid: {
f: z => 1 / (1 + Math.exp(-z)),
df: a => a * (1 - a) // a = σ(z)
},
tanh: {
f: z => Math.tanh(z),
df: a => 1 - a * a // a = tanh(z)
},
relu: {
f: z => Math.max(0, z),
df: a => a > 0 ? 1 : 0
}
};
4. Прямий прохід
Прямий прохід пропускає вхід крізь кожен шар, зберігаючи передактивацію Z та післяактивацію A на кожному шарі — вони знадобляться під час зворотного поширення:
5. Функції втрат
Функція втрат вимірює, наскільки прогноз мережі Ŷ помилковий порівняно з істинним значенням Y:
Градієнт функції втрат за активаціями вихідного шару є відправною точкою для зворотного поширення.
6. Зворотне поширення
Зворотне поширення застосовує ланцюгове правило, щоб поширити градієнт втрат назад крізь кожен шар. Для шару ℓ (рухаючись від L униз до 1):
7. Повна реалізація мережі
// Повнозв'язна мережа — чистий JS, без залежностей
class DenseLayer {
constructor(inSize, outSize, activation = 'sigmoid') {
this.activation = activations[activation];
// Ініціалізація Ксав'є / Глоро
const scale = Math.sqrt(2 / (inSize + outSize));
this.W = mat(outSize, inSize,
Array.from({length: outSize * inSize}, () => (Math.random() * 2 - 1) * scale));
this.b = mat(outSize, 1); // нулі
this.Z = null; this.A = null; this.A_prev = null;
}
forward(A_prev) {
this.A_prev = A_prev;
this.Z = matAdd(matMul(this.W, A_prev), this.b);
this.A = mat(this.Z.rows, this.Z.cols,
this.Z.d.map(this.activation.f));
return this.A;
}
backward(delta_next) {
// delta_next — це δ із шару вище (вже помножене на W[ℓ+1]ᵀ, якщо є)
const dZ = mat(this.Z.rows, this.Z.cols,
delta_next.d.map((v, i) => v * this.activation.df(this.A.d[i])));
const m = this.A_prev.cols;
this.dW = scalarDiv(matMul(dZ, matT(this.A_prev)), m);
this.db = rowMean(dZ);
return matMul(matT(this.W), dZ); // δ для передачі шару ℓ−1
}
update(lr) {
for (let i = 0; i < this.W.d.length; i++) this.W.d[i] -= lr * this.dW.d[i];
for (let i = 0; i < this.b.d.length; i++) this.b.d[i] -= lr * this.db.d[i];
}
}
class NeuralNetwork {
constructor(...layers) { this.layers = layers; }
predict(X) {
let A = X;
for (const layer of this.layers) A = layer.forward(A);
return A;
}
train(X, Y, lr = 0.1) {
const Yhat = this.predict(X);
// Градієнт MSE: 2*(Yhat-Y)/n
const m = X.cols;
let delta = mat(Yhat.rows, Yhat.cols,
Yhat.d.map((v, i) => 2 * (v - Y.d[i]) / m));
for (let i = this.layers.length - 1; i >= 0; i--)
delta = this.layers[i].backward(delta);
for (const layer of this.layers) layer.update(lr);
return mseLoss(Yhat, Y);
}
}
// Допоміжне: ділення на скаляр, середнє по рядку
function scalarDiv(M, s) { return mat(M.rows, M.cols, M.d.map(v => v / s)); }
function rowMean(M) {
const b = mat(M.rows, 1);
for (let i = 0; i < M.rows; i++) {
let s = 0;
for (let j = 0; j < M.cols; j++) s += M.d[i * M.cols + j];
b.d[i] = s / M.cols;
}
return b;
}
function mseLoss(Yhat, Y) {
return Yhat.d.reduce((s, v, i) => s + (v - Y.d[i]) ** 2, 0) / Yhat.d.length;
}
8. Навчання на XOR
XOR — це класична лінійно нероздільна задача, яку одношаровий перцептрон розв'язати не може. Прихований шар принаймні з 2 нейронів — може:
// Набір даних XOR — 4 зразки, пакетне навчання
// Входи як стовпці (2 рядки × 4 зразки)
const X = mat(2, 4, [0,0,1,1, 0,1,0,1]);
const Y = mat(1, 4, [0,1,1,0]);
const net = new NeuralNetwork(
new DenseLayer(2, 4, 'tanh'), // прихований: 4 нейрони
new DenseLayer(4, 1, 'sigmoid') // вихідний: 1 нейрон
);
for (let epoch = 0; epoch < 10000; epoch++) {
const loss = net.train(X, Y, 0.5);
if (epoch % 1000 === 0)
console.log(`Epoch ${epoch}: loss = ${loss.toFixed(5)}`);
}
// Після навчання прогнози мають наближатися до [0, 1, 1, 0]
const pred = net.predict(X);
console.log('Predictions:', [...pred.d].map(v => v.toFixed(3)));
Запуск цього в консолі браузера навчає за мілісекунди. Після 10 000 епох мережа надійно видає значення, близькі до [0.02, 0.97, 0.97, 0.02] — правильно розв'язуючи XOR взагалі без бібліотек.