NanoNeuron
NanoNeuron (далее - нейрончик) - это очень упрощенная версия концепции нейронов из нейронных сетей. Нейрончик умеет конвертировать значения температуры из градусов Цельсия в градусы Фаренгейта.
Код примера содержит 7 простых функций (затрагивающих предсказание модели, вычисление стоимости, прямое/обратное распространение и обучение), позволяющих понять, как машины на самом деле "обучаются". В коде нет сторонних библиотек, внешних зависимостей или наборов данных, только чистые и простые функции.
Эти функции не являются руководством по машинному обучению. Многие концепции машинного обучения отсутствуют и упрощены! Упрощение преследует цель дать читателю самое базовое понимание того, как учатся машины, а также того, что машинное обучение - это не магия, а математика.
Чему NanoNeuron будет учиться?
Вероятно, вы слышали о нейронах в контексте нейронных сетей. Нейрончик - это как раз такой нейрон, только проще. Мы реализуем его с нуля. Для простоты мы даже не будем создавать сеть из нейрончиков. Он будет работать сам по себе, делая некоторые "магические" предсказания для нас. Мы научим его конвертировать (предсказывать) температуру в градусах Фаренгейта на основе температуры в градусах Цельсия.
Кстати, формула для такого преобразования следующая:
f = c * 1.8 + 32
Но пока наш нейрончик о ней не знает.
Модель NanoNeuron
Определим функцию моделирования нейрончика. Она реализует базовую линейную зависимость между x
и y
, которая выглядит как y = w * x + b
. Проще говоря, наш нейрончик - это "ребенок" в "школе", который учится рисовать прямую линию в системе координат XY
.
Переменные w
и b
- это параметры модели. Нейрончику известны только эти параметры линейной функции. Он "выучит" значения этих параметров в процессе обучения.
Единственная вещь, которую умеет делать нейрончик, - это имитация линейной зависимости. В методе predict()
он принимает некоторый x
и предсказывает y
. Никакой магии:
function NanoNeuron(w, b) {
this.w = w;
this.b = b;
this.predict = (x) => {
return x * this.w + this.b;
}
}
Линейная регрессия - это ты?🧐
Преобразование градусов
function celsiusToFahrenheit(c) {
const w = 1.8;
const b = 32;
const f = c * w + b;
return f;
};
Мы хотим научить нейрончика имитировать эту функцию, т.е. научить, что w = 1.8
, а b = 32
без предоставления этих значений.
Графически эту функцию можно представить следующим образом:

Генерация наборов данных
Перед обучением модели нужно сгенерировать наборы тренировочных и тестовых данных на основе функции celsiusToFahrenheit()
. Наборы состоят из пар входных значений и правильно размеченных результатов.
В реальной жизни данные, как правило, собираются, а не генерируются. Например, у нас может быть набор изображений с рукописными цифрами и набор соответствующих цифр.
Для обучения нейрончика используется тренировочный набор. Перед тем, как нейрончик вырастет и сможет принимать решения самостоятельно, мы должны объяснить ему, что такое хорошо и что такое плохо с помощью тренировочных примеров.
Тестовый набор используется для проверки того, насколько хорошо нейрончик обрабатывает данные, которых он не видел в процессе обучения. Это та точка, когда мы видим, что наш "ребенок" вырос и может принимать самостоятельные решения:
function generateDataSets() {
// xTrain -> [0, 1, 2, ...],
// yTrain -> [32, 33.8, 35.6, ...]
const xTrain = [];
const yTrain = [];
for (let x = 0; x < 100; x += 1) {
const y = celsiusToFahrenheit(x);
xTrain.push(x);
yTrain.push(y);
}
// xTest -> [0.5, 1.5, 2.5, ...]
// yTest -> [32.9, 34.7, 36.5, ...]
const xTest = [];
const yTest = [];
// Начиная с 0.5 и используя такой же шаг 1,
// который мы использовали для тренировочного набора,
// мы обеспечиваем уникальность данных
for (let x = 0.5; x < 100; x += 1) {
const y = celsiusToFahrenheit(x);
xTest.push(x);
yTest.push(y);
}
return [xTrain, yTrain, xTest, yTest];
}
Стоимость (ошибка) предсказания
Нам нужен какой-то критерий верности предсказания. Вычисление стоимости (ошибки) между правильным значением y
и prediction
(предсказанием), сделанным нейрончиком, производится по следующей формуле:
predictionCost = (y - prediction) ** 2 * 0.5
Это просто разница между двумя значениями. Чем ближе значения друг к другу, тем меньше разница. Мы используем степень 2
для избавления от отрицательных чисел, поэтому (1 - 2) ** 2
= (2 - 1) ** 2
. Деление на 2
выполняется для упрощения дальнейшей формулы обратного распространения (см. ниже):
function predictionCost(y, prediction) {
return (y - prediction) ** 2 / 2;
}
Прямое распространение
Прямое распространение (forward propagation) означает выполнение предсказаний для всех тренировочных примеров из наборов xTrain
и yTrain
и вычисление средней стоимости этих предсказаний.
Мы позволяет нейрончику играть в "угадайку". Он может сильно ошибаться. Средняя стоимость покажет нам, насколько некорректной является наша модель. Эта стоимость очень важна, поскольку влияет на параметры w
и b
, которыми оперирует нейрончик. Повторное выполнение прямого распространения показывает, стал ли нейрончик умнее после соответствующих изменений.
Средняя стоимость вычисляется по такой формуле:

Где m
- это количество тренировочных примеров (100
в нашем случае).
function forwardPropagation(model, xTrain, yTrain) {
const m = xTrain.length;
const predictions = [];
let cost = 0;
for (let i = 0; i < m; i += 1) {
const prediction = nanoNeuron.predict(xTrain[i]);
cost += predictionCost(yTrain[i], prediction);
predictions.push(prediction);
}
// Нас интересует средняя стоимость
cost /= m;
return [predictions, cost];
}
Обратное распространение
После того, как мы узнали, насколько верными являются предсказания модели (на основе средней стоимости), как нам сделать их более точными?
Ответ - обратное распространение (backward propagation). Обратное распространение - это процесс оценки стоимости предсказания и модификации параметров w
и b
таким образом, чтобы будущие предсказания были более точными.
В этом месте машинное обучение выглядит как магия. Ключевой концепцией здесь является производная (derivative), которая показывает, какой шаг необходимо предпринять, чтобы подобраться к минимальной функции стоимости (minimum cost function).
Помните, что нахождение минимальной функции стоимости - это конечная цель обучения. Если мы нашли значения w
и b
, которые делают среднюю стоимость маленькой, значит, модель будет делать хорошие и точные предсказания.
Производные - это большая и отдельная тема, выходящая за рамки нашей беседы. MathIsFun - отличный ресурс для начала погружения в эту тему.
Производная, по сути, является касательной к кривой функции, которая указывает в направлении минимума функции:

Из приведенного графика следует, что если мы находимся в точке (x=2, y=4)
, то кривая "говорит" нам двигаться влево и вниз для достижения минимума функции.
Производные функции averageCost()
для параметров w
и b
выглядят так:


Где m
- количество тренировочных примеров (100
в нашем случае).
function backwardPropagation(predictions, xTrain, yTrain) {
const m = xTrain.length;
// В начале мы не знаем, как менять параметры 'w' и 'b'.
// Поэтому устанавливаем шаг изменения для каждого параметра в 0
let dW = 0;
let dB = 0;
for (let i = 0; i < m; i += 1) {
dW += (yTrain[i] - predictions[i]) * xTrain[i];
dB += yTrain[i] - predictions[i];
}
// Нас интересует средняя дельта каждого параметра
dW /= m;
dB /= m;
return [dW, dB];
}
Обучение модели
Теперь мы знаем, как оценивать корректность модели для всех тренировочных примеров (прямое распространение). Мы также знаем, как применять небольшие модификации параметров w
и b
модели (обратное распространение). Но проблема состоит в том, что однократного запуска прямого и обратного распространений недостаточно для того, чтобы наша модель извлекла какие-то уроки из тренировочных данных. Это можно сравнить с одним днем обучения ребенка в школе. Ребенок ходит в школу не однажды, а день за днем и год за годом, чтобы чему-нибудь научиться.
Поэтому распространения следует повторять много раз. Это как раз то, что делает функция trainModel()
. Это как учитель для нашего нейрончика:
- он проводит некоторое время (
epochs
) с нашим глупым нейрончиком и пытается его чему-то научить - он использует специальные "книги" (наборы данных
xTrain
иyTrain
) для обучения - он заставляет нейрончика учиться усерднее (быстрее) с помощью параметра оценки обучения
alpha
Несколько слов об alpha
. Это просто произведение dW
и dB
, вычисленных в процессе обратного распространения. Производная указывает нам направление, в котором мы должны двигаться для достижения минимума функции стоимости (положительные/отрицательные знаки dW
и dB
), а также скорость, с которой мы должны двигаться в этом направлении (абсолютные значения dW
и dB
). Нам нужно умножить эти шаги на alpha
, чтобы ускорить или замедлить наше движение к минимуму. При использовании больших значений для alpha
можно просто перепрыгнуть минимум и никогда его не найти.
Если использовать аналогию с учителем, то можно сказать, что чем сильнее учитель будет давить на ребенка, тем быстрее он будет учиться, но если учитель будет давить слишком сильно, то у ребенка случится нервный срыв и больше учиться он не сможет 🤯
Параметры w
и b
будут обновляться следующим образом:
w = w + alpha * dW
b = b + alpha * dB
Функция обучения:
function trainModel({model, epochs, alpha, xTrain, yTrain}) {
// История обучения
const costHistory = [];
// Перебираем эпохи
for (let epoch = 0; epoch < epochs; epoch += 1) {
// Прямое распространение
const [predictions, cost] = forwardPropagation(model, xTrain, yTrain);
costHistory.push(cost);
// Обратное распространение
const [dW, dB] = backwardPropagation(predictions, xTrain, yTrain);
// Модифицируем параметры модели для повышения точности предсказаний
nanoNeuron.w += alpha * dW;
nanoNeuron.b += alpha * dB;
}
return costHistory;
}
Вместе веселее
Применим созданные функции.
Создаем экземпляр NanoNeuron
. В данный момент нейрончику неизвестны значения w
и b
. Устанавливаем их произвольно:
const w = Math.random(); // например -> 0.9492
const b = Math.random(); // например -> 0.4570
const nanoNeuron = new NanoNeuron(w, b);
Генерируем наборы данных:
const [xTrain, yTrain, xTest, yTest] = generateDataSets();
Обучаем модель небольшими шагами (0.0005
) на протяжении 7000
эпох. Эти значения были определены эмпирическим путем, не стесняйтесь их менять:
const epochs = 70000;
const alpha = 0.0005;
const trainingCostHistory = trainModel({ model: nanoNeuron, epochs, alpha, xTrain, yTrain });
Проверяем функцию стоимости в процессе обучения. Мы ожидаем, что стоимость после обучения будет значительно ниже, чем до него. Это будет означать, что нейрончик стал умнее. Противоположное также возможно:
console.log('Стоимость до обучения:', trainingCostHistory[0]); // например, 4694.3335043
console.log('Стоимость после обучения:', trainingCostHistory[epochs - 1]); // например, 0.0000024
Графически снижение стоимости выглядит так (ось x
- количество эпох):

Взглянем на параметры модели. Мы ожидаем, что w
и b
нейрончика будут близки к истинным (w = 1.8
и b = 32
):
console.log('Параметры нейрончика:', { w: nanoNeuron.w, b: nanoNeuron.b }); // например -> { w: 1.8, b: 31.99 }
Оцениваем точность модели на тестовых данных, чтобы увидеть, как хорошо нейрончик справляется с обработкой неизвестных данных. Мы ожидаем, что стоимость тестовых предсказаний будет близка к стоимости тренировочных предсказаний:
[testPredictions, testCost] = forwardPropagation(nanoNeuron, xTest, yTest);
console.log('Стоимость тестовых предсказаний:', testCost); // например -> 0.0000023
Теперь, поскольку наш "ребенок" хорошо показал себя при обучении в "школе" и научился правильно обрабатывать данные, которых он не видел, мы можем назвать его "умным" и задать ему парочку вопросов. В этом и заключалась цель обучения:
const tempInCelsius = 70;
const customPrediction = nanoNeuron.predict(tempInCelsius);
console.log(`Нейрончик "думает", что ${tempInCelsius}°C в градусах Фаренгейта:`, customPrediction); // -> 158.0002
console.log('Правильный ответ:', celsiusToFahrenheit(tempInCelsius)); // -> 158
Очень близко! Наш нейрончик хорош, но не идеален, как все люди :)
// Модель NanoNeuron (нейрончика).
// Она реализует базовую линейную зависимость между 'x' и 'y': y = w * x + b.
// Проще говоря, наш нейрончик - это "ребенок", умеющий рисовать прямую линию в системе координат XY.
// w, b - параметры модели
class NanoNeuron {
constructor(w, b) {
// Нейрончику известны только эти два параметра линейной функции.
// Значения этих параметров будут определяться нейрончиком в процессе обучения
this.w = w
this.b = b
}
// Все, что умеет нейрончик, - имитировать линейную зависимость.
// Он принимает некоторый 'x' и предсказывает 'y'. Никакой магии
predict(x) {
return x * this.w + this.b
}
}
// Конвертирует градусы Цельсия в градусы Фаренгейта по формуле: f = 1.8 * c + 32.
// Мы хотим научить нейрончика имитировать эту функцию, т.е.
// научить, что W = 1.8, а B = 32 без предоставления этих значений.
// c - температура в градусах Цельсия
// f - вычисленная температура в градусах Фаренгейта
const W = 1.8
const B = 32
function celsiusToFahrenheit(c) {
const f = c * W + B
return f
}
// Генерирует обучающий и тестовый наборы данных с помощью функции celsiusToFahrenheit().
// Наборы состоят из пар входных значений и правильно размеченных результатов.
// В реальной жизни в большинстве случаев эти данные будут собраны, а не сгенерированы.
// Например, у нас может быть набор изображений рукописных цифр и
// набор соответствующих цифр
function generateDataSets() {
// Генерируем ТРЕНИРОВОЧНЫЕ данные.
// Эти данные будут использоваться для обучения модели.
// Перед тем, как нейрончик вырастет и сможет принимать решения самостоятельно,
// мы должны объяснить ему, что такое хорошо и что такое плохо с помощью
// тренировочных примеров.
// xTrain -> [0, 1, 2, ...],
// yTrain -> [32, 33.8, 35.6, ...]
const xTrain = []
const yTrain = []
for (let x = 0; x < 100; x += 1) {
const y = celsiusToFahrenheit(x)
xTrain.push(x)
yTrain.push(y)
}
// Генерируем ТЕСТОВЫЕ данные.
// Эти данные будут использоваться для оценки того, насколько хорошо модель работает с данными,
// которых она не видела в процессе обучения. Здесь мы можем увидеть,
// что наш "ребенок" вырос и может принимать решения самостоятельно.
// xTest -> [0.5, 1.5, 2.5, ...]
// yTest -> [32.9, 34.7, 36.5, ...]
const xTest = []
const yTest = []
// Начиная с 0.5 и используя такой же шаг 1,
// который мы использовали для тренировочного набора,
// мы обеспечиваем уникальность данных
for (let x = 0.5; x < 100; x += 1) {
const y = celsiusToFahrenheit(x)
xTest.push(x)
yTest.push(y)
}
return [xTrain, yTrain, xTest, yTest]
}
// Вычисляем стоимость (ошибку) между правильным значением 'y' и
// 'prediction' (предсказанием), сделанным нейрончиком
function predictionCost(y, prediction) {
// Это просто разница между двумя значениями.
// Чем ближе значения друг к другу, тем меньше разница.
// Мы используем здесь степень 2 только для того, чтобы избавиться от отрицательных чисел,
// поэтому (1 - 2) ^ 2 = (2 - 1) ^ 2.
// Результат делится на 2 просто для упрощения дальнейшей формулы обратного распространения (см. ниже)
return (y - prediction) ** 2 / 2 // например -> 235.6
}
// Прямое распространение.
// Эта функция берет все примеры из тренировочных наборов xTrain и yTrain
// и вычисляет предсказания модели для каждого примера из xTrain.
// По пути она также вычисляет среднюю стоимость предсказаний
function forwardPropagation(model, xTrain, yTrain) {
const m = xTrain.length
const predictions = []
let cost = 0
for (let i = 0; i < m; i += 1) {
const prediction = model.predict(xTrain[i])
cost += predictionCost(yTrain[i], prediction)
predictions.push(prediction)
}
// Нас интересует средняя стоимость
cost /= m
return [predictions, cost]
}
// Обратное распространение.
// В этом месте машинное обучение выглядит как магия.
// Ключевой концепцией здесь является производная (derivative), которая показывает, какой шаг нужно предпринять, чтобы
// приблизиться к минимуму функции стоимости. Помните, нахождение минимальной функции стоимости -
// конечная цель процесса обучения. Функция стоимости выглядит следующим образом:
// (y - prediction) ^ 2 * 1/2, где prediction = x * w + b.
function backwardPropagation(predictions, xTrain, yTrain) {
const m = xTrain.length
// В начале мы не знаем, как менять параметры 'w' и 'b'.
// Поэтому устанавливаем шаг изменения для каждого параметра в значение 0
let dW = 0
let dB = 0
for (let i = 0; i < m; i += 1) {
// Это производная функции стоимости параметра 'w'.
// Она показывает, в каком направлении (положительный/отрицательный знак 'dW') и
// на сколько (абсолютное значение 'dW') параметр 'w' должен быть изменен
dW += (yTrain[i] - predictions[i]) * xTrain[i]
// Это производная функции стоимости параметра 'b'.
// Она показывает, в каком направлении (знак 'dB') и
// на сколько (абсолютное значение 'dB') параметр 'b' должен быть изменен
dB += yTrain[i] - predictions[i]
}
// Нас интересуют средняя дельта каждого параметра
dW /= m
dB /= m
return [dW, dB]
}
// Обучает модель.
// Это "учитель" нашего нейрончика:
// - он проводит некоторое время (epochs) с нашим глупым нейрончиком и пытается его чему-то научить,
// - он использует специальные "книги" (наборы данных xTrain и yTrain) для обучения,
// - он заставляет ребенка учиться усерднее (быстрее) с помощью параметра оценки обучения 'alpha'
// (чем сильнее стимул, тем быстрее модель учится, но если учитель будет давить слишком сильно
// у "ребенка" может случиться нервный срыв, и больше он не сможет учиться)
function trainModel(model, epochs, alpha, xTrain, yTrain) {
// История обучения модели.
// Она может содержать хорошие или плохие "оценки" (стоимость),
// полученные в процессе обучения
const costHistory = []
// Перебираем эпохи
for (let i = 0; i < epochs; i += 1) {
// Прямое распространение для всех тренировочных примеров.
// Сохраняем стоимость текущей итерации.
// Это поможет анализировать обучение модели
const [predictions, cost] = forwardPropagation(model, xTrain, yTrain)
costHistory.push(cost)
// Обратное распространение. Учимся на ошибках.
// Эта функция возвращает небольшие модификации, которые нужно применить к параметрам 'w' и 'b',
// чтобы сделать предсказания более точными
const [dW, dB] = backwardPropagation(predictions, xTrain, yTrain)
// Модифицируем параметры нейрончика для повышения точности его предсказаний
nanoNeuron.w += alpha * dW
nanoNeuron.b += alpha * dB
}
// Возвращаем историю обучения для анализа и визуализации
return costHistory
}
// ===
// Создаем экземпляр модели.
// В данный момент нейрончику неизвестны значения параметров 'w' и 'b'.
// Устанавливаем их произвольно
const w = Math.random() // например -> 0.9492
const b = Math.random() // например -> 0.4570
const nanoNeuron = new NanoNeuron(w, b)
// Генерируем тренировочные и тестовые наборы данных
const [xTrain, yTrain, xTest, yTest] = generateDataSets()
// Обучаем модель небольшими шагами (0.0005) в течение 70000 эпох.
// Можете попробовать другие значения, они определены эмпирическим путем
const epochs = 70000
const alpha = 0.0005
const trainingCostHistory = trainModel(
nanoNeuron,
epochs,
alpha,
xTrain,
yTrain,
)
// Проверим, как менялась стоимость в процессе обучения.
// Мы ожидаем, что стоимость после обучения будет значительно ниже, чем до него.
// Это будет означать, что наш нейрончик стал умнее. Но возможно и обратное
console.log('Стоимость до обучения:', trainingCostHistory[0]) // например -> 4694.3335043
console.log('Стоимость после обучения:', trainingCostHistory[epochs - 1]) // например -> 0.0000024
// Взглянем на параметры нейрончика, чтобы увидеть, чему он научился.
// Мы ожидаем, что значения параметров 'w' и 'b' модели будут близки к истинным значениям,
// которые используются в функции celsiusToFahrenheit() (w = 1.8 и b = 32)
console.log(
'Параметры нейрончика:',
JSON.stringify({ w: nanoNeuron.w, b: nanoNeuron.b }, null, 2),
) // например -> { w: 1.8, b: 31.99 }
// Оцениваем точность модели на тестовых данных, чтобы увидеть, насколько хорошо она обрабатывает неизвестные данные.
// Мы ожидаем, что стоимость тестовых предсказаний будет близкой к стоимости тренировочных предсказаний.
// Это будет означать, что нейрончик хорошо справляется как с тренировочными, так и с тестовыми данными
const [testPredictions, testCost] = forwardPropagation(nanoNeuron, xTest, yTest)
console.log('Стоимость тестовых предсказаний:', testCost) // например -> 0.0000023
// После того, как "ребенок" хорошо показал себя в "школе" в процессе обучения и хорошо справился с тестовыми данными,
// мы можем назвать его "умным" и задать ему парочку вопросов
const tempInCelsius = 70
const customPrediction = nanoNeuron.predict(tempInCelsius)
console.log(
`Нейрончик "думает", что ${tempInCelsius}°C в градусах Фаренгейта:`,
customPrediction,
) // -> 158.0002
console.log('Правильный ответ:', celsiusToFahrenheit(tempInCelsius)) // -> 158
// Очень близко! Нейрончик хорош, но не идеален, как все люди :)
Пропущенные концепции машинного обучения
Следующие концепции машинного обучения были пропущены или упрощены.
Разделение данных
Обычно, у нас имеется один большой набор данных. Часто он разделяется в пропорции 70/30 для тренировочного/тестового набора (это зависит от количества примеров). Данные должны произвольно перемешиваться перед разделением. Если примеров много (например, миллионы), то пропорция может быть ближе к 90/10 или даже к 95/5.
Сеть
Как правило, нейроны не используются по отдельности. Настоящая сила заключается в нейронных сетях. Сеть можно научить гораздо более сложным вещам. Наш нейрончик больше похож на линейную регрессию, чем на нейронную сеть.
Нормализация
Перед обучением входные данные лучше нормализовать.
Векторная реализация
Для сетей векторные (матричные) вычисления работают намного быстрее, чем циклы for
.
Минимум функции стоимости
Используемая нами функция стоимости очень упрощена. Она должна содержать логарифмические компоненты. Обратите внимание, что изменение функции стоимости повлечет изменение ее производных, поэтому в обратном распространении также надо будет использовать другие формулы.
Функция активации
Обычно, результат нейрона пропускается через функцию активации, такую как сигмоида, ReLU или аналоги.